503 lines
18 KiB
Python
503 lines
18 KiB
Python
import re
|
|
from datetime import datetime, timezone
|
|
from bson import ObjectId
|
|
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)
|
|
|
|
|
|
def _owner_name_from_email(email: str) -> str:
|
|
local = email.split("@", 1)[0]
|
|
return re.sub(r"[._-]+", " ", local).strip().title() or email
|
|
|
|
|
|
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", ""),
|
|
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([("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,
|
|
database=Depends(get_mongo_database),
|
|
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": 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,
|
|
"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,
|
|
}
|
|
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,
|
|
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 "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()
|
|
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)
|
|
|
|
|
|
@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=(
|
|
"Would hand this email to the Human Owner"
|
|
if evaluation["would_handoff"]
|
|
else "Would not match handoff rule"
|
|
),
|
|
reason=evaluation["instruction_reason"],
|
|
)
|
|
|
|
|
|
@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()
|
|
],
|
|
}
|