human hand off

This commit is contained in:
debu-2 2026-10-06 07:12:55 +05:30
parent d0fee5c8f7
commit 7af776196a
34 changed files with 2941 additions and 195 deletions

View file

@ -1,23 +1,36 @@
import re
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import EmailStr, Field
from pymongo.errors import DuplicateKeyError
from app.database import get_mongo_database
from app.models import Account
from app.routers.auth import get_current_account
from app.schemas import (
HandoffRuleOrderUpdate,
HandoffRuleCreate,
HandoffRuleRead,
HandoffRuleUpdate,
HandoffTestRequest,
HandoffTestResult,
HandoffConversationRead,
HandoffConversationSummary,
HandoffLinkedExternal,
HandoffMessageRead,
)
from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
from app.services.handoff_delivery import (
STATUS_AMBIGUOUS,
STATUS_FAILED,
STATUS_PENDING,
)
router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"])
HANDOFF_MAILBOXES = {"handoff_inbox", "handoff_outbox"}
def _now() -> datetime:
return datetime.now(timezone.utc)
@ -31,8 +44,10 @@ def _owner_name_from_email(email: str) -> str:
def _serialize(document: dict) -> HandoffRuleRead:
return HandoffRuleRead(
id=str(document["_id"]),
name=document.get("name") or document.get("owner_name") or "Human handoff",
owner_email=document["owner_email"],
owner_name=document.get("owner_name") or _owner_name_from_email(document["owner_email"]),
priority=document.get("priority", 0),
labels=document.get("labels", []),
email_types=document.get("email_types", []),
instructions=document.get("instructions", ""),
@ -51,10 +66,231 @@ async def list_handoff_rules(
):
cursor = database.human_handoff_rules.find(
{"account_id": str(account.id)}
).sort("updated_at", -1)
).sort([("priority", 1), ("created_at", 1), ("_id", 1)])
return [_serialize(document) async for document in cursor]
def _handoff_status_label(value: str) -> str:
return {
"ACTIVE": "Active",
"WAITING_FOR_HUMAN": "Waiting for human",
"HUMAN_REPLIED": "Human replied",
"CLOSED": "Closed",
}.get(value, value.replace("_", " ").title())
@router.get("/conversations", response_model=list[HandoffConversationSummary])
async def list_handoff_conversations(
mailbox: str = Query(default="handoff_inbox"),
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if mailbox not in HANDOFF_MAILBOXES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid handoff mailbox.",
)
account_id = str(account.id)
cursor = database.mailbox_entries.find(
{
"account_id": account_id,
"message_scope": "HANDOFF",
"mailbox": mailbox,
"deletion_state": {"$in": [None, ""]},
}
).sort([("event_at", -1), ("_id", -1)])
seen: set[ObjectId] = set()
items: list[HandoffConversationSummary] = []
async for entry in cursor:
thread_id = entry.get("thread_id")
if not thread_id or thread_id in seen:
continue
seen.add(thread_id)
handoff = await database.human_handoffs.find_one(
{
"account_id": account_id,
"handoff_thread_id": thread_id,
}
)
message = await database.messages.find_one({"_id": entry["message_id"]})
thread = await database.email_threads.find_one({"_id": thread_id})
if not handoff or not message:
continue
items.append(
HandoffConversationSummary(
id=str(handoff["_id"]),
thread_id=str(thread_id),
mailbox=mailbox,
rule_name=handoff.get("rule_name", "Human handoff"),
external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""),
status=handoff.get("status", "ACTIVE"),
status_label=_handoff_status_label(
handoff.get("status", "ACTIVE")
),
current_owner=handoff.get("current_owner", "HUMAN"),
snippet=message.get("new_reply_content")
or message.get("snippet", ""),
message_count=(thread or {}).get("message_count", 1),
updated_at=entry.get("event_at")
or handoff.get("updated_at")
or _now(),
)
)
return items
@router.get("/conversations/counts")
async def handoff_conversation_counts(
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
account_id = str(account.id)
result = {}
for mailbox in sorted(HANDOFF_MAILBOXES):
thread_ids = await database.mailbox_entries.distinct(
"thread_id",
{
"account_id": account_id,
"message_scope": "HANDOFF",
"mailbox": mailbox,
"deletion_state": {"$in": [None, ""]},
},
)
result[mailbox] = len([value for value in thread_ids if value])
return result
@router.post("/deliveries/{job_id}/retry")
async def retry_handoff_delivery(
job_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(job_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid delivery job ID.",
)
now = _now()
result = await database.handoff_delivery_jobs.find_one_and_update(
{
"_id": ObjectId(job_id),
"account_id": str(account.id),
"status": {"$in": [STATUS_FAILED, STATUS_AMBIGUOUS]},
},
{
"$set": {
"status": STATUS_PENDING,
"next_attempt_at": now,
"updated_at": now,
},
"$unset": {
"error": "",
"failed_at": "",
"ambiguous_at": "",
},
},
return_document=True,
)
if result is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This delivery cannot be retried.",
)
return {"id": str(result["_id"]), "status": result["status"]}
@router.get(
"/conversations/{handoff_id}",
response_model=HandoffConversationRead,
)
async def get_handoff_conversation(
handoff_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(handoff_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid handoff ID.",
)
account_id = str(account.id)
handoff = await database.human_handoffs.find_one(
{"_id": ObjectId(handoff_id), "account_id": account_id}
)
if handoff is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Handoff conversation not found.",
)
cursor = database.mailbox_entries.find(
{
"account_id": account_id,
"thread_id": handoff["handoff_thread_id"],
"message_scope": "HANDOFF",
"deletion_state": {"$in": [None, ""]},
}
).sort([("event_at", 1), ("_id", 1)])
messages: list[HandoffMessageRead] = []
async for entry in cursor:
message = await database.messages.find_one({"_id": entry["message_id"]})
if not message:
continue
delivery = await database.handoff_delivery_jobs.find_one(
{
"$or": [
{"source_message_id": message["_id"]},
{"target_message_id": message["_id"]},
]
},
sort=[("created_at", -1)],
)
messages.append(
HandoffMessageRead(
id=str(message["_id"]),
role="human" if message.get("direction") == "inbound" else "agent",
subject=message.get("subject", ""),
snippet=message.get("snippet", ""),
body_text=message.get("body_text", ""),
body_html=message.get("body_html"),
new_reply_content=message.get("new_reply_content"),
raw_body=message.get("body_text")
if message.get("direction") == "inbound"
else None,
has_quoted_text=bool(message.get("has_quoted_text")),
relay_status=message.get("relay_status"),
delivery_status=(delivery or {}).get("status"),
delivery_job_id=str(delivery["_id"]) if delivery else None,
direction=message.get("direction", "outbound"),
mailbox=entry["mailbox"],
event_at=entry.get("event_at")
or message.get("received_at")
or message.get("sent_at")
or message["created_at"],
)
)
return HandoffConversationRead(
id=str(handoff["_id"]),
thread_id=str(handoff["handoff_thread_id"]),
rule_name=handoff.get("rule_name", "Human handoff"),
external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""),
handoff_reason="Matched handoff rule",
status=handoff.get("status", "ACTIVE"),
status_label=_handoff_status_label(handoff.get("status", "ACTIVE")),
current_owner=handoff.get("current_owner", "HUMAN"),
linked_external=HandoffLinkedExternal(
thread_id=str(handoff["external_thread_id"]),
subject=handoff.get("external_subject", ""),
sender=handoff.get("external_sender", ""),
),
messages=messages,
)
@router.post("", response_model=HandoffRuleRead, status_code=status.HTTP_201_CREATED)
async def create_handoff_rule(
payload: HandoffRuleCreate,
@ -62,9 +298,30 @@ async def create_handoff_rule(
account: Account = Depends(get_current_account),
):
now = _now()
account_id = str(account.id)
name = payload.name.strip()
duplicate = await database.human_handoff_rules.find_one(
{"account_id": account_id, "name_key": name.casefold()},
{"_id": 1},
)
if duplicate:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A handoff rule with this name already exists.",
)
priority = payload.priority
if priority is None:
latest = await database.human_handoff_rules.find_one(
{"account_id": account_id},
sort=[("priority", -1)],
)
priority = int(latest.get("priority", -1)) + 1 if latest else 0
document = {
"_id": ObjectId(),
"account_id": str(account.id),
"account_id": account_id,
"name": name,
"name_key": name.casefold(),
"priority": priority,
"owner_email": str(payload.owner_email).lower(),
"owner_name": payload.owner_name or _owner_name_from_email(str(payload.owner_email)),
"labels": payload.labels,
@ -76,10 +333,60 @@ async def create_handoff_rule(
"created_at": now,
"updated_at": now,
}
await database.human_handoff_rules.insert_one(document)
try:
await database.human_handoff_rules.insert_one(document)
except DuplicateKeyError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A handoff rule with this name or priority already exists.",
)
return _serialize(document)
@router.put("/reorder", response_model=list[HandoffRuleRead])
async def reorder_handoff_rules(
payload: HandoffRuleOrderUpdate,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if len(payload.rule_ids) != len(set(payload.rule_ids)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Rule IDs must be unique.",
)
if any(not ObjectId.is_valid(value) for value in payload.rule_ids):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid rule ID.",
)
account_id = str(account.id)
object_ids = [ObjectId(value) for value in payload.rule_ids]
count = await database.human_handoff_rules.count_documents(
{"account_id": account_id, "_id": {"$in": object_ids}}
)
if count != len(object_ids):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more handoff rules were not found.",
)
now = _now()
# Shift priorities out of the unique range first so swaps cannot collide.
for offset, rule_id in enumerate(object_ids):
await database.human_handoff_rules.update_one(
{"_id": rule_id, "account_id": account_id},
{"$set": {"priority": offset + 10_000, "updated_at": now}},
)
for priority, rule_id in enumerate(object_ids):
await database.human_handoff_rules.update_one(
{"_id": rule_id, "account_id": account_id},
{"$set": {"priority": priority, "updated_at": now}},
)
cursor = database.human_handoff_rules.find(
{"account_id": account_id}
).sort([("priority", 1), ("created_at", 1), ("_id", 1)])
return [_serialize(document) async for document in cursor]
@router.patch("/{rule_id}", response_model=HandoffRuleRead)
async def update_handoff_rule(
rule_id: str,
@ -90,14 +397,36 @@ async def update_handoff_rule(
if not ObjectId.is_valid(rule_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
patch = payload.model_dump(exclude_unset=True)
if "name" in patch:
patch["name"] = patch["name"].strip()
patch["name_key"] = patch["name"].casefold()
duplicate = await database.human_handoff_rules.find_one(
{
"account_id": str(account.id),
"name_key": patch["name_key"],
"_id": {"$ne": ObjectId(rule_id)},
},
{"_id": 1},
)
if duplicate:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A handoff rule with this name already exists.",
)
if "owner_email" in patch and patch["owner_email"]:
patch["owner_email"] = str(patch["owner_email"]).lower()
patch["updated_at"] = _now()
result = await database.human_handoff_rules.find_one_and_update(
{"_id": ObjectId(rule_id), "account_id": str(account.id)},
{"$set": patch},
return_document=True,
)
try:
result = await database.human_handoff_rules.find_one_and_update(
{"_id": ObjectId(rule_id), "account_id": str(account.id)},
{"$set": patch},
return_document=True,
)
except DuplicateKeyError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A handoff rule with this name or priority already exists.",
)
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
return _serialize(result)
@ -152,7 +481,7 @@ async def test_handoff(
owner_email=owner_email,
**evaluation,
result_path=(
"Would match handoff rule (configuration only)"
"Would hand this email to the Human Owner"
if evaluation["would_handoff"]
else "Would not match handoff rule"
),

View file

@ -1,5 +1,6 @@
import base64
import json
import logging
import re
from datetime import date, datetime, time, timedelta, timezone
from typing import Annotated
@ -35,10 +36,13 @@ from app.services.thread_service import (
message_event_at,
)
from app.services.external_reply_service import build_external_reply_plan, log_smtp_message_sending
from app.services.handoff_mirror_policy import GENERATED_BY_AGENT, MESSAGE_ORIGIN_AGENT
from app.services.human_handoff_service import maybe_trigger_handoff
from app.services.mailbox_scope import apply_external_scope
from app.services.thread_resolver import ThreadResolution
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/emails", tags=["emails"])
@ -191,6 +195,11 @@ async def create_email(
)
created_at = utc_now()
sender_name = " ".join(
part.strip()
for part in [account.first_name, account.last_name]
if part and part.strip()
)
subject = payload.subject
if parent_message:
reply_plan = build_external_reply_plan(
@ -213,10 +222,12 @@ async def create_email(
document = {
"direction": "outbound",
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_origin": MESSAGE_ORIGIN_AGENT,
"generated_by": GENERATED_BY_AGENT,
"account_id": str(account.id),
"sender": address(
sender,
" ".join(part for part in [account.first_name, account.last_name] if part),
sender_name,
),
"to": [address(value) for value in recipients],
"cc": [address(value) for value in cc],
@ -251,6 +262,7 @@ async def create_email(
)
internet_message_id = await deliver_email(
sender=sender,
sender_name=sender_name,
recipients=recipients,
cc=cc,
bcc=bcc,
@ -324,6 +336,22 @@ async def create_email(
"deletion_state": None,
}
)
if document["status"] == "accepted":
try:
await maybe_trigger_handoff(
database,
account_id=str(account.id),
agent_email=sender,
external_message=document,
external_thread_id=thread_id,
)
except Exception:
logger.exception(
"handoff_trigger_failed account_id=%s thread_id=%s message_id=%s",
account.id,
thread_id,
document["_id"],
)
if document["status"] == "failed":
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,