handoff fixes
This commit is contained in:
parent
3f4bcc26d9
commit
6c52cd19cb
8 changed files with 561 additions and 59 deletions
|
|
@ -264,13 +264,23 @@ async def get_handoff_conversation(
|
||||||
sender_address = (message.get("sender") or {}).get("address", "").lower()
|
sender_address = (message.get("sender") or {}).get("address", "").lower()
|
||||||
role = "human" if sender_address == human_email else "agent"
|
role = "human" if sender_address == human_email else "agent"
|
||||||
event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"]
|
event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"]
|
||||||
|
raw_body = message.get("raw_body")
|
||||||
|
new_reply_content = message.get("new_reply_content")
|
||||||
|
display_body = new_reply_content or message.get("body_text", "")
|
||||||
messages.append(
|
messages.append(
|
||||||
HandoffMessageRead(
|
HandoffMessageRead(
|
||||||
id=str(message["_id"]),
|
id=str(message["_id"]),
|
||||||
role=role,
|
role=role,
|
||||||
subject=message.get("subject", ""),
|
subject=message.get("subject", ""),
|
||||||
snippet=message.get("snippet", ""),
|
snippet=message.get("snippet", "") or display_body[:240],
|
||||||
body_text=message.get("body_text", ""),
|
body_text=display_body,
|
||||||
|
new_reply_content=new_reply_content,
|
||||||
|
raw_body=raw_body,
|
||||||
|
has_quoted_text=bool(
|
||||||
|
raw_body
|
||||||
|
and new_reply_content
|
||||||
|
and raw_body.strip() != new_reply_content.strip()
|
||||||
|
),
|
||||||
event_at=event_at,
|
event_at=event_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,9 @@ class HandoffMessageRead(BaseModel):
|
||||||
subject: str
|
subject: str
|
||||||
snippet: str
|
snippet: str
|
||||||
body_text: str
|
body_text: str
|
||||||
|
new_reply_content: str | None = None
|
||||||
|
raw_body: str | None = None
|
||||||
|
has_quoted_text: bool = False
|
||||||
event_at: datetime
|
event_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
198
backend/app/services/handoff_email_templates.py
Normal file
198
backend/app/services/handoff_email_templates.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
DEBUG_REASON_MARKERS = (
|
||||||
|
"step passes",
|
||||||
|
"classifier",
|
||||||
|
"rule evaluation",
|
||||||
|
"no handoff instructions were set",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_external_subject(subject: str) -> str:
|
||||||
|
value = (subject or "(No subject)").strip()
|
||||||
|
for prefix in ("Re: ", "RE: ", "re: "):
|
||||||
|
if value.startswith(prefix):
|
||||||
|
value = value[len(prefix) :].strip()
|
||||||
|
for prefix in ("Human Handoff · ", "Human Handoff: "):
|
||||||
|
if value.lower().startswith(prefix.lower()):
|
||||||
|
value = value[len(prefix) :].strip()
|
||||||
|
return value or "(No subject)"
|
||||||
|
|
||||||
|
|
||||||
|
def handoff_email_subject(external_subject: str) -> str:
|
||||||
|
return f"Human Handoff · {_clean_external_subject(external_subject)}"
|
||||||
|
|
||||||
|
|
||||||
|
def external_reply_subject(external_subject: str) -> str:
|
||||||
|
cleaned = _clean_external_subject(external_subject)
|
||||||
|
return f"Re: {cleaned}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_reason_for_human(reason: str | None, *, is_update: bool = False) -> str | None:
|
||||||
|
if is_update:
|
||||||
|
return None
|
||||||
|
if not reason or not reason.strip():
|
||||||
|
return None
|
||||||
|
lower = reason.strip().lower()
|
||||||
|
if any(marker in lower for marker in DEBUG_REASON_MARKERS):
|
||||||
|
return "This conversation matched your Human Handoff settings."
|
||||||
|
if lower in {
|
||||||
|
"matched handoff rules.",
|
||||||
|
"new message in the linked conversation.",
|
||||||
|
}:
|
||||||
|
return "This conversation requires human attention."
|
||||||
|
return reason.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_initial_handoff_text(
|
||||||
|
*,
|
||||||
|
external_sender: str,
|
||||||
|
external_subject: str,
|
||||||
|
external_body: str,
|
||||||
|
agent_email: str,
|
||||||
|
handoff_reason: str | None,
|
||||||
|
) -> str:
|
||||||
|
reason = format_reason_for_human(handoff_reason)
|
||||||
|
lines = [
|
||||||
|
"GENTIC — HUMAN HANDOFF",
|
||||||
|
"",
|
||||||
|
"This conversation needs your attention.",
|
||||||
|
"",
|
||||||
|
f"From: {external_sender}",
|
||||||
|
f"Subject: {_clean_external_subject(external_subject)}",
|
||||||
|
"",
|
||||||
|
"LATEST MESSAGE",
|
||||||
|
"",
|
||||||
|
external_body.strip(),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if reason:
|
||||||
|
lines.extend(["WHY YOU'RE RECEIVING THIS", "", reason, ""])
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"REPLY NORMALLY",
|
||||||
|
"",
|
||||||
|
f"Reply to this email and Gentic will send only your new reply to the "
|
||||||
|
f"original sender through {agent_email}.",
|
||||||
|
"",
|
||||||
|
"This private handoff conversation and quoted history will not be shared "
|
||||||
|
"with the sender.",
|
||||||
|
"",
|
||||||
|
"—",
|
||||||
|
"Gentic Human Handoff",
|
||||||
|
"Private Agent ↔ Human conversation",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_initial_handoff_html(
|
||||||
|
*,
|
||||||
|
external_sender: str,
|
||||||
|
external_subject: str,
|
||||||
|
external_body: str,
|
||||||
|
agent_email: str,
|
||||||
|
handoff_reason: str | None,
|
||||||
|
) -> str:
|
||||||
|
reason = format_reason_for_human(handoff_reason)
|
||||||
|
reason_block = ""
|
||||||
|
if reason:
|
||||||
|
reason_block = f"""
|
||||||
|
<tr><td style="padding:18px 24px 0;">
|
||||||
|
<p style="margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Why you're receiving this</p>
|
||||||
|
<p style="margin:0;font-size:13px;line-height:1.55;color:#4A4652;">{escape(reason)}</p>
|
||||||
|
</td></tr>"""
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html><body style="margin:0;padding:0;background:#F5F4F2;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F5F4F2;padding:24px 12px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;background:#FFFFFF;border:1px solid #E8E5EA;border-radius:12px;overflow:hidden;">
|
||||||
|
<tr><td style="padding:24px 24px 8px;">
|
||||||
|
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#7357F6;">✦ Gentic</p>
|
||||||
|
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#171424;">Human Handoff</h1>
|
||||||
|
<p style="margin:0;font-size:13px;line-height:1.5;color:#77758A;">This conversation needs your attention.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:8px 24px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#FAF9FB;border:1px solid #ECE9EF;border-radius:8px;">
|
||||||
|
<tr><td style="padding:14px;">
|
||||||
|
<p style="margin:0 0 10px;font-size:10px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">From</p>
|
||||||
|
<p style="margin:0 0 14px;font-size:13px;font-weight:500;color:#292536;">{escape(external_sender)}</p>
|
||||||
|
<p style="margin:0 0 10px;font-size:10px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Subject</p>
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#292536;">{escape(_clean_external_subject(external_subject))}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:18px 24px 0;">
|
||||||
|
<p style="margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#8A8695;">Latest message</p>
|
||||||
|
<div style="border-left:3px solid #7357F6;padding:4px 0 4px 14px;font-size:14px;line-height:1.55;color:#292536;white-space:pre-wrap;">{escape(external_body.strip())}</div>
|
||||||
|
</td></tr>
|
||||||
|
{reason_block}
|
||||||
|
<tr><td style="padding:18px 24px 24px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F7F5FF;border:1px solid #E5DFFF;border-radius:8px;">
|
||||||
|
<tr><td style="padding:14px;">
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;font-weight:650;color:#5633CE;">↩ Reply normally</p>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.5;color:#625D70;">
|
||||||
|
Reply to this email and Gentic will send your response to the original sender as
|
||||||
|
<strong>{escape(agent_email)}</strong>. Only the new text you write will be sent.
|
||||||
|
This handoff email and quoted history stay private.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 24px 24px;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:10px;color:#A19DAA;">Gentic Human Handoff<br>Private Agent ↔ Human conversation</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_update_handoff_text(
|
||||||
|
*,
|
||||||
|
external_sender: str,
|
||||||
|
external_body: str,
|
||||||
|
agent_email: str,
|
||||||
|
) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"Customer replied",
|
||||||
|
"",
|
||||||
|
external_sender,
|
||||||
|
"",
|
||||||
|
external_body.strip(),
|
||||||
|
"",
|
||||||
|
f"Reply normally to respond through {agent_email}.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_update_handoff_html(
|
||||||
|
*,
|
||||||
|
external_sender: str,
|
||||||
|
external_body: str,
|
||||||
|
agent_email: str,
|
||||||
|
) -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html><body style="margin:0;padding:0;background:#F5F4F2;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F5F4F2;padding:24px 12px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;background:#FFFFFF;border:1px solid #E8E5EA;border-radius:12px;">
|
||||||
|
<tr><td style="padding:24px;">
|
||||||
|
<p style="margin:0 0 8px;font-size:12px;font-weight:600;color:#7357F6;">✦ Gentic</p>
|
||||||
|
<h1 style="margin:0 0 16px;font-size:18px;font-weight:700;color:#171424;">Customer replied</h1>
|
||||||
|
<p style="margin:0 0 12px;font-size:13px;font-weight:500;color:#292536;">{escape(external_sender)}</p>
|
||||||
|
<div style="border-left:3px solid #7357F6;padding:4px 0 4px 14px;font-size:14px;line-height:1.55;color:#292536;white-space:pre-wrap;">{escape(external_body.strip())}</div>
|
||||||
|
<p style="margin:18px 0 0;font-size:12px;line-height:1.5;color:#625D70;">
|
||||||
|
Reply normally to respond through <strong>{escape(agent_email)}</strong>.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_relay_confirmation_text(customer_body: str) -> str:
|
||||||
|
return f"Sent to customer:\n\n{customer_body.strip()}"
|
||||||
|
|
@ -4,9 +4,19 @@ from datetime import datetime, timezone
|
||||||
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
|
from app.services.handoff_email_templates import (
|
||||||
|
build_initial_handoff_html,
|
||||||
|
build_initial_handoff_text,
|
||||||
|
build_relay_confirmation_text,
|
||||||
|
build_update_handoff_html,
|
||||||
|
build_update_handoff_text,
|
||||||
|
external_reply_subject,
|
||||||
|
handoff_email_subject,
|
||||||
|
)
|
||||||
from app.services.handoff_evaluator import evaluate_handoff
|
from app.services.handoff_evaluator import evaluate_handoff
|
||||||
from app.services.mail_sender import deliver_email
|
from app.services.mail_sender import deliver_email
|
||||||
from app.services.message_ids import build_references_header, normalize_message_id, parse_message_id_list
|
from app.services.message_ids import normalize_message_id, parse_message_id_list
|
||||||
|
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
|
||||||
from app.services.thread_service import (
|
from app.services.thread_service import (
|
||||||
attach_message_to_thread,
|
attach_message_to_thread,
|
||||||
message_event_at,
|
message_event_at,
|
||||||
|
|
@ -42,36 +52,8 @@ def _snippet(text: str, limit: int = 240) -> str:
|
||||||
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
|
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
|
||||||
|
|
||||||
|
|
||||||
def _handoff_subject(external_subject: str) -> str:
|
def _handoff_reply_subject(external_subject: str) -> str:
|
||||||
subject = (external_subject or "(No subject)").strip()
|
return f"Re: {handoff_email_subject(external_subject)}"
|
||||||
if subject.lower().startswith("human handoff:"):
|
|
||||||
return subject
|
|
||||||
return f"Human Handoff: {subject}"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_handoff_body(
|
|
||||||
*,
|
|
||||||
external_sender: str,
|
|
||||||
external_subject: str,
|
|
||||||
external_body: str,
|
|
||||||
handoff_reason: str,
|
|
||||||
agent_email: str,
|
|
||||||
) -> str:
|
|
||||||
return (
|
|
||||||
"Human Handoff\n\n"
|
|
||||||
"A conversation needs your attention.\n\n"
|
|
||||||
f"From\n{external_sender}\n\n"
|
|
||||||
f"Subject\n{external_subject}\n\n"
|
|
||||||
"Latest message\n\n"
|
|
||||||
f"\"{external_body.strip()}\"\n\n"
|
|
||||||
"Why this was handed off\n\n"
|
|
||||||
f"{handoff_reason}\n\n"
|
|
||||||
"────────────────────────────────────────\n\n"
|
|
||||||
f"Reply to this email to respond to the original sender through {agent_email}.\n\n"
|
|
||||||
"Your reply will be sent to the customer.\n\n"
|
|
||||||
"────────────────────────────────────────\n\n"
|
|
||||||
"Gentic Human Handoff"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_empty_handoff_thread(
|
async def create_empty_handoff_thread(
|
||||||
|
|
@ -114,6 +96,47 @@ async def _record_message_link(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_external_customer_message(
|
||||||
|
database,
|
||||||
|
account_id: str,
|
||||||
|
external_thread_id: ObjectId,
|
||||||
|
customer_address: str,
|
||||||
|
) -> dict | None:
|
||||||
|
customer = customer_address.lower()
|
||||||
|
pipeline = [
|
||||||
|
{
|
||||||
|
"$match": {
|
||||||
|
"account_id": account_id,
|
||||||
|
"thread_id": external_thread_id,
|
||||||
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
"deletion_state": {"$in": [None, ""]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"$sort": {"event_at": -1, "_id": -1}},
|
||||||
|
{
|
||||||
|
"$lookup": {
|
||||||
|
"from": "messages",
|
||||||
|
"localField": "message_id",
|
||||||
|
"foreignField": "_id",
|
||||||
|
"as": "message",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"$unwind": "$message"},
|
||||||
|
{
|
||||||
|
"$match": {
|
||||||
|
"$or": [
|
||||||
|
{"message.direction": "inbound"},
|
||||||
|
{"message.sender.address": customer},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"$limit": 1},
|
||||||
|
]
|
||||||
|
cursor = await database.mailbox_entries.aggregate(pipeline)
|
||||||
|
rows = [row async for row in cursor]
|
||||||
|
return rows[0]["message"] if rows else None
|
||||||
|
|
||||||
|
|
||||||
async def _latest_external_message(database, account_id: str, external_thread_id: ObjectId) -> dict | None:
|
async def _latest_external_message(database, account_id: str, external_thread_id: ObjectId) -> dict | None:
|
||||||
pipeline = [
|
pipeline = [
|
||||||
{
|
{
|
||||||
|
|
@ -250,8 +273,12 @@ async def store_handoff_message(
|
||||||
link_relationship: str | None = None,
|
link_relationship: str | None = None,
|
||||||
external_message_id: ObjectId | None = None,
|
external_message_id: ObjectId | None = None,
|
||||||
event_at: datetime | None = None,
|
event_at: datetime | None = None,
|
||||||
|
raw_body: str | None = None,
|
||||||
|
new_reply_content: str | None = None,
|
||||||
|
body_html: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
now = event_at or _now()
|
now = event_at or _now()
|
||||||
|
display_body = new_reply_content if new_reply_content is not None else body_text
|
||||||
document = {
|
document = {
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||||
|
|
@ -259,9 +286,11 @@ async def store_handoff_message(
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
"to": recipients,
|
"to": recipients,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"snippet": _snippet(body_text),
|
"snippet": _snippet(display_body),
|
||||||
"body_text": body_text,
|
"body_text": display_body,
|
||||||
"body_html": None,
|
"raw_body": raw_body,
|
||||||
|
"new_reply_content": new_reply_content,
|
||||||
|
"body_html": body_html,
|
||||||
"attachments": [],
|
"attachments": [],
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"sent_at": now if direction == "outbound" else None,
|
"sent_at": now if direction == "outbound" else None,
|
||||||
|
|
@ -331,17 +360,21 @@ async def send_handoff_to_human(
|
||||||
external_subject = external_message.get("subject", "")
|
external_subject = external_message.get("subject", "")
|
||||||
external_body = external_message.get("body_text") or external_message.get("snippet") or ""
|
external_body = external_message.get("body_text") or external_message.get("snippet") or ""
|
||||||
|
|
||||||
|
stable_subject = handoff_email_subject(external_subject)
|
||||||
if existing_handoff:
|
if existing_handoff:
|
||||||
handoff_id = existing_handoff["_id"]
|
handoff_id = existing_handoff["_id"]
|
||||||
handoff_thread_id = existing_handoff["handoff_thread_id"]
|
handoff_thread_id = existing_handoff["handoff_thread_id"]
|
||||||
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
|
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
|
||||||
threading = threading_fields_from_parent(parent) if parent else {}
|
threading = threading_fields_from_parent(parent) if parent else {}
|
||||||
subject = _handoff_subject(external_subject)
|
subject = _handoff_reply_subject(external_subject)
|
||||||
body = _build_handoff_body(
|
body_text = build_update_handoff_text(
|
||||||
|
external_sender=external_sender_text,
|
||||||
|
external_body=external_body,
|
||||||
|
agent_email=agent_email,
|
||||||
|
)
|
||||||
|
body_html = build_update_handoff_html(
|
||||||
external_sender=external_sender_text,
|
external_sender=external_sender_text,
|
||||||
external_subject=external_subject,
|
|
||||||
external_body=external_body,
|
external_body=external_body,
|
||||||
handoff_reason="New message in the linked conversation.",
|
|
||||||
agent_email=agent_email,
|
agent_email=agent_email,
|
||||||
)
|
)
|
||||||
relationship = LINK_EXTERNAL_UPDATE_RELAYED
|
relationship = LINK_EXTERNAL_UPDATE_RELAYED
|
||||||
|
|
@ -349,11 +382,18 @@ async def send_handoff_to_human(
|
||||||
handoff_thread_id = await create_empty_handoff_thread(
|
handoff_thread_id = await create_empty_handoff_thread(
|
||||||
database,
|
database,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
subject=_handoff_subject(external_subject),
|
subject=stable_subject,
|
||||||
)
|
)
|
||||||
threading = {}
|
threading = {}
|
||||||
subject = _handoff_subject(external_subject)
|
subject = stable_subject
|
||||||
body = _build_handoff_body(
|
body_text = build_initial_handoff_text(
|
||||||
|
external_sender=external_sender_text,
|
||||||
|
external_subject=external_subject,
|
||||||
|
external_body=external_body,
|
||||||
|
handoff_reason=handoff_reason,
|
||||||
|
agent_email=agent_email,
|
||||||
|
)
|
||||||
|
body_html = build_initial_handoff_html(
|
||||||
external_sender=external_sender_text,
|
external_sender=external_sender_text,
|
||||||
external_subject=external_subject,
|
external_subject=external_subject,
|
||||||
external_body=external_body,
|
external_body=external_body,
|
||||||
|
|
@ -386,8 +426,8 @@ async def send_handoff_to_human(
|
||||||
cc=[],
|
cc=[],
|
||||||
bcc=[],
|
bcc=[],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body,
|
body_text=body_text,
|
||||||
body_html=None,
|
body_html=body_html,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
in_reply_to=threading.get("in_reply_to"),
|
||||||
references=threading.get("references"),
|
references=threading.get("references"),
|
||||||
|
|
@ -402,7 +442,8 @@ async def send_handoff_to_human(
|
||||||
sender={"name": "Gentic Agent", "address": agent_email},
|
sender={"name": "Gentic Agent", "address": agent_email},
|
||||||
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=body,
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
internet_message_id=internet_message_id,
|
internet_message_id=internet_message_id,
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
in_reply_to=threading.get("in_reply_to"),
|
||||||
references=threading.get("references"),
|
references=threading.get("references"),
|
||||||
|
|
@ -458,6 +499,13 @@ async def process_human_reply(
|
||||||
rule = await _handoff_rule_for_human(database, account_id, human_email)
|
rule = await _handoff_rule_for_human(database, account_id, human_email)
|
||||||
allow_replies = rule.get("allow_replies", True) if rule else True
|
allow_replies = rule.get("allow_replies", True) if rule else True
|
||||||
|
|
||||||
|
extracted = extract_new_reply_content(
|
||||||
|
body_text=inbound_body,
|
||||||
|
body_html=inbound_parsed.get("body_html"),
|
||||||
|
)
|
||||||
|
new_reply_content = extracted["new_reply_content"].strip()
|
||||||
|
raw_body = extracted["raw_body"]
|
||||||
|
|
||||||
human_message = await store_handoff_message(
|
human_message = await store_handoff_message(
|
||||||
database,
|
database,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
|
@ -467,7 +515,10 @@ async def process_human_reply(
|
||||||
sender={"name": human_email.split("@")[0], "address": human_email},
|
sender={"name": human_email.split("@")[0], "address": human_email},
|
||||||
recipients=[{"name": "Gentic Agent", "address": agent_email}],
|
recipients=[{"name": "Gentic Agent", "address": agent_email}],
|
||||||
subject=inbound_parsed.get("subject", ""),
|
subject=inbound_parsed.get("subject", ""),
|
||||||
body_text=inbound_body,
|
body_text=raw_body,
|
||||||
|
raw_body=raw_body,
|
||||||
|
new_reply_content=new_reply_content,
|
||||||
|
body_html=inbound_parsed.get("body_html"),
|
||||||
internet_message_id=inbound_parsed.get("message_id"),
|
internet_message_id=inbound_parsed.get("message_id"),
|
||||||
in_reply_to=inbound_parsed.get("in_reply_to"),
|
in_reply_to=inbound_parsed.get("in_reply_to"),
|
||||||
references=inbound_parsed.get("references", []),
|
references=inbound_parsed.get("references", []),
|
||||||
|
|
@ -490,21 +541,56 @@ async def process_human_reply(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not new_reply_content:
|
||||||
|
logger.warning(
|
||||||
|
"handoff_reply_empty_extract handoff_id=%s account=%s",
|
||||||
|
handoff["_id"],
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
await database.human_handoffs.update_one(
|
||||||
|
{"_id": handoff["_id"]},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"status": HANDOFF_STATUS_HUMAN_REPLIED,
|
||||||
|
"current_owner": OWNER_AGENT,
|
||||||
|
"updated_at": now,
|
||||||
|
"last_human_message_at": now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not validate_external_reply_body(new_reply_content):
|
||||||
|
logger.error(
|
||||||
|
"handoff_reply_leakage_guard handoff_id=%s account=%s",
|
||||||
|
handoff["_id"],
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
external_recipient = handoff.get("external_sender") or ""
|
||||||
|
external_parent = await _latest_external_customer_message(
|
||||||
|
database,
|
||||||
|
account_id,
|
||||||
|
external_thread_id,
|
||||||
|
external_recipient,
|
||||||
|
)
|
||||||
|
if external_parent is None:
|
||||||
external_parent = await _latest_external_message(database, account_id, external_thread_id)
|
external_parent = await _latest_external_message(database, account_id, external_thread_id)
|
||||||
if external_parent is None:
|
if external_parent is None:
|
||||||
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
|
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
|
||||||
return
|
return
|
||||||
|
|
||||||
threading = threading_fields_from_parent(external_parent)
|
threading = threading_fields_from_parent(external_parent)
|
||||||
external_subject = handoff.get("external_subject") or external_parent.get("subject", "")
|
external_subject = external_reply_subject(
|
||||||
if not re.match(r"^re:\s*", external_subject, flags=re.IGNORECASE):
|
handoff.get("external_subject") or external_parent.get("subject", "")
|
||||||
external_subject = f"Re: {external_subject}"
|
)
|
||||||
|
|
||||||
external_recipient = handoff.get("external_sender") or external_parent.get("sender", {}).get("address")
|
external_recipient = external_recipient or external_parent.get("sender", {}).get("address")
|
||||||
if not external_recipient:
|
if not external_recipient:
|
||||||
return
|
return
|
||||||
|
|
||||||
customer_body = inbound_body.strip()
|
customer_body = new_reply_content
|
||||||
internet_message_id = await deliver_email(
|
internet_message_id = await deliver_email(
|
||||||
sender=agent_email,
|
sender=agent_email,
|
||||||
recipients=[external_recipient],
|
recipients=[external_recipient],
|
||||||
|
|
@ -572,8 +658,8 @@ async def process_human_reply(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
relay_body = f"Sent to customer:\n\n\"{customer_body}\""
|
relay_body = build_relay_confirmation_text(customer_body)
|
||||||
relay_subject = f"Re: {_handoff_subject(handoff.get('external_subject', ''))}"
|
relay_subject = _handoff_reply_subject(handoff.get("external_subject", ""))
|
||||||
relay_threading = threading_fields_from_parent(human_message)
|
relay_threading = threading_fields_from_parent(human_message)
|
||||||
relay_internet_id = await deliver_email(
|
relay_internet_id = await deliver_email(
|
||||||
sender=agent_email,
|
sender=agent_email,
|
||||||
|
|
@ -656,7 +742,7 @@ async def maybe_trigger_handoff(
|
||||||
existing = await find_active_handoff_for_external_thread(
|
existing = await find_active_handoff_for_external_thread(
|
||||||
database, account_id, external_thread_id
|
database, account_id, external_thread_id
|
||||||
)
|
)
|
||||||
reason = evaluation.get("instruction_reason") or rule.get("instructions") or "Matched handoff rules."
|
reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention."
|
||||||
await send_handoff_to_human(
|
await send_handoff_to_human(
|
||||||
database,
|
database,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
|
|
||||||
119
backend/app/services/reply_extractor.py
Normal file
119
backend/app/services/reply_extractor.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
# Patterns that mark the start of quoted previous-message content.
|
||||||
|
TEXT_QUOTE_MARKERS = [
|
||||||
|
re.compile(r"\n-{2,}\s*Original Message\s*-{2,}", re.IGNORECASE),
|
||||||
|
re.compile(
|
||||||
|
r"\nOn .+? wrote:\s*\n",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"\nOn .+?, .+? at .+? .+? wrote:\s*\n",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"\nFrom:\s*.+\nSent:\s*.+\nTo:\s*.+\nSubject:\s*.+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(r"\n_{5,}\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
HTML_QUOTE_PATTERNS = [
|
||||||
|
re.compile(r"<blockquote\b[^>]*>.*?</blockquote>", re.IGNORECASE | re.DOTALL),
|
||||||
|
re.compile(r'<div[^>]+class="[^"]*gmail_quote[^"]*"[^>]*>.*?</div>', re.IGNORECASE | re.DOTALL),
|
||||||
|
re.compile(r'<div[^>]+class="[^"]*gmail_attr[^"]*"[^>]*>.*?</div>', re.IGNORECASE | re.DOTALL),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIVATE_LEAK_MARKERS = [
|
||||||
|
"human handoff",
|
||||||
|
"a conversation needs your attention",
|
||||||
|
"why this was handed off",
|
||||||
|
"why you're receiving this",
|
||||||
|
"gentic human handoff",
|
||||||
|
"no handoff instructions were set",
|
||||||
|
"step passes",
|
||||||
|
"classifier returned",
|
||||||
|
"rule evaluation succeeded",
|
||||||
|
"reply normally to respond through",
|
||||||
|
"latest message",
|
||||||
|
"private agent ↔ human conversation",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_newlines(text: str) -> str:
|
||||||
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_text(html: str) -> str:
|
||||||
|
cleaned = html
|
||||||
|
for pattern in HTML_QUOTE_PATTERNS:
|
||||||
|
cleaned = pattern.sub("", cleaned)
|
||||||
|
cleaned = re.sub(r"<br\s*/?>", "\n", cleaned, flags=re.IGNORECASE)
|
||||||
|
cleaned = re.sub(r"</p\s*>", "\n", cleaned, flags=re.IGNORECASE)
|
||||||
|
cleaned = re.sub(r"<[^>]+>", "", cleaned)
|
||||||
|
return unescape(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_trailing_quoted_lines(text: str) -> str:
|
||||||
|
lines = text.split("\n")
|
||||||
|
kept: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if re.match(r"^\s*>", line):
|
||||||
|
break
|
||||||
|
kept.append(line)
|
||||||
|
return "\n".join(kept).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_at_quote_markers(text: str) -> tuple[str, str]:
|
||||||
|
normalized = _normalize_newlines(text)
|
||||||
|
earliest_index = None
|
||||||
|
for pattern in TEXT_QUOTE_MARKERS:
|
||||||
|
match = pattern.search(normalized)
|
||||||
|
if match and (earliest_index is None or match.start() < earliest_index):
|
||||||
|
earliest_index = match.start()
|
||||||
|
if earliest_index is not None:
|
||||||
|
new_content = normalized[:earliest_index].strip()
|
||||||
|
quoted = normalized[earliest_index:].strip()
|
||||||
|
return new_content, quoted
|
||||||
|
return _strip_trailing_quoted_lines(normalized), ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_new_reply_content(
|
||||||
|
*,
|
||||||
|
body_text: str | None,
|
||||||
|
body_html: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
raw_text = _normalize_newlines((body_text or "").strip())
|
||||||
|
if body_html and not raw_text:
|
||||||
|
raw_text = _html_to_text(body_html).strip()
|
||||||
|
|
||||||
|
if body_html:
|
||||||
|
html_stripped = body_html
|
||||||
|
for pattern in HTML_QUOTE_PATTERNS:
|
||||||
|
html_stripped = pattern.sub("", html_stripped)
|
||||||
|
html_text = _html_to_text(html_stripped).strip()
|
||||||
|
if html_text:
|
||||||
|
new_from_html, quoted_from_html = _split_at_quote_markers(html_text)
|
||||||
|
if new_from_html:
|
||||||
|
return {
|
||||||
|
"raw_body": raw_text or html_text,
|
||||||
|
"new_reply_content": new_from_html,
|
||||||
|
"quoted_content": quoted_from_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_content, quoted_content = _split_at_quote_markers(raw_text)
|
||||||
|
return {
|
||||||
|
"raw_body": raw_text,
|
||||||
|
"new_reply_content": new_content,
|
||||||
|
"quoted_content": quoted_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_external_reply_body(body: str) -> bool:
|
||||||
|
normalized = (body or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
lower = normalized.lower()
|
||||||
|
hits = sum(1 for marker in PRIVATE_LEAK_MARKERS if marker in lower)
|
||||||
|
return hits == 0
|
||||||
63
backend/tests/test_reply_extractor.py
Normal file
63
backend/tests/test_reply_extractor.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from app.services.handoff_email_templates import external_reply_subject, handoff_email_subject
|
||||||
|
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
|
||||||
|
|
||||||
|
SCREENSHOT_REPLY = """ahan, did you receive a response?
|
||||||
|
|
||||||
|
On Tue, Oct 6, 2026 at 2:11 AM <suyash@gentic.email> wrote:
|
||||||
|
|
||||||
|
> Human Handoff
|
||||||
|
>
|
||||||
|
> A conversation needs your attention.
|
||||||
|
>
|
||||||
|
> From
|
||||||
|
> 091rabbytes@gmail.com
|
||||||
|
>
|
||||||
|
> Subject
|
||||||
|
> are you ready?
|
||||||
|
>
|
||||||
|
> Latest message
|
||||||
|
> "lets start a threaddddd"
|
||||||
|
>
|
||||||
|
> Why this was handed off
|
||||||
|
> No handoff instructions were set, so this step passes.
|
||||||
|
>
|
||||||
|
> Reply to this email to respond to the original sender through suyash@gentic.email.
|
||||||
|
>
|
||||||
|
> Your reply will be sent to the customer.
|
||||||
|
>
|
||||||
|
> Gentic Human Handoff"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_new_reply_from_gmail_quote():
|
||||||
|
result = extract_new_reply_content(body_text=SCREENSHOT_REPLY)
|
||||||
|
assert result["new_reply_content"] == "ahan, did you receive a response?"
|
||||||
|
assert "Human Handoff" in result["quoted_content"]
|
||||||
|
assert "091rabbytes@gmail.com" in result["quoted_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracted_reply_passes_leakage_guard():
|
||||||
|
result = extract_new_reply_content(body_text=SCREENSHOT_REPLY)
|
||||||
|
assert validate_external_reply_body(result["new_reply_content"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_leakage_guard_blocks_private_template():
|
||||||
|
assert validate_external_reply_body(SCREENSHOT_REPLY) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_reply_subject_uses_customer_subject():
|
||||||
|
assert external_reply_subject("are you ready?") == "Re: are you ready?"
|
||||||
|
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handoff_subject_format():
|
||||||
|
assert handoff_email_subject("are you ready?") == "Human Handoff · are you ready?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_outlook_original_message_split():
|
||||||
|
body = """Thanks for the update.
|
||||||
|
|
||||||
|
-----Original Message-----
|
||||||
|
From: support@gentic.email
|
||||||
|
Subject: Human Handoff"""
|
||||||
|
result = extract_new_reply_content(body_text=body)
|
||||||
|
assert result["new_reply_content"] == "Thanks for the update."
|
||||||
|
|
@ -2,6 +2,29 @@ import { ArrowLeft } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { getHandoffConversation } from "../api";
|
import { getHandoffConversation } from "../api";
|
||||||
|
|
||||||
|
function HandoffMessageBody({ message }) {
|
||||||
|
const [showQuoted, setShowQuoted] = useState(false);
|
||||||
|
const displayText = message.new_reply_content || message.body_text || message.snippet;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="handoff-message-body">{displayText}</div>
|
||||||
|
{message.has_quoted_text && message.raw_body && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="handoff-show-quoted"
|
||||||
|
onClick={() => setShowQuoted((current) => !current)}
|
||||||
|
>
|
||||||
|
{showQuoted ? "Hide quoted text" : "Show quoted text"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showQuoted && message.raw_body && (
|
||||||
|
<pre className="handoff-quoted-body">{message.raw_body}</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(dateValue) {
|
function formatTime(dateValue) {
|
||||||
if (!dateValue) return "";
|
if (!dateValue) return "";
|
||||||
return new Date(dateValue).toLocaleString(undefined, {
|
return new Date(dateValue).toLocaleString(undefined, {
|
||||||
|
|
@ -63,9 +86,7 @@ export default function HandoffConversation({ handoffId, onBack }) {
|
||||||
<strong>{message.role === "human" ? "HUMAN OWNER" : "AGENT"}</strong>
|
<strong>{message.role === "human" ? "HUMAN OWNER" : "AGENT"}</strong>
|
||||||
<time>{formatTime(message.event_at)}</time>
|
<time>{formatTime(message.event_at)}</time>
|
||||||
</header>
|
</header>
|
||||||
<div className="handoff-message-body">
|
<HandoffMessageBody message={message} />
|
||||||
{message.body_text || message.snippet}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.handoff-message-human header strong { color:#11966a; }
|
.handoff-message-human header strong { color:#11966a; }
|
||||||
.handoff-message header time { color:#9a96a5; font-size:11px; }
|
.handoff-message header time { color:#9a96a5; font-size:11px; }
|
||||||
.handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; }
|
.handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; }
|
||||||
|
.handoff-show-quoted { margin-top:8px; padding:0; border:0; background:transparent; color:#6d5bd0; font-size:11px; font-weight:600; cursor:pointer; }
|
||||||
|
.handoff-quoted-body { margin:8px 0 0; padding:10px 12px; border:1px solid #ece9f2; border-radius:8px; background:#faf9fc; color:#77758a; font-size:11px; line-height:1.45; white-space:pre-wrap; overflow:auto; }
|
||||||
.handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; }
|
.handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; }
|
||||||
.handoff-settings-intro p { margin:0 0 10px; color:#4a4652; font-size:12px; line-height:1.6; }
|
.handoff-settings-intro p { margin:0 0 10px; color:#4a4652; font-size:12px; line-height:1.6; }
|
||||||
.handoff-settings-intro p strong { color:#171424; }
|
.handoff-settings-intro p strong { color:#171424; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue