human handoffs

This commit is contained in:
debu-2 2026-10-06 01:51:23 +05:30
parent 79188e0f59
commit 36a55930f9
35 changed files with 4039 additions and 139 deletions

View file

@ -0,0 +1,309 @@
import re
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import EmailStr, Field
from app.database import get_mongo_database
from app.models import Account
from app.routers.auth import get_current_account
from app.schemas import (
HandoffConversationRead,
HandoffConversationSummary,
HandoffLinkedExternal,
HandoffMessageRead,
HandoffRuleCreate,
HandoffRuleRead,
HandoffRuleUpdate,
HandoffTestRequest,
HandoffTestResult,
)
from app.services.human_handoff_service import MESSAGE_SCOPE_HANDOFF
from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"])
def _now() -> datetime:
return datetime.now(timezone.utc)
def _owner_name_from_email(email: str) -> str:
local = email.split("@", 1)[0]
return re.sub(r"[._-]+", " ", local).strip().title() or email
def _status_label(status: str, current_owner: str) -> str:
if status == "WAITING_FOR_HUMAN":
return "Waiting for Human"
if status == "HUMAN_REPLIED":
return "Needs Agent Reply"
if status == "AGENT_REPLIED":
return "Replied"
if status == "CLOSED":
return "Closed"
if current_owner == "HUMAN":
return "Waiting for Human"
return "Active"
def _serialize(document: dict) -> HandoffRuleRead:
return HandoffRuleRead(
id=str(document["_id"]),
owner_email=document["owner_email"],
owner_name=document.get("owner_name") or _owner_name_from_email(document["owner_email"]),
labels=document.get("labels", []),
email_types=document.get("email_types", []),
instructions=document.get("instructions", ""),
allow_replies=document.get("allow_replies", True),
reply_as=document.get("reply_as", "agent"),
active=document.get("active", True),
created_at=document["created_at"],
updated_at=document["updated_at"],
)
@router.get("", response_model=list[HandoffRuleRead])
async def list_handoff_rules(
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
cursor = database.human_handoff_rules.find(
{"account_id": str(account.id)}
).sort("updated_at", -1)
return [_serialize(document) async for document in cursor]
@router.post("", response_model=HandoffRuleRead, status_code=status.HTTP_201_CREATED)
async def create_handoff_rule(
payload: HandoffRuleCreate,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
now = _now()
document = {
"_id": ObjectId(),
"account_id": str(account.id),
"owner_email": str(payload.owner_email).lower(),
"owner_name": payload.owner_name or _owner_name_from_email(str(payload.owner_email)),
"labels": payload.labels,
"email_types": payload.email_types,
"instructions": payload.instructions.strip(),
"allow_replies": payload.allow_replies,
"reply_as": payload.reply_as,
"active": payload.active,
"created_at": now,
"updated_at": now,
}
await database.human_handoff_rules.insert_one(document)
return _serialize(document)
@router.patch("/{rule_id}", response_model=HandoffRuleRead)
async def update_handoff_rule(
rule_id: str,
payload: HandoffRuleUpdate,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
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 "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,
)
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
return _serialize(result)
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_handoff_rule(
rule_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(rule_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
result = await database.human_handoff_rules.delete_one(
{"_id": ObjectId(rule_id), "account_id": str(account.id)}
)
if result.deleted_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
@router.post("/test", response_model=HandoffTestResult)
async def test_handoff(
payload: HandoffTestRequest,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
labels = payload.labels
email_types = payload.email_types
instructions = payload.instructions
owner_email = payload.owner_email
if payload.rule_id:
if not ObjectId.is_valid(payload.rule_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
rule = await database.human_handoff_rules.find_one(
{"_id": ObjectId(payload.rule_id), "account_id": str(account.id)}
)
if rule is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
labels = rule.get("labels", [])
email_types = rule.get("email_types", [])
instructions = rule.get("instructions", "")
owner_email = rule.get("owner_email", owner_email)
evaluation = evaluate_handoff(
email=payload.email.model_dump(),
labels=labels,
email_types=email_types,
instructions=instructions or "",
)
return HandoffTestResult(
owner_email=owner_email,
**evaluation,
result_path="Agent → Human Owner" if evaluation["would_handoff"] else "Agent handles email",
reason=evaluation["instruction_reason"],
)
@router.get("/conversations", response_model=list[HandoffConversationSummary])
async def list_handoff_conversations(
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
account_id = str(account.id)
cursor = database.human_handoffs.find({"account_id": account_id}).sort("updated_at", -1)
items: list[HandoffConversationSummary] = []
async for handoff in cursor:
handoff_thread_id = handoff["handoff_thread_id"]
count = await database.mailbox_entries.count_documents(
{
"account_id": account_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
latest = await database.mailbox_entries.find_one(
{
"account_id": account_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
sort=[("event_at", -1), ("_id", -1)],
)
snippet = latest.get("snippet", "") if latest else ""
items.append(
HandoffConversationSummary(
id=str(handoff["_id"]),
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=_status_label(
handoff.get("status", "ACTIVE"),
handoff.get("current_owner", "AGENT"),
),
current_owner=handoff.get("current_owner", "AGENT"),
snippet=snippet,
message_count=count,
updated_at=handoff.get("updated_at") or handoff.get("created_at"),
)
)
return items
@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.")
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": handoff["handoff_thread_id"],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
},
{"$sort": {"event_at": 1, "_id": 1}},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
]
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
human_email = handoff.get("human_email", "").lower()
messages: list[HandoffMessageRead] = []
for row in rows:
message = row["message"]
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"]
messages.append(
HandoffMessageRead(
id=str(message["_id"]),
role=role,
subject=message.get("subject", ""),
snippet=message.get("snippet", ""),
body_text=message.get("body_text", ""),
event_at=event_at,
)
)
return HandoffConversationRead(
id=str(handoff["_id"]),
external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""),
handoff_reason=handoff.get("handoff_reason", ""),
status=handoff.get("status", "ACTIVE"),
status_label=_status_label(
handoff.get("status", "ACTIVE"),
handoff.get("current_owner", "AGENT"),
),
current_owner=handoff.get("current_owner", "AGENT"),
linked_external=HandoffLinkedExternal(
thread_id=str(handoff["external_thread_id"]),
subject=handoff.get("external_subject", ""),
sender=handoff.get("external_sender", ""),
),
messages=messages,
)
@router.get("/meta")
async def handoff_meta():
return {
"labels": HANDOFF_LABELS,
"email_types": [
{
"id": key,
"title": meta["title"],
}
for key, meta in EMAIL_TYPE_META.items()
],
}