handoff fixes

This commit is contained in:
debu-2 2026-10-06 02:21:27 +05:30
parent 3f4bcc26d9
commit 6c52cd19cb
8 changed files with 561 additions and 59 deletions

View 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&apos;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()}"

View file

@ -4,9 +4,19 @@ from datetime import datetime, timezone
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.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 (
attach_message_to_thread,
message_event_at,
@ -42,36 +52,8 @@ def _snippet(text: str, limit: int = 240) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
def _handoff_subject(external_subject: str) -> str:
subject = (external_subject or "(No subject)").strip()
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"
)
def _handoff_reply_subject(external_subject: str) -> str:
return f"Re: {handoff_email_subject(external_subject)}"
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:
pipeline = [
{
@ -250,8 +273,12 @@ async def store_handoff_message(
link_relationship: str | None = None,
external_message_id: ObjectId | None = None,
event_at: datetime | None = None,
raw_body: str | None = None,
new_reply_content: str | None = None,
body_html: str | None = None,
) -> dict:
now = event_at or _now()
display_body = new_reply_content if new_reply_content is not None else body_text
document = {
"direction": direction,
"message_scope": MESSAGE_SCOPE_HANDOFF,
@ -259,9 +286,11 @@ async def store_handoff_message(
"sender": sender,
"to": recipients,
"subject": subject,
"snippet": _snippet(body_text),
"body_text": body_text,
"body_html": None,
"snippet": _snippet(display_body),
"body_text": display_body,
"raw_body": raw_body,
"new_reply_content": new_reply_content,
"body_html": body_html,
"attachments": [],
"created_at": now,
"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_body = external_message.get("body_text") or external_message.get("snippet") or ""
stable_subject = handoff_email_subject(external_subject)
if existing_handoff:
handoff_id = existing_handoff["_id"]
handoff_thread_id = existing_handoff["handoff_thread_id"]
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
threading = threading_fields_from_parent(parent) if parent else {}
subject = _handoff_subject(external_subject)
body = _build_handoff_body(
subject = _handoff_reply_subject(external_subject)
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_subject=external_subject,
external_body=external_body,
handoff_reason="New message in the linked conversation.",
agent_email=agent_email,
)
relationship = LINK_EXTERNAL_UPDATE_RELAYED
@ -349,11 +382,18 @@ async def send_handoff_to_human(
handoff_thread_id = await create_empty_handoff_thread(
database,
account_id=account_id,
subject=_handoff_subject(external_subject),
subject=stable_subject,
)
threading = {}
subject = _handoff_subject(external_subject)
body = _build_handoff_body(
subject = stable_subject
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_subject=external_subject,
external_body=external_body,
@ -386,8 +426,8 @@ async def send_handoff_to_human(
cc=[],
bcc=[],
subject=subject,
body_text=body,
body_html=None,
body_text=body_text,
body_html=body_html,
domain=domain,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references"),
@ -402,7 +442,8 @@ async def send_handoff_to_human(
sender={"name": "Gentic Agent", "address": agent_email},
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
subject=subject,
body_text=body,
body_text=body_text,
body_html=body_html,
internet_message_id=internet_message_id,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references"),
@ -458,6 +499,13 @@ async def process_human_reply(
rule = await _handoff_rule_for_human(database, account_id, human_email)
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(
database,
account_id=account_id,
@ -467,7 +515,10 @@ async def process_human_reply(
sender={"name": human_email.split("@")[0], "address": human_email},
recipients=[{"name": "Gentic Agent", "address": agent_email}],
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"),
in_reply_to=inbound_parsed.get("in_reply_to"),
references=inbound_parsed.get("references", []),
@ -490,21 +541,56 @@ async def process_human_reply(
)
return
external_parent = await _latest_external_message(database, account_id, external_thread_id)
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)
if external_parent is None:
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
return
threading = threading_fields_from_parent(external_parent)
external_subject = handoff.get("external_subject") or external_parent.get("subject", "")
if not re.match(r"^re:\s*", external_subject, flags=re.IGNORECASE):
external_subject = f"Re: {external_subject}"
external_subject = external_reply_subject(
handoff.get("external_subject") or external_parent.get("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:
return
customer_body = inbound_body.strip()
customer_body = new_reply_content
internet_message_id = await deliver_email(
sender=agent_email,
recipients=[external_recipient],
@ -572,8 +658,8 @@ async def process_human_reply(
}
)
relay_body = f"Sent to customer:\n\n\"{customer_body}\""
relay_subject = f"Re: {_handoff_subject(handoff.get('external_subject', ''))}"
relay_body = build_relay_confirmation_text(customer_body)
relay_subject = _handoff_reply_subject(handoff.get("external_subject", ""))
relay_threading = threading_fields_from_parent(human_message)
relay_internet_id = await deliver_email(
sender=agent_email,
@ -656,7 +742,7 @@ async def maybe_trigger_handoff(
existing = await find_active_handoff_for_external_thread(
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(
database,
account_id=account_id,

View 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