Files
group_fqcd_jr/app/service/agent_persistence_service.py
T

326 lines
18 KiB
Python
Raw Normal View History

import logging
2026-09-09 21:55:37 +08:00
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
2026-09-09 21:55:37 +08:00
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.customer_service_rules import (
ALL_TRANSFER_REASONS,
normalize_transfer_reason,
transfer_priority,
)
2026-09-09 21:55:37 +08:00
from app.core.errors import RunLeaseLostError
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
2026-09-10 19:51:08 +08:00
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency
from app.model.risk import RiskUser
2026-09-11 16:12:38 +08:00
from app.model.session import ConversationSession
from app.service.customer_service_handover_context import (
MAX_SUMMARY_MESSAGES,
CustomerServiceHandoverContext,
build_customer_service_handover_context,
)
2026-09-09 21:55:37 +08:00
logger = logging.getLogger(__name__)
#: 建单白名单只对客服 Agent 生效(`E-01` ①)。这里硬编码而不导入实现层模块,
#: 避免底座(持久化服务)反向依赖业务 Agent 实现。
CUSTOMER_SERVICE_AGENT_TYPE = "customer_service"
#: 治理层追加免责声明时使用的分隔形状(`app/service/agent/governance.py` 里定义)。
#: 这里只用于**审计留痕**,不参与任何判定:判据是"末尾是否出现这个形状"。
_GOVERNANCE_APPEND_MARKERS: tuple[str, ...] = ("\n\n本内容仅为投资分析参考",)
def _suitability_record(result: AgentResult) -> dict[str, Any] | None:
"""取出本轮客服答复附带的适当性裁决留痕(`E-04`);没有则返回 None。
只认 `data['suitability']` 这个形状:其它 Agent 的 `data` 结构各不相同,
这里不做任何猜测式提取,避免把无关字段误记成合规留痕。
"""
data = result.result.data
if not isinstance(data, dict):
return None
candidate = data.get("suitability")
if not isinstance(candidate, dict):
return None
return dict(candidate)
def _governance_rewrote(result: AgentResult) -> bool:
"""治理层是否改写过这次输出(用于审计)。
两条可观测痕迹(都不改协议、只读结果本身):
1. **追加了固定免责声明**:正文末尾出现治理层使用的分隔形状;
2. **拦截并替换**:命中禁用词/硬规则时治理层会把回复换成安全话术并置 `transfer_required`。
保守取值:任一条成立即记 True。它只是审计信息,判错方向的代价是"多标了一次",
不会影响业务行为——因此宁可宽一点,也不为了精确而改动治理协议。
"""
text = result.result.text or ""
appended = any(text.endswith(marker) or marker in text
for marker in _GOVERNANCE_APPEND_MARKERS)
return appended or bool(result.result.transfer_required)
2026-09-09 21:55:37 +08:00
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,
2026-09-11 17:16:43 +08:00
*, worker_id: str | None = None, profile_candidate_requested: bool = False,
2026-09-09 21:55:37 +08:00
) -> 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
2026-09-14 18:13:10 +08:00
stored_tool_calls: dict[str, Any] = {
"calls": [call.model_dump(mode="json")
for call in result.result.tool_calls],
"transfer_required": bool(result.result.transfer_required),
"transfer_reason": result.result.transfer_reason,
# `E-05`:出口**声明**的主语(产品 / 类目 / 条款)随消息落库,
# 下一轮由 `runtime._conversation_history` 读成
# `ConversationTurn.subject` —— 读侧不再反解回答文本。
"topic": result.result.topic,
2026-09-14 18:13:10 +08:00
}
if result.result.data:
stored_tool_calls["data"] = result.result.data
if result.result.sql:
stored_tool_calls["sql"] = result.result.sql
2026-09-09 21:55:37 +08:00
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` 是**唯一能承载附加信息的现成 JSON 列**(`conversation_message`
# 没有 `transfer_required` 列,加列要迁移,而规则 4 禁止改既有字段定义)。
# 因此把「转人工标记」作为 `calls` 的**兄弟键**放进来:
# {"calls": [...], "transfer_required": bool, "transfer_reason": str|None}
# 之所以必须落库:`docs/05` §6.3 规定 `GET /agent-runs/{run_id}` 的
# `result` 里要有 `transfer_required` / `transfer_reason`,而它此前
# **既没落库也没出参** —— 前端只能靠"回答里是否含兜底话术开头"来猜要不要转人工
# (`docs/24` 自己把这称为权宜之计)。落库后读写两侧才有同一份真相。
# 读侧允许 `calls` 是裸列表(历史行),见 `RunQueryService.get`。
2026-09-14 18:13:10 +08:00
tool_calls=stored_tool_calls,
2026-09-09 21:55:37 +08:00
)
self.session.add(message)
await self.session.flush()
2026-09-10 19:51:08 +08:00
handover_ticket: HandoverTicket | None = None
handover_context: CustomerServiceHandoverContext | None = None
# `E-04`:适当性裁决要**同时**落消息表(上面 `stored_tool_calls['data']`)
# 与审计表。只落消息表的话,合规要按「谁在什么时候对哪个客户做过风险
# 揭示」去查,就得翻聊天正文 —— 审计表里一行结构化记录才是可核对的凭据。
# 零 DDL:`interaction_audit` 表与 `action_type` 列都已存在。
suitability_record = _suitability_record(result)
2026-09-10 19:51:08 +08:00
if result.result.transfer_required:
# 访客 subject 不是正式用户主键,先按 RiskUser 查询,查不到则保留空归属。
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
)
# `E-01` ①:**建单白名单只对客服 Agent 生效** —— 其它 Agent 的
# 建单行为一字不改。客服侧的白名单由 `H-04` 收在 `_exit_transfer`
# (`ALL_TRANSFER_REASONS` = 四类白名单 ∪ 治理层合规码,见规则文件)
# (越界即抛错),这里再留一道**响亮告警**:将来若有人又把
# 「答不上来就转人工」塞进客服侧,日志会立刻出现 ERROR。
#
# 为什么**仍然建单**:走到这里说明已经对客户说过「为您转接人工」了,
# 把工单丢掉等于把客户半路扔下 —— 那比原因码不够精确严重得多。
# 因此这里的收敛动作是「纠原因码 + 告警」,不是「拒绝建单」。
if (run.agent_type == CUSTOMER_SERVICE_AGENT_TYPE
and result.result.transfer_reason not in ALL_TRANSFER_REASONS):
logger.error(
"客服转人工原因不在白名单内(回归信号):agent_type=%s "
"transfer_reason=%r",
run.agent_type, result.result.transfer_reason,
)
# `E-01` ③:`reason_code` 是**枚举列**,自由文本一律收敛后再落库。
raw_reason = result.result.transfer_reason
reason_code = normalize_transfer_reason(raw_reason)
if reason_code != raw_reason:
logger.warning(
"转人工原因非枚举码,已收敛:agent_type=%s raw=%r -> %s",
run.agent_type, raw_reason, reason_code,
)
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,
)
2026-09-10 19:51:08 +08:00
handover_ticket = HandoverTicket(
ticket_no=f"ticket-{uuid4().hex[:24]}",
session_id=run.session_id,
customer_id=ticket_customer_id,
2026-09-10 19:51:08 +08:00
source_agent=run.agent_type,
source_message_id=message.id,
intent=(result.result.intent.intent if result.result.intent else None),
confidence=confidence,
# `E-01` ②:优先级由原因码映射而来
# (此前从未赋值,恒为模型默认值 `P1`,队列索引白建)。
priority=transfer_priority(reason_code),
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,
2026-09-10 19:51:08 +08:00
)
self.session.add(handover_ticket)
if suitability_record 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.suitability_disclosed",
detail={
"agent_type": run.agent_type,
"trace_id": run.trace_id,
**suitability_record,
},
created_at=now,
))
2026-09-09 21:55:37 +08:00
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
2026-09-11 16:12:38 +08:00
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 and result.result.intent is not None:
session_row.last_intent = result.result.intent.intent
session_row.updated_at = now
2026-09-09 21:55:37 +08:00
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.run_completed",
# `agent_type` 必须落进审计:治理层(`PlatformGovernance.review`)会**改写对外
# 输出**(追加固定免责声明、命中禁用词时整条替换成安全话术),事后要能回答
# "这次改写是哪个 Agent 触发的、改写到了什么程度"。
# `governance_rewrite` 记录治理是否动过输出:正文里出现固定话术的追加形状,
# 或该次运行被标记为需转人工(拦截分支会置 `transfer_required`)。
# 不改表结构:`detail` 是 JSON 列,加键不需要迁移(AGENTS.md 规则 4)。
detail={
"run_id": run_id,
"result_message_id": message.id,
"agent_type": run.agent_type,
"governance_rewrite": _governance_rewrote(result),
},
created_at=now,
2026-09-09 21:55:37 +08:00
))
2026-09-10 19:51:08 +08:00
if handover_ticket is not None:
assert handover_context is not None
2026-09-10 19:51:08 +08:00
self.session.add(InteractionAudit(
actor_type="agent", actor_id=run.user_id,
target_customer_id=handover_ticket.customer_id,
2026-09-10 19:51:08 +08:00
session_id=run.session_id, portal="agent",
action_type="agent.handover_requested",
detail={
"run_id": run_id, "ticket_no": handover_ticket.ticket_no,
2026-09-10 19:51:08 +08:00
"reason_code": handover_ticket.reason_code,
"clarification_round": clarification_round,
"source_reference_count": len(handover_context.source_references),
2026-09-10 19:51:08 +08:00
},
created_at=now,
))
2026-09-09 21:55:37 +08:00
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,
))
2026-09-11 17:16:43 +08:00
if profile_candidate_requested:
# 候选画像只允许由已登录客服会话触发;Worker 会再次校验身份标记。
2026-09-11 17:16:43 +08:00
events.append(DomainEvent(
event_id=str(uuid4()),
event_type="customer_profile.candidate_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,
"actor_type": "authenticated_customer",
},
occurred_at=now,
))
2026-09-10 19:51:08 +08:00
if handover_ticket is not None:
assert handover_context is not None
2026-09-10 19:51:08 +08:00
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,
2026-09-10 19:51:08 +08:00
))
2026-09-09 21:55:37 +08:00
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