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.handoff_mirror_policy import is_relay_acknowledgment_message 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"}, ] cursor = await database.mailbox_entries.aggregate(pipeline) rows = [row async for row in cursor] human_email = handoff.get("human_email", "").lower() messages: list[HandoffMessageRead] = [] for row in rows: message = row["message"] if is_relay_acknowledgment_message(message): continue sender_address = (message.get("sender") or {}).get("address", "").lower() if message.get("mirrored_from_external_message_id"): role = "customer" elif sender_address == human_email: role = "human" else: role = "agent" event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"] raw_body = message.get("raw_body") new_reply_content = message.get("new_reply_content") display_body = new_reply_content or message.get("body_text", "") messages.append( HandoffMessageRead( id=str(message["_id"]), role=role, subject=message.get("subject", ""), snippet=message.get("snippet", "") or display_body[:240], body_text=display_body, new_reply_content=new_reply_content, raw_body=raw_body, has_quoted_text=bool( raw_body and new_reply_content and raw_body.strip() != new_reply_content.strip() ), relay_status=message.get("relay_status") if role == "human" else None, 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() ], }