320 lines
12 KiB
Python
320 lines
12 KiB
Python
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"},
|
|
]
|
|
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"]
|
|
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"]
|
|
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()
|
|
),
|
|
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()
|
|
],
|
|
}
|