diff --git a/backend/app/routers/agent_handoff.py b/backend/app/routers/agent_handoff.py index adaf7eb..e54b5de 100644 --- a/backend/app/routers/agent_handoff.py +++ b/backend/app/routers/agent_handoff.py @@ -264,13 +264,23 @@ async def get_handoff_conversation( sender_address = (message.get("sender") or {}).get("address", "").lower() 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"] + 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( HandoffMessageRead( id=str(message["_id"]), role=role, subject=message.get("subject", ""), - snippet=message.get("snippet", ""), - body_text=message.get("body_text", ""), + snippet=message.get("snippet", "") or display_body[:240], + 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, ) ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 756c055..3b6b684 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -314,6 +314,9 @@ class HandoffMessageRead(BaseModel): subject: str snippet: str body_text: str + new_reply_content: str | None = None + raw_body: str | None = None + has_quoted_text: bool = False event_at: datetime diff --git a/backend/app/services/handoff_email_templates.py b/backend/app/services/handoff_email_templates.py new file mode 100644 index 0000000..73f7b7e --- /dev/null +++ b/backend/app/services/handoff_email_templates.py @@ -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""" + +

Why you're receiving this

+

{escape(reason)}

+ """ + + return f""" + + + +
+ + + + + {reason_block} + + +
+

✦ Gentic

+

Human Handoff

+

This conversation needs your attention.

+
+ + +
+

From

+

{escape(external_sender)}

+

Subject

+

{escape(_clean_external_subject(external_subject))}

+
+
+

Latest message

+
{escape(external_body.strip())}
+
+ + +
+

↩ Reply normally

+

+ Reply to this email and Gentic will send your response to the original sender as + {escape(agent_email)}. Only the new text you write will be sent. + This handoff email and quoted history stay private. +

+
+
+

Gentic Human Handoff
Private Agent ↔ Human conversation

+
+
+""" + + +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""" + + + +
+ + +
+

✦ Gentic

+

Customer replied

+

{escape(external_sender)}

+
{escape(external_body.strip())}
+

+ Reply normally to respond through {escape(agent_email)}. +

+
+
+""" + + +def build_relay_confirmation_text(customer_body: str) -> str: + return f"Sent to customer:\n\n{customer_body.strip()}" diff --git a/backend/app/services/human_handoff_service.py b/backend/app/services/human_handoff_service.py index 6fa04a8..0155097 100644 --- a/backend/app/services/human_handoff_service.py +++ b/backend/app/services/human_handoff_service.py @@ -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, diff --git a/backend/app/services/reply_extractor.py b/backend/app/services/reply_extractor.py new file mode 100644 index 0000000..0780f45 --- /dev/null +++ b/backend/app/services/reply_extractor.py @@ -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"]*>.*?", re.IGNORECASE | re.DOTALL), + re.compile(r']+class="[^"]*gmail_quote[^"]*"[^>]*>.*?', re.IGNORECASE | re.DOTALL), + re.compile(r']+class="[^"]*gmail_attr[^"]*"[^>]*>.*?', 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"", "\n", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"", "\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 diff --git a/backend/tests/test_reply_extractor.py b/backend/tests/test_reply_extractor.py new file mode 100644 index 0000000..fba95eb --- /dev/null +++ b/backend/tests/test_reply_extractor.py @@ -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 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." diff --git a/frontend/src/components/HandoffConversation.jsx b/frontend/src/components/HandoffConversation.jsx index e87fdc3..019b121 100644 --- a/frontend/src/components/HandoffConversation.jsx +++ b/frontend/src/components/HandoffConversation.jsx @@ -2,6 +2,29 @@ import { ArrowLeft } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { getHandoffConversation } from "../api"; +function HandoffMessageBody({ message }) { + const [showQuoted, setShowQuoted] = useState(false); + const displayText = message.new_reply_content || message.body_text || message.snippet; + + return ( + <> +
{displayText}
+ {message.has_quoted_text && message.raw_body && ( + + )} + {showQuoted && message.raw_body && ( +
{message.raw_body}
+ )} + + ); +} + function formatTime(dateValue) { if (!dateValue) return ""; return new Date(dateValue).toLocaleString(undefined, { @@ -63,9 +86,7 @@ export default function HandoffConversation({ handoffId, onBack }) { {message.role === "human" ? "HUMAN OWNER" : "AGENT"} -
- {message.body_text || message.snippet} -
+ ))} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 92c60cd..bbcab18 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -559,6 +559,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .handoff-message-human header strong { color:#11966a; } .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-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 p { margin:0 0 10px; color:#4a4652; font-size:12px; line-height:1.6; } .handoff-settings-intro p strong { color:#171424; }