human hand off fix
This commit is contained in:
parent
7b9e574b86
commit
0981262612
5 changed files with 154 additions and 24 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
from datetime import datetime
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,13 +26,72 @@ def format_sender_line(sender: dict) -> str:
|
||||||
name = (sender.get("name") or "").strip()
|
name = (sender.get("name") or "").strip()
|
||||||
address = (sender.get("address") or "").strip()
|
address = (sender.get("address") or "").strip()
|
||||||
if not address:
|
if not address:
|
||||||
return "unknown sender"
|
return name or "unknown sender"
|
||||||
local = address.split("@", 1)[0].lower()
|
local = address.split("@", 1)[0].lower()
|
||||||
if name and name.lower() not in {local, address.lower()}:
|
if name and name.lower() not in {local, address.lower()}:
|
||||||
return f"{name} <{address}>"
|
return f"{name} <{address}>"
|
||||||
return address
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def sender_display_name(sender: dict | None) -> str:
|
||||||
|
sender = sender or {}
|
||||||
|
name = (sender.get("name") or "").strip()
|
||||||
|
address = (sender.get("address") or "").strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
if address:
|
||||||
|
return address.split("@", 1)[0].replace(".", " ").replace("_", " ").replace("-", " ").title()
|
||||||
|
return "unknown sender"
|
||||||
|
|
||||||
|
|
||||||
|
def _attribution_date(value) -> str:
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
return ""
|
||||||
|
return value.strftime("%a, %b %d, %Y at %I:%M %p").replace(" 0", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def build_gmail_quote(
|
||||||
|
*,
|
||||||
|
sender: dict | None,
|
||||||
|
event_at,
|
||||||
|
body_text: str | None,
|
||||||
|
body_html: str | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
identity = format_sender_line(sender or {})
|
||||||
|
date = _attribution_date(event_at)
|
||||||
|
attribution = f"On {date} {identity} wrote:" if date else f"{identity} wrote:"
|
||||||
|
text_source = (body_text or "").rstrip()
|
||||||
|
quoted_lines = [f"> {line}" if line else ">" for line in text_source.splitlines()]
|
||||||
|
quoted_text = f"{attribution}\n" + "\n".join(quoted_lines)
|
||||||
|
inner_html = (body_html or "").strip() or (
|
||||||
|
f"<div>{escape(text_source).replace(chr(10), '<br>')}</div>"
|
||||||
|
)
|
||||||
|
quoted_html = (
|
||||||
|
f'<div class="gmail_quote">'
|
||||||
|
f'<div class="gmail_attr">{escape(attribution)}<br></div>'
|
||||||
|
f'<blockquote class="gmail_quote" '
|
||||||
|
f'style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">'
|
||||||
|
f"{inner_html}"
|
||||||
|
f"</blockquote></div>"
|
||||||
|
)
|
||||||
|
return quoted_text, quoted_html
|
||||||
|
|
||||||
|
|
||||||
|
def attach_quoted_history(
|
||||||
|
*,
|
||||||
|
new_text: str,
|
||||||
|
new_html: str | None,
|
||||||
|
quoted_text: str,
|
||||||
|
quoted_html: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
body_text = f"{new_text.rstrip()}\n\n{quoted_text}".strip()
|
||||||
|
visible_html = (new_html or "").strip() or (
|
||||||
|
f"<div>{escape(new_text).replace(chr(10), '<br>')}</div>"
|
||||||
|
)
|
||||||
|
body_html = f"{visible_html}<br>{quoted_html}"
|
||||||
|
return body_text, body_html
|
||||||
|
|
||||||
|
|
||||||
def _footer_html(agent_email: str) -> str:
|
def _footer_html(agent_email: str) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<div class="gentic-handoff-footer" style="background:#F8F7FC;border-top:1px solid #E9E5F2;padding:12px 20px;">
|
<div class="gentic-handoff-footer" style="background:#F8F7FC;border-top:1px solid #E9E5F2;padding:12px 20px;">
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ from app.services.handoff_delivery import (
|
||||||
JOB_RELAY,
|
JOB_RELAY,
|
||||||
enqueue_handoff_delivery,
|
enqueue_handoff_delivery,
|
||||||
)
|
)
|
||||||
|
from app.services.handoff_email_templates import (
|
||||||
|
attach_quoted_history,
|
||||||
|
build_gmail_quote,
|
||||||
|
format_sender_line,
|
||||||
|
sender_display_name,
|
||||||
|
)
|
||||||
from app.services.handoff_evaluator import evaluate_handoff
|
from app.services.handoff_evaluator import evaluate_handoff
|
||||||
from app.services.handoff_mirror import load_mirror_payload
|
from app.services.handoff_mirror import load_mirror_payload
|
||||||
from app.services.handoff_mirror_policy import (
|
from app.services.handoff_mirror_policy import (
|
||||||
|
|
@ -89,13 +95,15 @@ def _safe_subject_part(value: str, fallback: str) -> str:
|
||||||
|
|
||||||
def build_handoff_subject(
|
def build_handoff_subject(
|
||||||
*,
|
*,
|
||||||
rule_name: str,
|
sender_name: str,
|
||||||
external_sender: str,
|
external_sender: str,
|
||||||
external_subject: str,
|
external_subject: str,
|
||||||
|
rule_name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
del rule_name
|
||||||
subject = " · ".join(
|
subject = " · ".join(
|
||||||
[
|
[
|
||||||
_safe_subject_part(rule_name, "Human"),
|
_safe_subject_part(sender_name, "unknown sender"),
|
||||||
"handoff",
|
"handoff",
|
||||||
_safe_subject_part(external_sender, "unknown sender"),
|
_safe_subject_part(external_sender, "unknown sender"),
|
||||||
_safe_subject_part(external_subject, "(No subject)"),
|
_safe_subject_part(external_subject, "(No subject)"),
|
||||||
|
|
@ -472,10 +480,11 @@ async def _create_handoff_mapping(
|
||||||
rule: dict,
|
rule: dict,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
sender = external_message.get("sender") or {}
|
sender = external_message.get("sender") or {}
|
||||||
sender_text = sender.get("address") or sender.get("name") or "unknown sender"
|
sender_email = (sender.get("address") or "").strip()
|
||||||
|
customer_name = sender_display_name(sender)
|
||||||
private_subject = build_handoff_subject(
|
private_subject = build_handoff_subject(
|
||||||
rule_name=rule.get("name") or rule.get("owner_name") or "Human",
|
sender_name=customer_name,
|
||||||
external_sender=sender_text,
|
external_sender=sender_email or customer_name,
|
||||||
external_subject=external_message.get("subject", ""),
|
external_subject=external_message.get("subject", ""),
|
||||||
)
|
)
|
||||||
thread_id = await create_handoff_thread(
|
thread_id = await create_handoff_thread(
|
||||||
|
|
@ -494,7 +503,8 @@ async def _create_handoff_mapping(
|
||||||
"human_email": rule["owner_email"].lower(),
|
"human_email": rule["owner_email"].lower(),
|
||||||
"human_name": rule.get("owner_name") or rule["owner_email"].split("@", 1)[0],
|
"human_name": rule.get("owner_name") or rule["owner_email"].split("@", 1)[0],
|
||||||
"agent_email": agent_email.lower(),
|
"agent_email": agent_email.lower(),
|
||||||
"external_sender": sender.get("address", "").lower(),
|
"external_sender": sender_email.lower(),
|
||||||
|
"external_sender_name": customer_name,
|
||||||
"external_subject": external_message.get("subject", ""),
|
"external_subject": external_message.get("subject", ""),
|
||||||
"private_subject": private_subject,
|
"private_subject": private_subject,
|
||||||
"allow_replies": rule.get("allow_replies", True),
|
"allow_replies": rule.get("allow_replies", True),
|
||||||
|
|
@ -710,6 +720,20 @@ async def process_human_reply(
|
||||||
return human_message
|
return human_message
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_from_identity(source: dict, agent_name: str) -> str:
|
||||||
|
if source.get("direction") == "inbound":
|
||||||
|
return format_sender_line(source.get("sender") or {})
|
||||||
|
return agent_name
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_body(source: dict, payload) -> tuple[str, str | None]:
|
||||||
|
new_text = (source.get("new_reply_content") or "").strip()
|
||||||
|
new_html = (source.get("new_reply_html") or "").strip() or None
|
||||||
|
if new_text:
|
||||||
|
return new_text, new_html
|
||||||
|
return payload.body_text, payload.body_html
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> ObjectId:
|
async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> ObjectId:
|
||||||
existing = await database.messages.find_one(
|
existing = await database.messages.find_one(
|
||||||
{
|
{
|
||||||
|
|
@ -724,6 +748,8 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
job["account_id"], handoff["agent_email"]
|
job["account_id"], handoff["agent_email"]
|
||||||
)
|
)
|
||||||
payload = await load_mirror_payload(source)
|
payload = await load_mirror_payload(source)
|
||||||
|
body_text, body_html = _mirror_body(source, payload)
|
||||||
|
from_name = _mirror_from_identity(source, sender_name)
|
||||||
parent = await _latest_thread_message(
|
parent = await _latest_thread_message(
|
||||||
database,
|
database,
|
||||||
account_id=job["account_id"],
|
account_id=job["account_id"],
|
||||||
|
|
@ -736,13 +762,13 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
subject = f"Re: {subject}"
|
subject = f"Re: {subject}"
|
||||||
internet_message_id = await deliver_email(
|
internet_message_id = await deliver_email(
|
||||||
sender=handoff["agent_email"],
|
sender=handoff["agent_email"],
|
||||||
sender_name=sender_name,
|
sender_name=from_name,
|
||||||
recipients=[handoff["human_email"]],
|
recipients=[handoff["human_email"]],
|
||||||
cc=[],
|
cc=[],
|
||||||
bcc=[],
|
bcc=[],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=payload.body_text,
|
body_text=body_text,
|
||||||
body_html=payload.body_html,
|
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"),
|
||||||
|
|
@ -758,7 +784,7 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
account_id=job["account_id"],
|
account_id=job["account_id"],
|
||||||
thread_id=handoff["handoff_thread_id"],
|
thread_id=handoff["handoff_thread_id"],
|
||||||
direction="outbound",
|
direction="outbound",
|
||||||
sender={"name": sender_name, "address": handoff["agent_email"]},
|
sender={"name": from_name, "address": handoff["agent_email"]},
|
||||||
recipients=[
|
recipients=[
|
||||||
{
|
{
|
||||||
"name": handoff.get("human_name", ""),
|
"name": handoff.get("human_name", ""),
|
||||||
|
|
@ -766,8 +792,8 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=payload.body_text,
|
body_text=body_text,
|
||||||
body_html=payload.body_html,
|
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", []),
|
||||||
|
|
@ -844,10 +870,26 @@ async def _deliver_relay(database, job: dict, handoff: dict, source: dict) -> Ob
|
||||||
rich = extract_new_reply_mime_content(raw)
|
rich = extract_new_reply_mime_content(raw)
|
||||||
inline_parts = rich.inline_parts
|
inline_parts = rich.inline_parts
|
||||||
file_attachments = rich.file_attachments
|
file_attachments = rich.file_attachments
|
||||||
|
quoted_text, quoted_html = build_gmail_quote(
|
||||||
|
sender=external_parent.get("sender") or {},
|
||||||
|
event_at=(
|
||||||
|
external_parent.get("sent_at")
|
||||||
|
or external_parent.get("received_at")
|
||||||
|
or external_parent.get("created_at")
|
||||||
|
),
|
||||||
|
body_text=external_parent.get("body_text") or external_parent.get("snippet") or "",
|
||||||
|
body_html=external_parent.get("body_html"),
|
||||||
|
)
|
||||||
|
relay_text, relay_html = attach_quoted_history(
|
||||||
|
new_text=new_text,
|
||||||
|
new_html=body_html,
|
||||||
|
quoted_text=quoted_text,
|
||||||
|
quoted_html=quoted_html,
|
||||||
|
)
|
||||||
plan = build_external_reply_plan(
|
plan = build_external_reply_plan(
|
||||||
parent_external_message=external_parent,
|
parent_external_message=external_parent,
|
||||||
body_text=new_text,
|
body_text=relay_text,
|
||||||
body_html=body_html,
|
body_html=relay_html,
|
||||||
subject_override=None,
|
subject_override=None,
|
||||||
recipients=[recipient],
|
recipients=[recipient],
|
||||||
parent_internet_id_override=parent_internet_id,
|
parent_internet_id_override=parent_internet_id,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,33 @@ def test_format_sender_with_name():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_history_uses_original_sender_not_human_owner():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services.handoff_email_templates import (
|
||||||
|
attach_quoted_history,
|
||||||
|
build_gmail_quote,
|
||||||
|
)
|
||||||
|
|
||||||
|
quoted_text, quoted_html = build_gmail_quote(
|
||||||
|
sender={"name": "Parth Dixit", "address": "parthdixit091@gmail.com"},
|
||||||
|
event_at=datetime(2026, 10, 6, 7, 29, tzinfo=timezone.utc),
|
||||||
|
body_text="sender: C",
|
||||||
|
body_html="<div>sender: C</div>",
|
||||||
|
)
|
||||||
|
body_text, body_html = attach_quoted_history(
|
||||||
|
new_text="responder: D",
|
||||||
|
new_html="<div>responder: D</div>",
|
||||||
|
quoted_text=quoted_text,
|
||||||
|
quoted_html=quoted_html,
|
||||||
|
)
|
||||||
|
assert body_text.startswith("responder: D")
|
||||||
|
assert "Parth Dixit <parthdixit091@gmail.com>" in body_text
|
||||||
|
assert "> sender: C" in body_text
|
||||||
|
assert "parthdixit091@gmail.com" in body_html
|
||||||
|
assert "Suyash AIML" not in body_text
|
||||||
|
|
||||||
|
|
||||||
def test_external_threading_headers_from_customer_message():
|
def test_external_threading_headers_from_customer_message():
|
||||||
parent = {
|
parent = {
|
||||||
"_id": "mongo-id",
|
"_id": "mongo-id",
|
||||||
|
|
|
||||||
|
|
@ -278,12 +278,13 @@ def test_human_echo_and_agent_mirror_policy():
|
||||||
|
|
||||||
def test_private_subject_stays_off_the_external_plane():
|
def test_private_subject_stays_off_the_external_plane():
|
||||||
private = build_handoff_subject(
|
private = build_handoff_subject(
|
||||||
rule_name="Finance approvals",
|
sender_name="Parth Dixit",
|
||||||
external_sender="customer@example.com",
|
external_sender="customer@example.com",
|
||||||
external_subject="Invoice 42",
|
external_subject="Invoice 42",
|
||||||
)
|
)
|
||||||
assert private.startswith("Finance approvals · handoff ·")
|
assert private.startswith("Parth Dixit · handoff ·")
|
||||||
assert "Invoice 42" in private
|
assert "Invoice 42" in private
|
||||||
|
assert "Finance approvals" not in private
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -314,7 +315,7 @@ async def test_first_matching_rule_creates_a_new_linked_handoff():
|
||||||
"_id": source_id,
|
"_id": source_id,
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
"direction": "inbound",
|
"direction": "inbound",
|
||||||
"sender": {"address": "customer@example.com"},
|
"sender": {"name": "Parth Dixit", "address": "customer@example.com"},
|
||||||
"subject": "Invoice 42",
|
"subject": "Invoice 42",
|
||||||
"body_text": "please review this invoice",
|
"body_text": "please review this invoice",
|
||||||
"snippet": "please review this invoice",
|
"snippet": "please review this invoice",
|
||||||
|
|
@ -331,7 +332,7 @@ async def test_first_matching_rule_creates_a_new_linked_handoff():
|
||||||
assert result["rule_name"] == "Finance approvals"
|
assert result["rule_name"] == "Finance approvals"
|
||||||
assert result["external_thread_id"] == external_thread_id
|
assert result["external_thread_id"] == external_thread_id
|
||||||
assert result["handoff_thread_id"]
|
assert result["handoff_thread_id"]
|
||||||
assert result["private_subject"].startswith("Finance approvals · handoff ·")
|
assert result["private_subject"].startswith("Parth Dixit · handoff ·")
|
||||||
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,20 @@ def test_human_handoff_runtime_is_enabled():
|
||||||
assert callable(resolve_handoff_from_inbound)
|
assert callable(resolve_handoff_from_inbound)
|
||||||
|
|
||||||
|
|
||||||
def test_private_subject_contains_rule_sender_and_external_subject():
|
def test_private_subject_contains_customer_name_and_external_subject():
|
||||||
subject = build_handoff_subject(
|
subject = build_handoff_subject(
|
||||||
rule_name="Finance approvals",
|
sender_name="Parth Dixit",
|
||||||
external_sender="customer@example.com",
|
external_sender="parthdixit091@gmail.com",
|
||||||
external_subject="Invoice 42",
|
external_subject="Invoice 42",
|
||||||
)
|
)
|
||||||
assert subject == (
|
assert subject == (
|
||||||
"Finance approvals · handoff · customer@example.com · Invoice 42"
|
"Parth Dixit · handoff · parthdixit091@gmail.com · Invoice 42"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_private_subject_removes_header_line_breaks():
|
def test_private_subject_removes_header_line_breaks():
|
||||||
subject = build_handoff_subject(
|
subject = build_handoff_subject(
|
||||||
rule_name="Urgent\r\nBcc: attacker@example.com",
|
sender_name="Urgent\r\nBcc: attacker@example.com",
|
||||||
external_sender="customer@example.com",
|
external_sender="customer@example.com",
|
||||||
external_subject="Help",
|
external_subject="Help",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue