191 lines
10 KiB
Python
191 lines
10 KiB
Python
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.contracts import AgentResult, DomainEvent
|
|
from app.core.errors import RunLeaseLostError
|
|
from app.model.audit import InteractionAudit
|
|
from app.model.conversation import ConversationMessage
|
|
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency
|
|
from app.model.risk import RiskUser
|
|
from app.model.session import ConversationSession
|
|
from app.service.customer_service_handover_context import (
|
|
MAX_SUMMARY_MESSAGES,
|
|
CustomerServiceHandoverContext,
|
|
build_customer_service_handover_context,
|
|
)
|
|
|
|
|
|
class AgentPersistenceService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
async def complete_run(
|
|
self, run_id: str, result: AgentResult, memory_extraction_requested: bool = True,
|
|
*, worker_id: str | None = None,
|
|
) -> int:
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|
async with self.session.begin():
|
|
run = await self.session.scalar(
|
|
select(AgentRun).where(AgentRun.run_id == run_id).with_for_update()
|
|
)
|
|
if run is None:
|
|
raise ValueError("run not found")
|
|
if result.run_id != run_id:
|
|
raise ValueError("result belongs to another run")
|
|
if run.status == "succeeded" and run.result_message_id is not None:
|
|
return run.result_message_id
|
|
if worker_id is not None and (
|
|
run.status != "running" or run.worker_id != worker_id
|
|
or run.locked_until is None or run.locked_until <= now
|
|
):
|
|
raise RunLeaseLostError("运行租约失效或已取消")
|
|
if run.status not in {"queued", "running"}:
|
|
raise RunLeaseLostError("不能覆盖运行终态")
|
|
clarification_round = 0
|
|
# 客服的澄清计数只在成功完成本轮回复时更新。请求澄清递增,其他客服结果重置,
|
|
# 这样用户提供足够信息后不会被上一轮低置信状态永久卡住。
|
|
if run.agent_type == "customer_service":
|
|
session_row = await self.session.scalar(
|
|
select(ConversationSession).where(
|
|
ConversationSession.session_id == run.session_id,
|
|
ConversationSession.user_id == run.user_id,
|
|
).with_for_update()
|
|
)
|
|
if session_row is not None:
|
|
clarification_round = session_row.clarification_round
|
|
if result.result.clarification_required:
|
|
session_row.clarification_round = min(
|
|
session_row.clarification_round + 1, 2
|
|
)
|
|
else:
|
|
session_row.clarification_round = 0
|
|
message = ConversationMessage(
|
|
session_id=run.session_id, customer_id=run.user_id, portal="agent",
|
|
role="assistant", content=result.result.text,
|
|
trace_id=run.trace_id, created_at=now,
|
|
intent=result.result.intent.intent if result.result.intent else None,
|
|
confidence=(Decimal(str(result.result.intent.confidence))
|
|
if result.result.intent else None),
|
|
source_references=[ref.model_dump(mode="json")
|
|
for ref in result.result.source_references],
|
|
tool_calls={"calls": [call.model_dump(mode="json")
|
|
for call in result.result.tool_calls]},
|
|
)
|
|
self.session.add(message)
|
|
await self.session.flush()
|
|
# 转人工只接受已完成治理决策的结果;工单与回复绑定,便于管理员回看上下文。
|
|
handover_ticket: HandoverTicket | None = None
|
|
handover_context: CustomerServiceHandoverContext | None = None
|
|
if result.result.transfer_required:
|
|
# 访客令牌的 subject 只是匿名会话标识,不对应 sys_user;工单归属字段
|
|
# 可为空,避免把匿名 ID 当成正式用户外键,同时保留 session_id 供后台追踪。
|
|
ticket_customer_id = await self.session.scalar(
|
|
select(RiskUser.id).where(RiskUser.id == run.user_id)
|
|
)
|
|
# 只取本会话最新若干已持久化消息;构造器会二次脱敏和截断,避免历史敏感
|
|
# 内容在转人工时被再次扩散。
|
|
recent_messages = list(await self.session.scalars(
|
|
select(ConversationMessage)
|
|
.where(ConversationMessage.session_id == run.session_id)
|
|
.order_by(ConversationMessage.id.desc())
|
|
.limit(MAX_SUMMARY_MESSAGES)
|
|
))
|
|
recent_messages.reverse()
|
|
confidence = (
|
|
Decimal(str(result.result.intent.confidence))
|
|
if result.result.intent else None
|
|
)
|
|
reason_code = result.result.transfer_reason or "agent_requested"
|
|
handover_context = build_customer_service_handover_context(
|
|
reason_code=reason_code, clarification_round=clarification_round,
|
|
confidence=confidence, source_references=result.result.source_references,
|
|
messages=recent_messages,
|
|
)
|
|
handover_ticket = HandoverTicket(
|
|
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
|
session_id=run.session_id,
|
|
customer_id=ticket_customer_id,
|
|
source_agent=run.agent_type,
|
|
source_message_id=message.id,
|
|
intent=(result.result.intent.intent if result.result.intent else None),
|
|
confidence=confidence,
|
|
reason_code=reason_code,
|
|
reason_detail=handover_context.reason_detail,
|
|
conversation_summary=handover_context.conversation_summary,
|
|
source_references=handover_context.source_references,
|
|
status="pending",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
self.session.add(handover_ticket)
|
|
run.result_message_id = message.id
|
|
run.status = "succeeded"
|
|
run.result_version = 1
|
|
run.completed_at = now
|
|
run.updated_at = now
|
|
run.locked_until = None
|
|
self.session.add(InteractionAudit(
|
|
actor_type="agent", actor_id=run.user_id,
|
|
target_customer_id=handover_ticket.customer_id if handover_ticket else run.user_id,
|
|
session_id=run.session_id, portal="agent", action_type="agent.run_completed",
|
|
detail={"run_id": run_id, "result_message_id": message.id}, created_at=now,
|
|
))
|
|
if handover_ticket is not None:
|
|
# 管理员工单列表之外还需留一条不可变审计,记录 Agent 自动转接来源。
|
|
assert handover_context is not None
|
|
self.session.add(InteractionAudit(
|
|
actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id,
|
|
session_id=run.session_id, portal="agent",
|
|
action_type="agent.handover_requested",
|
|
detail={
|
|
"run_id": run_id,
|
|
"ticket_no": handover_ticket.ticket_no,
|
|
"reason_code": handover_ticket.reason_code,
|
|
"clarification_round": clarification_round,
|
|
"source_reference_count": len(handover_context.source_references),
|
|
},
|
|
created_at=now,
|
|
))
|
|
await self.session.execute(
|
|
update(RequestIdempotency)
|
|
.where(RequestIdempotency.id == run.idempotency_id)
|
|
.values(status="completed", result_message_id=message.id, updated_at=now)
|
|
)
|
|
events = [DomainEvent(
|
|
event_id=str(uuid4()), event_type="agent.run_completed", aggregate_type="agent_run",
|
|
aggregate_id=run_id, trace_id=run.trace_id,
|
|
payload={"run_id": run_id}, occurred_at=now,
|
|
)]
|
|
if memory_extraction_requested:
|
|
events.append(DomainEvent(
|
|
event_id=str(uuid4()), event_type="memory.extraction_requested",
|
|
aggregate_type="agent_run", aggregate_id=run_id, trace_id=run.trace_id,
|
|
payload={"run_id": run_id, "message_id": message.id,
|
|
"customer_id": run.user_id}, occurred_at=now,
|
|
))
|
|
if handover_ticket is not None:
|
|
# Outbox 事件由后续管理员通知/工单消费方可靠投递,服务层不直接通知外部系统。
|
|
assert handover_context is not None
|
|
events.append(DomainEvent(
|
|
event_id=str(uuid4()), event_type="conversation.transfer_requested",
|
|
aggregate_type="conversation", aggregate_id=run.session_id,
|
|
trace_id=run.trace_id,
|
|
payload={
|
|
"ticket_no": handover_ticket.ticket_no,
|
|
"handover_context": handover_context.event_metadata,
|
|
},
|
|
occurred_at=now,
|
|
))
|
|
for event in events:
|
|
self.session.add(DomainEventOutbox(
|
|
event_id=event.event_id, event_type=event.event_type,
|
|
aggregate_type=event.aggregate_type, aggregate_id=event.aggregate_id,
|
|
trace_id=event.trace_id, payload=event.payload, occurred_at=now,
|
|
created_at=now, updated_at=now,
|
|
))
|
|
return message.id
|