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

@ -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,