human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
309
backend/app/routers/agent_handoff.py
Normal file
309
backend/app/routers/agent_handoff.py
Normal 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()
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue