一、客服 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 生成的本地产物
346 lines
13 KiB
Python
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"
|
|
)
|