human hand off
This commit is contained in:
parent
d0fee5c8f7
commit
7af776196a
34 changed files with 2941 additions and 195 deletions
|
|
@ -1,23 +1,36 @@
|
|||
import re
|
||||
from datetime import datetime, timezone
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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)
|
||||
|
|
@ -31,8 +44,10 @@ def _owner_name_from_email(email: str) -> str:
|
|||
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", ""),
|
||||
|
|
@ -51,10 +66,231 @@ async def list_handoff_rules(
|
|||
):
|
||||
cursor = database.human_handoff_rules.find(
|
||||
{"account_id": str(account.id)}
|
||||
).sort("updated_at", -1)
|
||||
).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,
|
||||
|
|
@ -62,9 +298,30 @@ async def create_handoff_rule(
|
|||
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": str(account.id),
|
||||
"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,
|
||||
|
|
@ -76,10 +333,60 @@ async def create_handoff_rule(
|
|||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
await database.human_handoff_rules.insert_one(document)
|
||||
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,
|
||||
|
|
@ -90,14 +397,36 @@ async def update_handoff_rule(
|
|||
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()
|
||||
result = await database.human_handoff_rules.find_one_and_update(
|
||||
{"_id": ObjectId(rule_id), "account_id": str(account.id)},
|
||||
{"$set": patch},
|
||||
return_document=True,
|
||||
)
|
||||
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)
|
||||
|
|
@ -152,7 +481,7 @@ async def test_handoff(
|
|||
owner_email=owner_email,
|
||||
**evaluation,
|
||||
result_path=(
|
||||
"Would match handoff rule (configuration only)"
|
||||
"Would hand this email to the Human Owner"
|
||||
if evaluation["would_handoff"]
|
||||
else "Would not match handoff rule"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
|
@ -35,10 +36,13 @@ from app.services.thread_service import (
|
|||
message_event_at,
|
||||
)
|
||||
from app.services.external_reply_service import build_external_reply_plan, log_smtp_message_sending
|
||||
from app.services.handoff_mirror_policy import GENERATED_BY_AGENT, MESSAGE_ORIGIN_AGENT
|
||||
from app.services.human_handoff_service import maybe_trigger_handoff
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.thread_resolver import ThreadResolution
|
||||
|
||||
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/emails", tags=["emails"])
|
||||
|
||||
|
|
@ -191,6 +195,11 @@ async def create_email(
|
|||
)
|
||||
|
||||
created_at = utc_now()
|
||||
sender_name = " ".join(
|
||||
part.strip()
|
||||
for part in [account.first_name, account.last_name]
|
||||
if part and part.strip()
|
||||
)
|
||||
subject = payload.subject
|
||||
if parent_message:
|
||||
reply_plan = build_external_reply_plan(
|
||||
|
|
@ -213,10 +222,12 @@ async def create_email(
|
|||
document = {
|
||||
"direction": "outbound",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"message_origin": MESSAGE_ORIGIN_AGENT,
|
||||
"generated_by": GENERATED_BY_AGENT,
|
||||
"account_id": str(account.id),
|
||||
"sender": address(
|
||||
sender,
|
||||
" ".join(part for part in [account.first_name, account.last_name] if part),
|
||||
sender_name,
|
||||
),
|
||||
"to": [address(value) for value in recipients],
|
||||
"cc": [address(value) for value in cc],
|
||||
|
|
@ -251,6 +262,7 @@ async def create_email(
|
|||
)
|
||||
internet_message_id = await deliver_email(
|
||||
sender=sender,
|
||||
sender_name=sender_name,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
|
|
@ -324,6 +336,22 @@ async def create_email(
|
|||
"deletion_state": None,
|
||||
}
|
||||
)
|
||||
if document["status"] == "accepted":
|
||||
try:
|
||||
await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id=str(account.id),
|
||||
agent_email=sender,
|
||||
external_message=document,
|
||||
external_thread_id=thread_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"handoff_trigger_failed account_id=%s thread_id=%s message_id=%s",
|
||||
account.id,
|
||||
thread_id,
|
||||
document["_id"],
|
||||
)
|
||||
if document["status"] == "failed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue