handoff fixes
This commit is contained in:
parent
aabb63f947
commit
3f4bcc26d9
4 changed files with 137 additions and 28 deletions
|
|
@ -1,8 +1,14 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
HANDOFF_LABELS = ["Important", "Finance", "Clients", "Projects", "Personal", "Support"]
|
ALL_MAIL_LABEL = "All mail"
|
||||||
|
HANDOFF_LABELS = [ALL_MAIL_LABEL, "Important", "Finance", "Clients", "Projects", "Personal", "Support"]
|
||||||
|
ALL_TYPES_ID = "all"
|
||||||
|
|
||||||
EMAIL_TYPE_META = {
|
EMAIL_TYPE_META = {
|
||||||
|
"all": {
|
||||||
|
"title": "All types",
|
||||||
|
"patterns": "",
|
||||||
|
},
|
||||||
"utility": {
|
"utility": {
|
||||||
"title": "Utility",
|
"title": "Utility",
|
||||||
"patterns": r"account update|alert|notice|security|verification|password|service information|noreply|no-reply",
|
"patterns": r"account update|alert|notice|security|verification|password|service information|noreply|no-reply",
|
||||||
|
|
@ -97,11 +103,28 @@ def evaluate_handoff(
|
||||||
inferred_labels = infer_labels(email)
|
inferred_labels = infer_labels(email)
|
||||||
inferred_types = infer_email_types(email)
|
inferred_types = infer_email_types(email)
|
||||||
|
|
||||||
label_ok = not labels or any(label in inferred_labels for label in labels)
|
all_mail = ALL_MAIL_LABEL in labels
|
||||||
matched_labels = [label for label in labels if label in inferred_labels] if labels else inferred_labels[:1]
|
all_types = ALL_TYPES_ID in email_types
|
||||||
|
selected_labels = [label for label in labels if label != ALL_MAIL_LABEL]
|
||||||
|
selected_types = [item for item in email_types if item != ALL_TYPES_ID]
|
||||||
|
|
||||||
type_ok = not email_types or any(item in inferred_types for item in email_types)
|
label_ok = all_mail or not selected_labels or any(
|
||||||
matched_types = [item for item in email_types if item in inferred_types] if email_types else inferred_types[:1]
|
label in inferred_labels for label in selected_labels
|
||||||
|
)
|
||||||
|
matched_labels = (
|
||||||
|
[ALL_MAIL_LABEL]
|
||||||
|
if all_mail
|
||||||
|
else [label for label in selected_labels if label in inferred_labels]
|
||||||
|
)
|
||||||
|
|
||||||
|
type_ok = all_types or not selected_types or any(
|
||||||
|
item in inferred_types for item in selected_types
|
||||||
|
)
|
||||||
|
matched_types = (
|
||||||
|
[ALL_TYPES_ID]
|
||||||
|
if all_types
|
||||||
|
else [item for item in selected_types if item in inferred_types]
|
||||||
|
)
|
||||||
|
|
||||||
instruction_ok, instruction_reason = instruction_matches(instructions, email)
|
instruction_ok, instruction_reason = instruction_matches(instructions, email)
|
||||||
would_handoff = label_ok and type_ok and instruction_ok
|
would_handoff = label_ok and type_ok and instruction_ok
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from app.services.handoff_evaluator import evaluate_handoff
|
from app.services.handoff_evaluator import ALL_MAIL_LABEL, ALL_TYPES_ID, evaluate_handoff
|
||||||
|
|
||||||
|
|
||||||
def test_handoff_requires_all_selected_filters():
|
def test_handoff_requires_all_selected_filters():
|
||||||
|
|
@ -31,3 +31,35 @@ def test_handoff_does_not_match_unrelated_instruction():
|
||||||
instructions="Forward when a refund requires approval.",
|
instructions="Forward when a refund requires approval.",
|
||||||
)
|
)
|
||||||
assert result["would_handoff"] is False
|
assert result["would_handoff"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_handoff_all_mail_matches_generic_inbound():
|
||||||
|
email = {
|
||||||
|
"subject": "test handoff",
|
||||||
|
"snippet": "did you receive this?",
|
||||||
|
"body_text": "did you receive this?",
|
||||||
|
}
|
||||||
|
result = evaluate_handoff(
|
||||||
|
email=email,
|
||||||
|
labels=[ALL_MAIL_LABEL],
|
||||||
|
email_types=[ALL_TYPES_ID],
|
||||||
|
instructions="",
|
||||||
|
)
|
||||||
|
assert result["would_handoff"] is True
|
||||||
|
assert result["label_matched"] is True
|
||||||
|
assert result["type_matched"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_handoff_selected_categories_do_not_match_generic_email():
|
||||||
|
email = {
|
||||||
|
"subject": "hi",
|
||||||
|
"snippet": "check the inbox please",
|
||||||
|
"body_text": "check the inbox please",
|
||||||
|
}
|
||||||
|
result = evaluate_handoff(
|
||||||
|
email=email,
|
||||||
|
labels=["Important", "Finance", "Clients"],
|
||||||
|
email_types=["transactional"],
|
||||||
|
instructions="",
|
||||||
|
)
|
||||||
|
assert result["would_handoff"] is False
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Mail, UserRound, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import AgentSpark from "./AgentSpark";
|
import AgentSpark from "./AgentSpark";
|
||||||
import {
|
import {
|
||||||
|
ALL_MAIL_LABEL,
|
||||||
|
ALL_TYPES_ID,
|
||||||
buildHandoffSummary,
|
buildHandoffSummary,
|
||||||
EMAIL_TYPE_OPTIONS,
|
EMAIL_TYPE_OPTIONS,
|
||||||
HANDOFF_LABELS,
|
HANDOFF_LABELS,
|
||||||
|
|
@ -62,19 +64,33 @@ export default function HumanHandoffDrawer({
|
||||||
);
|
);
|
||||||
|
|
||||||
function toggleLabel(label) {
|
function toggleLabel(label) {
|
||||||
|
if (label === ALL_MAIL_LABEL) {
|
||||||
setLabels((current) => (
|
setLabels((current) => (
|
||||||
current.includes(label)
|
current.includes(ALL_MAIL_LABEL) ? [] : [ALL_MAIL_LABEL]
|
||||||
? current.filter((item) => item !== label)
|
|
||||||
: [...current, label]
|
|
||||||
));
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLabels((current) => {
|
||||||
|
const withoutAll = current.filter((item) => item !== ALL_MAIL_LABEL);
|
||||||
|
return withoutAll.includes(label)
|
||||||
|
? withoutAll.filter((item) => item !== label)
|
||||||
|
: [...withoutAll, label];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEmailType(typeId) {
|
function toggleEmailType(typeId) {
|
||||||
|
if (typeId === ALL_TYPES_ID) {
|
||||||
setEmailTypes((current) => (
|
setEmailTypes((current) => (
|
||||||
current.includes(typeId)
|
current.includes(ALL_TYPES_ID) ? [] : [ALL_TYPES_ID]
|
||||||
? current.filter((item) => item !== typeId)
|
|
||||||
: [...current, typeId]
|
|
||||||
));
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailTypes((current) => {
|
||||||
|
const withoutAll = current.filter((item) => item !== ALL_TYPES_ID);
|
||||||
|
return withoutAll.includes(typeId)
|
||||||
|
? withoutAll.filter((item) => item !== typeId)
|
||||||
|
: [...withoutAll, typeId];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertExample(example) {
|
function insertExample(example) {
|
||||||
|
|
@ -137,7 +153,10 @@ export default function HumanHandoffDrawer({
|
||||||
|
|
||||||
<section className="handoff-form-section">
|
<section className="handoff-form-section">
|
||||||
<h3>Hand off emails with these labels</h3>
|
<h3>Hand off emails with these labels</h3>
|
||||||
<p className="handoff-helper">An email matching any selected label will be eligible for handoff.</p>
|
<p className="handoff-helper">
|
||||||
|
These are content-based handoff categories, not inbox tabs. Gentic infers them from
|
||||||
|
subject and body text. Choose <strong>All mail</strong> to hand off every incoming email.
|
||||||
|
</p>
|
||||||
<div className="handoff-label-toolbar">
|
<div className="handoff-label-toolbar">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
|
@ -145,7 +164,13 @@ export default function HumanHandoffDrawer({
|
||||||
value={labelQuery}
|
value={labelQuery}
|
||||||
onChange={(event) => setLabelQuery(event.target.value)}
|
onChange={(event) => setLabelQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={() => setLabels([...HANDOFF_LABELS])}>Select all</button>
|
<button type="button" onClick={() => setLabels([ALL_MAIL_LABEL])}>All mail</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLabels(HANDOFF_LABELS.filter((label) => label !== ALL_MAIL_LABEL))}
|
||||||
|
>
|
||||||
|
Select categories
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="handoff-label-grid">
|
<div className="handoff-label-grid">
|
||||||
{filteredLabels.map((label) => (
|
{filteredLabels.map((label) => (
|
||||||
|
|
@ -163,7 +188,10 @@ export default function HumanHandoffDrawer({
|
||||||
|
|
||||||
<section className="handoff-form-section">
|
<section className="handoff-form-section">
|
||||||
<h3>Hand off these types of email</h3>
|
<h3>Hand off these types of email</h3>
|
||||||
<p className="handoff-helper">Emails matching any selected type can be considered for handoff.</p>
|
<p className="handoff-helper">
|
||||||
|
Types are also inferred from email content. Choose <strong>All types</strong> if you
|
||||||
|
do not want to restrict by category.
|
||||||
|
</p>
|
||||||
<div className="handoff-type-grid">
|
<div className="handoff-type-grid">
|
||||||
{EMAIL_TYPE_OPTIONS.map((type) => (
|
{EMAIL_TYPE_OPTIONS.map((type) => (
|
||||||
<label key={type.id} className={`handoff-type-card${emailTypes.includes(type.id) ? " selected" : ""}`}>
|
<label key={type.id} className={`handoff-type-card${emailTypes.includes(type.id) ? " selected" : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
export const HANDOFF_LABELS = ["Important", "Finance", "Clients", "Projects", "Personal", "Support"];
|
export const ALL_MAIL_LABEL = "All mail";
|
||||||
|
export const ALL_TYPES_ID = "all";
|
||||||
|
|
||||||
|
export const HANDOFF_LABELS = [
|
||||||
|
ALL_MAIL_LABEL,
|
||||||
|
"Important",
|
||||||
|
"Finance",
|
||||||
|
"Clients",
|
||||||
|
"Projects",
|
||||||
|
"Personal",
|
||||||
|
"Support",
|
||||||
|
];
|
||||||
|
|
||||||
export const EMAIL_TYPE_OPTIONS = [
|
export const EMAIL_TYPE_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: ALL_TYPES_ID,
|
||||||
|
title: "All types",
|
||||||
|
description: "Any incoming email, regardless of category",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "utility",
|
id: "utility",
|
||||||
title: "Utility",
|
title: "Utility",
|
||||||
|
|
@ -46,10 +62,14 @@ export function buildHandoffSummary({
|
||||||
emailTypes,
|
emailTypes,
|
||||||
instructions,
|
instructions,
|
||||||
}) {
|
}) {
|
||||||
const labelText = labels?.length
|
const labelText = labels?.includes(ALL_MAIL_LABEL)
|
||||||
|
? "All incoming emails"
|
||||||
|
: labels?.length
|
||||||
? `${joinOrNone(labels)} emails`
|
? `${joinOrNone(labels)} emails`
|
||||||
: "eligible emails";
|
: "eligible emails";
|
||||||
const typeText = emailTypes?.length
|
const typeText = emailTypes?.includes(ALL_TYPES_ID)
|
||||||
|
? "any type"
|
||||||
|
: emailTypes?.length
|
||||||
? typeTitles(emailTypes).join(" or ").toLowerCase()
|
? typeTitles(emailTypes).join(" or ").toLowerCase()
|
||||||
: "any type";
|
: "any type";
|
||||||
const instructionText = instructions?.trim()
|
const instructionText = instructions?.trim()
|
||||||
|
|
@ -61,8 +81,14 @@ export function buildHandoffSummary({
|
||||||
|
|
||||||
export function buildRuleCardSummary(rule) {
|
export function buildRuleCardSummary(rule) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (rule.labels?.length) parts.push(rule.labels.join(", "));
|
if (rule.labels?.includes(ALL_MAIL_LABEL)) {
|
||||||
if (rule.email_types?.length) {
|
parts.push("All mail");
|
||||||
|
} else if (rule.labels?.length) {
|
||||||
|
parts.push(rule.labels.join(", "));
|
||||||
|
}
|
||||||
|
if (rule.email_types?.includes(ALL_TYPES_ID)) {
|
||||||
|
parts.push("All types");
|
||||||
|
} else if (rule.email_types?.length) {
|
||||||
parts.push(typeTitles(rule.email_types).join(", "));
|
parts.push(typeTitles(rule.email_types).join(", "));
|
||||||
}
|
}
|
||||||
const meta = parts.length ? parts.join(" • ") : "All eligible emails";
|
const meta = parts.length ? parts.join(" • ") : "All eligible emails";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue