handoff fixes
This commit is contained in:
parent
89ddebdb60
commit
8d5e9712a6
20 changed files with 1056 additions and 141 deletions
|
|
@ -5,18 +5,37 @@ from datetime import datetime, timezone
|
|||
from bson import ObjectId
|
||||
|
||||
from app.services.external_reply_service import (
|
||||
ExternalReplyThreadingError,
|
||||
build_external_reply_plan,
|
||||
internet_message_id_of,
|
||||
log_original_external_message,
|
||||
resolve_external_reply_parent,
|
||||
resolve_internet_message_id,
|
||||
send_external_reply,
|
||||
)
|
||||
from app.services.handoff_email_templates import (
|
||||
build_relay_confirmation_text,
|
||||
external_reply_subject,
|
||||
handoff_email_subject,
|
||||
)
|
||||
from app.services.handoff_mirror_policy import (
|
||||
GENERATED_BY_HANDOFF_BRIDGE,
|
||||
GENERATED_BY_HUMAN,
|
||||
GENERATED_BY_SYSTEM,
|
||||
MESSAGE_ORIGIN_HANDOFF_MIRROR,
|
||||
MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
RELAY_STATUS_FAILED,
|
||||
RELAY_STATUS_SENT,
|
||||
should_mirror_to_human,
|
||||
)
|
||||
from app.services.handoff_mirror import load_mirror_payload, load_mirror_update_payload
|
||||
from app.services.handoff_evaluator import evaluate_handoff
|
||||
from app.services.mail_sender import deliver_email
|
||||
from app.services.message_ids import normalize_message_id, parse_message_id_list
|
||||
from app.services.message_ids import (
|
||||
canonical_message_id,
|
||||
folded_message_id,
|
||||
message_id_fields,
|
||||
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,
|
||||
|
|
@ -226,9 +245,13 @@ async def resolve_handoff_from_inbound(
|
|||
return None
|
||||
|
||||
for internet_id in reversed(parse_message_id_list(" ".join(search_ids))):
|
||||
folded = folded_message_id(internet_id)
|
||||
message = await database.messages.find_one(
|
||||
{
|
||||
"message_id": internet_id,
|
||||
"$or": [
|
||||
{"message_id_folded": folded},
|
||||
{"message_id": internet_id},
|
||||
],
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
)
|
||||
|
|
@ -256,6 +279,26 @@ async def resolve_handoff_from_inbound(
|
|||
return None
|
||||
|
||||
|
||||
async def _handoff_mirror_exists(database, external_message_id: ObjectId) -> bool:
|
||||
existing = await database.messages.find_one(
|
||||
{
|
||||
"mirrored_from_external_message_id": external_message_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
},
|
||||
{"_id": 1},
|
||||
)
|
||||
return existing is not None
|
||||
|
||||
|
||||
async def _bridged_external_exists(database, handoff_message_id: ObjectId) -> dict | None:
|
||||
return await database.messages.find_one(
|
||||
{
|
||||
"generated_from_handoff_message_id": handoff_message_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def store_handoff_message(
|
||||
database,
|
||||
*,
|
||||
|
|
@ -277,6 +320,11 @@ async def store_handoff_message(
|
|||
raw_body: str | None = None,
|
||||
new_reply_content: str | None = None,
|
||||
body_html: str | None = None,
|
||||
message_origin: str | None = None,
|
||||
generated_by: str | None = None,
|
||||
mirrored_from_external_message_id: ObjectId | None = None,
|
||||
relay_status: str | None = None,
|
||||
generated_external_message_id: ObjectId | None = None,
|
||||
) -> dict:
|
||||
now = event_at or _now()
|
||||
display_body = new_reply_content if new_reply_content is not None else body_text
|
||||
|
|
@ -297,11 +345,20 @@ async def store_handoff_message(
|
|||
"sent_at": now if direction == "outbound" else None,
|
||||
"received_at": now if direction == "inbound" else None,
|
||||
"status": "received" if direction == "inbound" else "accepted",
|
||||
"message_id": normalize_message_id(internet_message_id),
|
||||
"internet_message_id": normalize_message_id(internet_message_id),
|
||||
"in_reply_to": normalize_message_id(in_reply_to),
|
||||
**message_id_fields(internet_message_id),
|
||||
"in_reply_to": canonical_message_id(in_reply_to),
|
||||
"references": references or [],
|
||||
}
|
||||
if message_origin:
|
||||
document["message_origin"] = message_origin
|
||||
if generated_by:
|
||||
document["generated_by"] = generated_by
|
||||
if mirrored_from_external_message_id:
|
||||
document["mirrored_from_external_message_id"] = mirrored_from_external_message_id
|
||||
if relay_status:
|
||||
document["relay_status"] = relay_status
|
||||
if generated_external_message_id:
|
||||
document["generated_external_message_id"] = generated_external_message_id
|
||||
result = await database.messages.insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
await attach_message_to_thread(
|
||||
|
|
@ -357,6 +414,23 @@ async def send_handoff_to_human(
|
|||
domain,
|
||||
existing_handoff: dict | None = None,
|
||||
) -> dict:
|
||||
if await _handoff_mirror_exists(database, external_message["_id"]):
|
||||
logger.info(
|
||||
"handoff_mirror_skipped_duplicate external_message=%s",
|
||||
external_message["_id"],
|
||||
)
|
||||
if existing_handoff:
|
||||
return existing_handoff
|
||||
existing = await database.human_handoffs.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"external_thread_id": external_thread_id,
|
||||
}
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
raise RuntimeError("Handoff mirror duplicate without handoff record")
|
||||
|
||||
external_subject = external_message.get("subject", "")
|
||||
stable_subject = handoff_email_subject(external_subject)
|
||||
mirror_source = {**external_message, "_agent_email": agent_email}
|
||||
|
|
@ -394,6 +468,8 @@ async def send_handoff_to_human(
|
|||
"updated_at": _now(),
|
||||
"last_external_message_at": message_event_at(external_message),
|
||||
"last_human_message_at": None,
|
||||
"trigger_external_message_id": external_message["_id"],
|
||||
"trigger_internet_message_id": internet_message_id_of(external_message),
|
||||
}
|
||||
insert = await database.human_handoffs.insert_one(handoff_doc)
|
||||
handoff_id = insert.inserted_id
|
||||
|
|
@ -431,6 +507,9 @@ async def send_handoff_to_human(
|
|||
handoff_id=handoff_id,
|
||||
link_relationship=relationship,
|
||||
external_message_id=external_message["_id"],
|
||||
message_origin=MESSAGE_ORIGIN_HANDOFF_MIRROR,
|
||||
generated_by=GENERATED_BY_SYSTEM,
|
||||
mirrored_from_external_message_id=external_message["_id"],
|
||||
)
|
||||
|
||||
await database.human_handoffs.update_one(
|
||||
|
|
@ -504,7 +583,8 @@ async def process_human_reply(
|
|||
in_reply_to=inbound_parsed.get("in_reply_to"),
|
||||
references=inbound_parsed.get("references", []),
|
||||
handoff_id=handoff["_id"],
|
||||
link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
|
||||
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
generated_by=GENERATED_BY_HUMAN,
|
||||
)
|
||||
|
||||
now = _now()
|
||||
|
|
@ -550,22 +630,68 @@ async def process_human_reply(
|
|||
return
|
||||
|
||||
external_recipient = handoff.get("external_sender") or ""
|
||||
external_parent = await _latest_external_customer_message(
|
||||
external_parent = await resolve_external_reply_parent(
|
||||
database,
|
||||
account_id,
|
||||
external_thread_id,
|
||||
external_recipient,
|
||||
account_id=account_id,
|
||||
external_thread_id=external_thread_id,
|
||||
customer_address=external_recipient,
|
||||
handoff=handoff,
|
||||
)
|
||||
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"])
|
||||
logger.warning(
|
||||
"handoff_reply_missing_external_parent handoff_id=%s external_thread=%s "
|
||||
"trigger_internet_message_id=%s",
|
||||
handoff["_id"],
|
||||
external_thread_id,
|
||||
handoff.get("trigger_internet_message_id"),
|
||||
)
|
||||
return
|
||||
|
||||
external_recipient = external_recipient or external_parent.get("sender", {}).get("address")
|
||||
parent_sender = (external_parent.get("sender") or {}).get("address")
|
||||
log_original_external_message(
|
||||
internal_id=external_parent["_id"],
|
||||
thread_id=external_thread_id,
|
||||
internet_message_id=internet_message_id_of(external_parent),
|
||||
sender=parent_sender,
|
||||
recipient=agent_email,
|
||||
subject=external_parent.get("subject"),
|
||||
in_reply_to=external_parent.get("in_reply_to"),
|
||||
references=external_parent.get("references"),
|
||||
raw_message_available=bool(external_parent.get("raw_mime")),
|
||||
)
|
||||
|
||||
external_recipient = external_recipient or parent_sender
|
||||
if not external_recipient:
|
||||
return
|
||||
|
||||
parent_internet_id = await resolve_internet_message_id(database, external_parent)
|
||||
if not parent_internet_id:
|
||||
logger.error(
|
||||
"handoff_reply_missing_parent_message_id handoff_id=%s parent_internal_id=%s",
|
||||
handoff["_id"],
|
||||
external_parent["_id"],
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"handoff_external_reply_threading handoff_id=%s parent_internal_id=%s "
|
||||
"parent_internet_message_id=%s human_in_reply_to=%s human_references=%s",
|
||||
handoff["_id"],
|
||||
external_parent["_id"],
|
||||
parent_internet_id,
|
||||
human_message.get("in_reply_to"),
|
||||
human_message.get("references"),
|
||||
)
|
||||
|
||||
existing_bridge = await _bridged_external_exists(database, human_message["_id"])
|
||||
if existing_bridge:
|
||||
logger.info(
|
||||
"handoff_bridge_already_processed handoff_message=%s external=%s",
|
||||
human_message["_id"],
|
||||
existing_bridge["_id"],
|
||||
)
|
||||
return
|
||||
|
||||
customer_body = new_reply_content
|
||||
reply_plan = build_external_reply_plan(
|
||||
parent_external_message=external_parent,
|
||||
|
|
@ -574,62 +700,87 @@ async def process_human_reply(
|
|||
handoff.get("external_subject") or external_parent.get("subject", "")
|
||||
),
|
||||
recipients=[external_recipient],
|
||||
parent_internet_id_override=parent_internet_id,
|
||||
)
|
||||
external_doc = await send_external_reply(
|
||||
database,
|
||||
account_id=account_id,
|
||||
sender=agent_email,
|
||||
sender_name="Gentic Agent",
|
||||
external_thread_id=external_thread_id,
|
||||
plan=reply_plan,
|
||||
domain=domain,
|
||||
resolution_method="human_handoff_bridge",
|
||||
logger.info(
|
||||
"handoff_external_reply_headers handoff_id=%s b_in_reply_to=%s b_references=%s",
|
||||
handoff["_id"],
|
||||
reply_plan.threading.in_reply_to,
|
||||
reply_plan.threading.references,
|
||||
)
|
||||
try:
|
||||
external_doc = await send_external_reply(
|
||||
database,
|
||||
account_id=account_id,
|
||||
sender=agent_email,
|
||||
sender_name="Gentic Agent",
|
||||
external_thread_id=external_thread_id,
|
||||
plan=reply_plan,
|
||||
domain=domain,
|
||||
resolution_method="human_handoff_bridge",
|
||||
generated_by=GENERATED_BY_HANDOFF_BRIDGE,
|
||||
generated_from_handoff_message_id=human_message["_id"],
|
||||
)
|
||||
except ExternalReplyThreadingError:
|
||||
await database.messages.update_one(
|
||||
{"_id": human_message["_id"]},
|
||||
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
|
||||
)
|
||||
logger.error(
|
||||
"handoff_bridge_threading_rejected handoff_id=%s handoff_message=%s "
|
||||
"parent_internet_message_id=%s",
|
||||
handoff["_id"],
|
||||
human_message["_id"],
|
||||
parent_internet_id,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
await database.messages.update_one(
|
||||
{"_id": human_message["_id"]},
|
||||
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
|
||||
)
|
||||
logger.exception(
|
||||
"handoff_bridge_send_failed handoff_id=%s handoff_message=%s",
|
||||
handoff["_id"],
|
||||
human_message["_id"],
|
||||
)
|
||||
raise
|
||||
|
||||
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,
|
||||
recipients=[human_email],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
subject=relay_subject,
|
||||
body_text=relay_body,
|
||||
body_html=None,
|
||||
domain=domain,
|
||||
in_reply_to=relay_threading.get("in_reply_to"),
|
||||
references=relay_threading.get("references"),
|
||||
await database.messages.update_one(
|
||||
{"_id": human_message["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"relay_status": RELAY_STATUS_SENT,
|
||||
"generated_external_message_id": external_doc["_id"],
|
||||
"relay_delivered_at": now,
|
||||
}
|
||||
},
|
||||
)
|
||||
await store_handoff_message(
|
||||
await _record_message_link(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
handoff_thread_id=handoff_thread_id,
|
||||
direction="outbound",
|
||||
sender={"name": "Gentic Agent", "address": agent_email},
|
||||
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
||||
subject=relay_subject,
|
||||
body_text=relay_body,
|
||||
internet_message_id=relay_internet_id,
|
||||
in_reply_to=relay_threading.get("in_reply_to"),
|
||||
references=relay_threading.get("references"),
|
||||
handoff_id=handoff["_id"],
|
||||
link_relationship=LINK_EXTERNAL_RELAYED,
|
||||
external_message_id=ext_result.inserted_id,
|
||||
external_message_id=external_doc["_id"],
|
||||
handoff_message_id=human_message["_id"],
|
||||
relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
|
||||
)
|
||||
|
||||
await database.human_handoffs.update_one(
|
||||
{"_id": handoff["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"status": HANDOFF_STATUS_AGENT_REPLIED,
|
||||
"current_owner": OWNER_AGENT,
|
||||
"status": HANDOFF_STATUS_WAITING,
|
||||
"current_owner": OWNER_HUMAN,
|
||||
"updated_at": now,
|
||||
"last_human_message_at": now,
|
||||
}
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"handoff_bridge_complete handoff_id=%s handoff_message=%s external=%s",
|
||||
handoff["_id"],
|
||||
human_message["_id"],
|
||||
external_doc["_id"],
|
||||
)
|
||||
|
||||
|
||||
async def maybe_trigger_handoff(
|
||||
|
|
@ -641,6 +792,40 @@ async def maybe_trigger_handoff(
|
|||
external_thread_id: ObjectId,
|
||||
domain,
|
||||
) -> None:
|
||||
if not should_mirror_to_human(external_message):
|
||||
logger.debug(
|
||||
"handoff_mirror_skipped message_id=%s generated_by=%s origin=%s direction=%s",
|
||||
external_message.get("_id"),
|
||||
external_message.get("generated_by"),
|
||||
external_message.get("message_origin"),
|
||||
external_message.get("direction"),
|
||||
)
|
||||
return
|
||||
|
||||
existing = await find_active_handoff_for_external_thread(
|
||||
database, account_id, external_thread_id
|
||||
)
|
||||
if existing:
|
||||
if await _handoff_mirror_exists(database, external_message["_id"]):
|
||||
return
|
||||
rule = await _handoff_rule_for_human(
|
||||
database, account_id, existing.get("human_email", "")
|
||||
)
|
||||
if rule and not rule.get("allow_replies", True):
|
||||
return
|
||||
await send_handoff_to_human(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
human_email=existing["human_email"],
|
||||
external_message=external_message,
|
||||
external_thread_id=external_thread_id,
|
||||
handoff_reason=existing.get("handoff_reason", ""),
|
||||
domain=domain,
|
||||
existing_handoff=existing,
|
||||
)
|
||||
return
|
||||
|
||||
rules = await database.human_handoff_rules.find(
|
||||
{"account_id": account_id, "active": True}
|
||||
).to_list(length=20)
|
||||
|
|
@ -667,9 +852,6 @@ async def maybe_trigger_handoff(
|
|||
if not rule.get("allow_replies", True):
|
||||
continue
|
||||
|
||||
existing = await find_active_handoff_for_external_thread(
|
||||
database, account_id, external_thread_id
|
||||
)
|
||||
reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention."
|
||||
await send_handoff_to_human(
|
||||
database,
|
||||
|
|
@ -680,6 +862,6 @@ async def maybe_trigger_handoff(
|
|||
external_thread_id=external_thread_id,
|
||||
handoff_reason=reason,
|
||||
domain=domain,
|
||||
existing_handoff=existing,
|
||||
existing_handoff=None,
|
||||
)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue