human hand off fix
This commit is contained in:
parent
0981262612
commit
249fb79aaf
3 changed files with 161 additions and 23 deletions
|
|
@ -25,7 +25,6 @@ from app.services.handoff_delivery import (
|
||||||
from app.services.handoff_email_templates import (
|
from app.services.handoff_email_templates import (
|
||||||
attach_quoted_history,
|
attach_quoted_history,
|
||||||
build_gmail_quote,
|
build_gmail_quote,
|
||||||
format_sender_line,
|
|
||||||
sender_display_name,
|
sender_display_name,
|
||||||
)
|
)
|
||||||
from app.services.handoff_evaluator import evaluate_handoff
|
from app.services.handoff_evaluator import evaluate_handoff
|
||||||
|
|
@ -206,16 +205,18 @@ async def _latest_thread_message(
|
||||||
account_id: str,
|
account_id: str,
|
||||||
thread_id: ObjectId,
|
thread_id: ObjectId,
|
||||||
scope: str,
|
scope: str,
|
||||||
|
exclude_message_ids: list | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
|
match = {
|
||||||
|
"account_id": account_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"message_scope": scope,
|
||||||
|
"deletion_state": {"$in": [None, ""]},
|
||||||
|
}
|
||||||
|
if exclude_message_ids:
|
||||||
|
match["message_id"] = {"$nin": list(exclude_message_ids)}
|
||||||
pipeline = [
|
pipeline = [
|
||||||
{
|
{"$match": match},
|
||||||
"$match": {
|
|
||||||
"account_id": account_id,
|
|
||||||
"thread_id": thread_id,
|
|
||||||
"message_scope": scope,
|
|
||||||
"deletion_state": {"$in": [None, ""]},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{"$sort": {"event_at": -1, "_id": -1}},
|
{"$sort": {"event_at": -1, "_id": -1}},
|
||||||
{"$limit": 1},
|
{"$limit": 1},
|
||||||
{
|
{
|
||||||
|
|
@ -720,18 +721,66 @@ async def process_human_reply(
|
||||||
return human_message
|
return human_message
|
||||||
|
|
||||||
|
|
||||||
def _mirror_from_identity(source: dict, agent_name: str) -> str:
|
def _mirror_from_identity(
|
||||||
|
source: dict, agent_name: str, agent_email: str
|
||||||
|
) -> tuple[str, str]:
|
||||||
if source.get("direction") == "inbound":
|
if source.get("direction") == "inbound":
|
||||||
return format_sender_line(source.get("sender") or {})
|
sender = source.get("sender") or {}
|
||||||
return agent_name
|
address = (sender.get("address") or "").strip()
|
||||||
|
return sender_display_name(sender), address or agent_email
|
||||||
|
return agent_name, agent_email
|
||||||
|
|
||||||
|
|
||||||
def _mirror_body(source: dict, payload) -> tuple[str, str | None]:
|
def _body_has_quote_trail(body_text: str | None, body_html: str | None) -> bool:
|
||||||
new_text = (source.get("new_reply_content") or "").strip()
|
html = body_html or ""
|
||||||
new_html = (source.get("new_reply_html") or "").strip() or None
|
text = body_text or ""
|
||||||
if new_text:
|
if re.search(r"gmail_quote|blockquote", html, re.IGNORECASE):
|
||||||
return new_text, new_html
|
return True
|
||||||
return payload.body_text, payload.body_html
|
if re.search(r"(?m)^>+", text):
|
||||||
|
return True
|
||||||
|
if re.search(r"wrote:\s*$", text, re.IGNORECASE | re.MULTILINE):
|
||||||
|
return True
|
||||||
|
return "-----Original Message-----" in text
|
||||||
|
|
||||||
|
|
||||||
|
async def _mirror_body_with_trail(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
handoff: dict,
|
||||||
|
source: dict,
|
||||||
|
payload,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
text = payload.body_text or ""
|
||||||
|
html = payload.body_html
|
||||||
|
if _body_has_quote_trail(text, html):
|
||||||
|
return text, html or None
|
||||||
|
parent = await _latest_thread_message(
|
||||||
|
database,
|
||||||
|
account_id=account_id,
|
||||||
|
thread_id=handoff["external_thread_id"],
|
||||||
|
scope=MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
exclude_message_ids=[source["_id"]],
|
||||||
|
)
|
||||||
|
if parent is None:
|
||||||
|
return text, html
|
||||||
|
parent_payload = await load_mirror_payload(parent)
|
||||||
|
quoted_text, quoted_html = build_gmail_quote(
|
||||||
|
sender=parent.get("sender") or {},
|
||||||
|
event_at=(
|
||||||
|
parent.get("sent_at")
|
||||||
|
or parent.get("received_at")
|
||||||
|
or parent.get("created_at")
|
||||||
|
),
|
||||||
|
body_text=parent_payload.body_text or parent.get("body_text") or "",
|
||||||
|
body_html=parent_payload.body_html or parent.get("body_html"),
|
||||||
|
)
|
||||||
|
return attach_quoted_history(
|
||||||
|
new_text=(source.get("new_reply_content") or text).strip() or text,
|
||||||
|
new_html=source.get("new_reply_html") or html,
|
||||||
|
quoted_text=quoted_text,
|
||||||
|
quoted_html=quoted_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:
|
||||||
|
|
@ -748,8 +797,16 @@ 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)
|
body_text, body_html = await _mirror_body_with_trail(
|
||||||
from_name = _mirror_from_identity(source, sender_name)
|
database,
|
||||||
|
account_id=job["account_id"],
|
||||||
|
handoff=handoff,
|
||||||
|
source=source,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
from_name, from_address = _mirror_from_identity(
|
||||||
|
source, sender_name, handoff["agent_email"]
|
||||||
|
)
|
||||||
parent = await _latest_thread_message(
|
parent = await _latest_thread_message(
|
||||||
database,
|
database,
|
||||||
account_id=job["account_id"],
|
account_id=job["account_id"],
|
||||||
|
|
@ -761,7 +818,7 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
if parent and not re.match(r"^re:\s*", subject, re.IGNORECASE):
|
if parent and not re.match(r"^re:\s*", subject, re.IGNORECASE):
|
||||||
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=from_address,
|
||||||
sender_name=from_name,
|
sender_name=from_name,
|
||||||
recipients=[handoff["human_email"]],
|
recipients=[handoff["human_email"]],
|
||||||
cc=[],
|
cc=[],
|
||||||
|
|
@ -770,6 +827,8 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
reply_to=handoff["agent_email"],
|
||||||
|
envelope_sender=handoff["agent_email"],
|
||||||
in_reply_to=threading.get("in_reply_to"),
|
in_reply_to=threading.get("in_reply_to"),
|
||||||
references=threading.get("references"),
|
references=threading.get("references"),
|
||||||
message_id=job["generated_message_id"],
|
message_id=job["generated_message_id"],
|
||||||
|
|
@ -784,7 +843,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": from_name, "address": handoff["agent_email"]},
|
sender={"name": from_name, "address": from_address},
|
||||||
recipients=[
|
recipients=[
|
||||||
{
|
{
|
||||||
"name": handoff.get("human_name", ""),
|
"name": handoff.get("human_name", ""),
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ async def deliver_email(
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
inline_parts: list[ExtractedMimePart] | None = None,
|
inline_parts: list[ExtractedMimePart] | None = None,
|
||||||
file_attachments: list[ExtractedMimePart] | None = None,
|
file_attachments: list[ExtractedMimePart] | None = None,
|
||||||
|
envelope_sender: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
_, sender_address = _sender_identity(sender, sender_name)
|
_, sender_address = _sender_identity(sender, sender_name)
|
||||||
message = build_message(
|
message = build_message(
|
||||||
|
|
@ -259,7 +260,7 @@ async def deliver_email(
|
||||||
log_signed_mime_headers(signed)
|
log_signed_mime_headers(signed)
|
||||||
await send_via_smtp(
|
await send_via_smtp(
|
||||||
signed,
|
signed,
|
||||||
sender=sender_address,
|
sender=envelope_sender or sender_address,
|
||||||
recipients=[*recipients, *cc, *bcc],
|
recipients=[*recipients, *cc, *bcc],
|
||||||
)
|
)
|
||||||
return str(message["Message-ID"])
|
return str(message["Message-ID"])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.services.human_handoff_service import (
|
from app.services.human_handoff_service import (
|
||||||
MESSAGE_SCOPE_EXTERNAL,
|
MESSAGE_SCOPE_EXTERNAL,
|
||||||
MESSAGE_SCOPE_HANDOFF,
|
MESSAGE_SCOPE_HANDOFF,
|
||||||
|
_body_has_quote_trail,
|
||||||
|
_mirror_from_identity,
|
||||||
build_handoff_subject,
|
build_handoff_subject,
|
||||||
maybe_trigger_handoff,
|
maybe_trigger_handoff,
|
||||||
process_human_reply,
|
process_human_reply,
|
||||||
|
|
@ -20,6 +24,80 @@ def test_human_handoff_runtime_is_enabled():
|
||||||
assert callable(resolve_handoff_from_inbound)
|
assert callable(resolve_handoff_from_inbound)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mirror_body_appends_external_history_when_missing(monkeypatch):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.services.human_handoff_service import _mirror_body_with_trail
|
||||||
|
|
||||||
|
parent = {
|
||||||
|
"_id": "parent",
|
||||||
|
"sender": {"name": "Parth Dixit", "address": "parthdixit091@gmail.com"},
|
||||||
|
"body_text": "sender: C\n\nOn earlier Suyash Dixit wrote:\n> responder: B",
|
||||||
|
"body_html": None,
|
||||||
|
"sent_at": None,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.human_handoff_service._latest_thread_message",
|
||||||
|
AsyncMock(return_value=parent),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.human_handoff_service.load_mirror_payload",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(body_text=parent["body_text"], body_html=None)),
|
||||||
|
)
|
||||||
|
text, html = await _mirror_body_with_trail(
|
||||||
|
SimpleNamespace(),
|
||||||
|
account_id="account-1",
|
||||||
|
handoff={"external_thread_id": "thread"},
|
||||||
|
source={"_id": "child", "new_reply_content": "responder: D", "new_reply_html": None},
|
||||||
|
payload=SimpleNamespace(body_text="responder: D", body_html=None),
|
||||||
|
)
|
||||||
|
assert text.startswith("responder: D")
|
||||||
|
assert "parthdixit091@gmail.com" in text
|
||||||
|
assert "> sender: C" in text
|
||||||
|
assert "responder: B" in text
|
||||||
|
assert html and "gmail_quote" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_trail_is_detected_in_forwarded_history():
|
||||||
|
assert _body_has_quote_trail(
|
||||||
|
"responder: B\n\nOn Tue, Oct 6, 2026 at 7:44 AM Suyash Dixit wrote:\n> sender: A",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert _body_has_quote_trail(
|
||||||
|
"sender: C",
|
||||||
|
'<div>sender: C</div><div class="gmail_quote">sender: A</div>',
|
||||||
|
)
|
||||||
|
assert _body_has_quote_trail("sender: E", None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_from_uses_one_address_not_both():
|
||||||
|
name, address = _mirror_from_identity(
|
||||||
|
{
|
||||||
|
"direction": "inbound",
|
||||||
|
"sender": {
|
||||||
|
"name": "Suyash Dixit",
|
||||||
|
"address": "suyashdixit091@gmail.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Suyash Dixit",
|
||||||
|
"suyash@gentic.email",
|
||||||
|
)
|
||||||
|
assert name == "Suyash Dixit"
|
||||||
|
assert address == "suyashdixit091@gmail.com"
|
||||||
|
assert "gentic.email" not in address
|
||||||
|
|
||||||
|
agent_name, agent_address = _mirror_from_identity(
|
||||||
|
{"direction": "outbound", "sender": {"address": "suyash@gentic.email"}},
|
||||||
|
"Suyash Dixit",
|
||||||
|
"suyash@gentic.email",
|
||||||
|
)
|
||||||
|
assert agent_name == "Suyash Dixit"
|
||||||
|
assert agent_address == "suyash@gentic.email"
|
||||||
|
assert "gmail.com" not in agent_address
|
||||||
|
|
||||||
|
|
||||||
def test_private_subject_contains_customer_name_and_external_subject():
|
def test_private_subject_contains_customer_name_and_external_subject():
|
||||||
subject = build_handoff_subject(
|
subject = build_handoff_subject(
|
||||||
sender_name="Parth Dixit",
|
sender_name="Parth Dixit",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue