Files
group_fqcd_jr/app/service/agent_persistence_service.py
T

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