Files
group_fqcd_jr/tests/unit/service/test_agent_persistence_handover.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

346 lines
13 KiB
Python

from datetime import UTC, datetime
from typing import Any
import pytest
from app.core.contracts import AgentResult, CoreResult, IntentResult
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket
from app.model.session import ConversationSession
from app.service.agent_persistence_service import AgentPersistenceService
class FakeTransaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(
self, exc_type: object, exc_value: object, traceback: object
) -> bool:
return False
class FakeSession:
"""仅收集持久化服务在同一事务内计划写入的 ORM 实体。"""
def __init__(self, run: AgentRun) -> None:
self.run = run
self.added: list[Any] = []
self.executed: list[Any] = []
self._scalar_calls = 0
self.conversation_messages = [ConversationMessage(
session_id=run.session_id, customer_id=run.user_id, portal="api", role="user",
content="我需要人工协助", trace_id=run.trace_id,
created_at=datetime.now(UTC).replace(tzinfo=None),
)]
def begin(self) -> FakeTransaction:
return FakeTransaction()
async def scalar(self, statement: object) -> object:
self._scalar_calls += 1
if self._scalar_calls == 1:
return self.run
# 根据查询表名区分“正式客户存在”与“当前测试没有会话行”。
return self.run.user_id if "sys_user" in str(statement) else None
def add(self, item: Any) -> None:
self.added.append(item)
async def flush(self) -> None:
for item in self.added:
if isinstance(item, ConversationMessage) and item.id is None:
item.id = 901
async def execute(self, statement: object) -> None:
self.executed.append(statement)
async def scalars(self, statement: object) -> Any:
del statement
assistant_messages = [
item for item in self.added if isinstance(item, ConversationMessage)
]
return iter([*self.conversation_messages, *assistant_messages])
def queued_run() -> AgentRun:
now = datetime.now(UTC).replace(tzinfo=None)
return AgentRun(
id=1,
run_id="run-transfer-1",
idempotency_id=2,
session_id="session-transfer-1",
user_id=7,
agent_type="customer_service",
trace_id="trace-transfer-1",
request_message_id=800,
status="queued",
created_at=now,
updated_at=now,
)
def result(
*, transfer_required: bool, transfer_reason: str = "write_or_dispute"
) -> AgentResult:
return AgentResult(
run_id="run-transfer-1",
result=CoreResult(
text="已为您转接人工客服。",
intent=IntentResult(intent="human_handover", confidence=1),
transfer_required=transfer_required,
# `E-01` ③:`reason_code` 是枚举列 —— fixture 必须用枚举码,
# 不能用公开接口的请求取值 `user_requested`。
transfer_reason=transfer_reason if transfer_required else None,
),
)
def added_of(items: list[Any], model: type[Any]) -> list[Any]:
return [item for item in items if isinstance(item, model)]
@pytest.mark.asyncio
async def test_transfer_required_result_creates_pending_ticket_and_outbox_event() -> None:
"""Agent 发起的转人工必须在完成运行的事务内留待处理工单和通知事件。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
)
tickets = added_of(session.added, HandoverTicket)
events = added_of(session.added, DomainEventOutbox)
assert len(tickets) == 1
assert tickets[0].status == "pending"
assert tickets[0].session_id == "session-transfer-1"
assert tickets[0].customer_id == 7
assert tickets[0].source_agent == "customer_service"
assert tickets[0].source_message_id == 901
assert tickets[0].reason_code == "write_or_dispute"
# `E-01` ②:优先级由原因码映射而来(此前恒为模型默认值 P1)
assert tickets[0].priority == "P2"
assert tickets[0].reason_detail == (
"系统自动转接;原因=write_or_dispute;澄清轮次=0;知识来源数=0"
)
assert "最近会话(已脱敏)" in (tickets[0].conversation_summary or "")
assert len(events) == 2 # agent.run_completed + conversation.transfer_requested
transfer_event = next(
event for event in events if event.event_type == "conversation.transfer_requested"
)
assert transfer_event.aggregate_type == "conversation"
assert transfer_event.aggregate_id == "session-transfer-1"
assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no
context = transfer_event.payload["handover_context"]
assert context["reason_code"] == "write_or_dispute"
assert context["clarification_round"] == 0
assert context["source_references"] == []
@pytest.mark.asyncio
async def test_handover_context_redacts_historical_sensitive_values() -> None:
"""即使旧会话里有原始凭据,工单与 Outbox 摘要也必须二次脱敏。"""
session = FakeSession(queued_run())
session.conversation_messages = [ConversationMessage(
session_id=session.run.session_id, customer_id=session.run.user_id, portal="api",
role="user", content="验证码 123456,银行卡 6222021234567890123,登录密码: Secret123",
trace_id=session.run.trace_id, created_at=datetime.now(UTC).replace(tzinfo=None),
)]
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
)
ticket = added_of(session.added, HandoverTicket)[0]
event = next(
item for item in added_of(session.added, DomainEventOutbox)
if item.event_type == "conversation.transfer_requested"
)
summary = ticket.conversation_summary or ""
event_summary = event.payload["handover_context"]["conversation_summary"]
for raw_value in ("123456", "6222021234567890123", "Secret123"):
assert raw_value not in summary
assert raw_value not in event_summary
assert "验证码" in summary
class VisitorSession(FakeSession):
"""模拟访客运行:运行记录存在,但不存在对应的正式 sys_user。"""
def __init__(self, run: AgentRun) -> None:
super().__init__(run)
async def scalar(self, statement: object) -> object:
self._scalar_calls += 1
if self._scalar_calls == 1:
return self.run
return None
@pytest.mark.asyncio
async def test_visitor_transfer_ticket_does_not_use_anonymous_id_as_customer_fk() -> None:
"""访客转人工写匿名工单,customer_id 必须为空而不是随机访客 ID。"""
session = VisitorSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
)
tickets = added_of(session.added, HandoverTicket)
assert len(tickets) == 1
assert tickets[0].customer_id is None
class ClarificationSession(FakeSession):
"""模拟拥有真实会话行的客服运行,验证轮次仅在成功结果中改变。"""
def __init__(self, run: AgentRun, clarification_round: int) -> None:
super().__init__(run)
self.row = ConversationSession(
session_id=run.session_id, user_id=run.user_id, agent_type=run.agent_type,
portal="api", status="active", clarification_round=clarification_round,
)
async def scalar(self, statement: object) -> object:
self._scalar_calls += 1
if self._scalar_calls == 1:
return self.run
if "svc_conversation_session" in str(statement):
return self.row
return self.run.user_id if "sys_user" in str(statement) else None
def clarification_result(*, required: bool) -> AgentResult:
return AgentResult(
run_id="run-transfer-1",
result=CoreResult(text="请补充一项信息。", clarification_required=required),
)
@pytest.mark.asyncio
async def test_customer_service_clarification_round_advances_and_resets() -> None:
session = ClarificationSession(queued_run(), clarification_round=1)
await AgentPersistenceService(session).complete_run(
"run-transfer-1", clarification_result(required=True), memory_extraction_requested=False
)
assert session.row.clarification_round == 2
resolved_session = ClarificationSession(queued_run(), clarification_round=2)
await AgentPersistenceService(resolved_session).complete_run(
"run-transfer-1", clarification_result(required=False), memory_extraction_requested=False
)
assert resolved_session.row.clarification_round == 0
@pytest.mark.asyncio
async def test_normal_result_does_not_create_handover_ticket_or_event() -> None:
"""非转人工回答不得污染管理员待处理队列。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=False), memory_extraction_requested=False
)
assert added_of(session.added, HandoverTicket) == []
assert all(
event.event_type != "conversation.transfer_requested"
for event in added_of(session.added, DomainEventOutbox)
)
@pytest.mark.asyncio
async def test_profile_candidate_event_is_written_without_modifying_profile() -> None:
"""画像候选只写 Outbox,正式画像更新留给后续确认/审核服务。"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1", result(transfer_required=False),
memory_extraction_requested=False, profile_candidate_requested=True,
)
events = added_of(session.added, DomainEventOutbox)
candidate = next(
event for event in events if event.event_type == "customer_profile.candidate_requested"
)
assert candidate.payload["customer_id"] == 7
assert candidate.payload["actor_type"] == "authenticated_customer"
assert added_of(session.added, HandoverTicket) == []
@pytest.mark.asyncio
async def test_ticket_priority_follows_reason_code() -> None:
"""`E-01` ②:工单优先级必须来自原因码映射,且与安全路由层一致。"""
expected = {
"safety_risk": "P0",
"account_data": "P1",
"write_or_dispute": "P2",
"explicit_request": "P2",
}
for reason, priority in expected.items():
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1",
result(transfer_required=True, transfer_reason=reason),
memory_extraction_requested=False,
)
ticket = added_of(session.added, HandoverTicket)[0]
assert ticket.reason_code == reason
assert ticket.priority == priority
@pytest.mark.asyncio
async def test_non_enum_reason_is_normalised_but_ticket_is_still_created() -> None:
"""`E-01` ③:非枚举原因码收敛到枚举;但**不丢单** —— 已经对客户说过转接。
丢单等于把客户半路扔下,比原因码不够精确严重得多;因此这里断言「收敛 + 仍然建单」。
"""
session = FakeSession(queued_run())
await AgentPersistenceService(session).complete_run(
"run-transfer-1",
result(transfer_required=True, transfer_reason="user_requested"),
memory_extraction_requested=False,
)
tickets = added_of(session.added, HandoverTicket)
assert len(tickets) == 1
assert tickets[0].reason_code == "explicit_request"
assert tickets[0].priority == "P2"
@pytest.mark.asyncio
async def test_suitability_disclosure_is_written_to_the_audit_table() -> None:
"""`E-04` 留痕:适当性裁决必须同时落**消息表**与**审计表**(零 DDL)。"""
session = FakeSession(queued_run())
record = {
"product": "南方季季盈90天",
"product_risk_level": 4,
"customer_risk_level": 3,
"allowed": True,
"reason_code": "SUITABLE_WITH_DISCLOSURE",
"required_disclosure": True,
"requires_recording": False,
"disclosure_first": True,
"customer_confirmed": False,
}
payload = result(transfer_required=False)
payload = payload.model_copy(update={
"result": payload.result.model_copy(update={"data": {"suitability": record}}),
})
await AgentPersistenceService(session).complete_run(
"run-transfer-1", payload, memory_extraction_requested=False
)
audits = added_of(session.added, InteractionAudit)
disclosed = [a for a in audits if a.action_type == "agent.suitability_disclosed"]
assert len(disclosed) == 1
detail = disclosed[0].detail
assert detail["product"] == "南方季季盈90天"
assert detail["disclosure_first"] is True
# 红线 2:客服永不代替客户确认
assert detail["customer_confirmed"] is False
# 消息表侧同一份留痕(结构化列,不改表结构)
messages = added_of(session.added, ConversationMessage)
assert messages[-1].tool_calls["data"]["suitability"]["reason_code"] == (
"SUITABLE_WITH_DISCLOSURE"
)