fix(customer-service): 统一客服热线来源;补 transfer_required 出参(docs/05 §6.3 未兑现的一半)

两个都是"代码里存在但没接对"的缺陷,都不是新功能。

## A1 客服热线在代码里有两个值(一个出口给假号码)

- `app/core/customer_service_rules.py:35` `CONTACT_PHONE = "15936583816"` ← 真号码,安全路由 6 处在用
- `app/service/agent/implementations/customer_service.py:155` `HOTLINE = "400-XXX-XXXX"` ← 占位符,兜底出口在用

后果:**同一个客服给客户两个不同的电话号码**。问"风险等级怎么划分"被安全路由处理时给真号码;
问一个知识库答不了的问题走兜底时给 `400-XXX-XXXX` —— 客户按这个号码永远打不通。

修法:`HOTLINE` / `SERVICE_HOURS` 改为**转发** `customer_service_rules` 的两个常量
(不是"改成相同的值",而是引用同一对象,避免日后再次漂移);工作时间也随之从
"每日 7:00-22:00" 统一为 "工作日 09:00-18:00"(与安全路由出口一致)。
新增守卫测试用 `is` 断言对象同一性 —— 值相等挡不住"两边各写一份恰好相同"的漂移。

## A2 `transfer_required` 既没落库也没出参

`docs/05` §6.3 一直规定 `GET /agent-runs/{run_id}` 的 `result` 里有
`transfer_required` / `transfer_reason`,但实现里两个都没有:前端判断"这轮要不要转人工"
只能靠**猜正文里有没有兜底话术的开头**(`docs/24` 自己把这称为权宜之计)。

- 写入侧:`conversation_message` **没有** `transfer_required` 列,加列要迁移且规则 4 禁止改既有
  字段定义 ⇒ 放进 `tool_calls` 这个现成 JSON 列,作为 `calls` 的兄弟键
  (`{"calls": [...], "transfer_required": bool, "transfer_reason": str|None}`)
- 读取侧:`RunQueryService.get` 取出来放进 `result`;**兼容历史行**(`calls` 裸列表 / None →
  按 False/None 处理,不抛异常、也不凭正文猜)

刻意**没做**的一半:`docs/05` §6.3 的 `result` 里还有 `degraded` / `degradation_reason`,
但 `CoreResult` 里根本没有这两个字段(降级信息目前只在工具出参里)—— 补它要改
`CoreResult` 并让各 Agent 传递降级状态,属另一个改动范围。**已在交付说明里注明这一半仍缺。**

## 真机验证

| 问题 | transfer_required | transfer_reason | 正文电话 |
|---|---|---|---|
「请介绍一下量子纠缠在基金估值中的应用」 | **True** | 置信度不足:score=0.571 gap=0.004 | 15936583816 ✅ |
「请帮我计算一下三体问题的数值解」 | **True** | 置信度不足:score=0.499 gap=0.011 | 15936583816 ✅ |
「基金申购后多久确认」(正常知识直返) | False | — | 无(正确) |
「你们公司明天会下雪吗」(闲聊出口) | False | — | 无(正确) |

(第一次我用"下雪"当兜底用例,结果它被闲聊出口正确接住了 —— 是我的期望值写错,不是代码问题。)

门禁:测试 1223 passed(新增 4 个用例)/ 3 failed(均为已知非代码缺陷)/ mypy 0 错。
This commit is contained in:
qyqy
2026-09-11 21:17:47 +08:00
parent 123c273bc8
commit a7e2d3eac0
5 changed files with 122 additions and 7 deletions
@@ -27,6 +27,7 @@ from app.core.contracts import (
RequestContext,
SourceReference,
)
from app.core.customer_service_rules import CONTACT_HOURS, CONTACT_PHONE
from app.core.errors import ForbiddenAgentError
from app.service.agent.base import BaseAgent
from app.service.model_gateway import DatabaseModelEndpointResolver
@@ -151,9 +152,15 @@ MAX_ANSWER_CHARS = 1200
REFERENCE_LIMIT = 3
COMPANY = "南方科技"
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
HOTLINE = "400-XXX-XXXX"
SERVICE_HOURS = "每日 7:00-22:00"
# 客服热线与工作时间:**唯一来源是 `app/core/customer_service_rules.py`**,这里只做转发。
#
# 为什么必须转发而不是各写一份:这两处曾一度不一致 —— `customer_service_rules.CONTACT_PHONE`
# 是真号码 `15936583816`(安全路由出口在用),而本文件曾写占位符 `400-XXX-XXXX`(兜底出口在用)。
# 后果是**同一个客服给客户两个不同的电话号码**:问"风险等级怎么划分"被安全路由处理时给真号码,
# 问一个知识库答不了的问题走兜底时给假号码 —— 客户按假号码永远打不通。
# 常量各写一份就一定会漂移,所以这里直接引用,改号码只需改 `customer_service_rules` 一处。
HOTLINE = CONTACT_PHONE
SERVICE_HOURS = CONTACT_HOURS
FALLBACK_TEMPLATE = (
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
+15 -2
View File
@@ -67,8 +67,21 @@ class AgentPersistenceService:
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]},
# `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`。
tool_calls={
"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,
},
)
self.session.add(message)
await self.session.flush()
+16 -2
View File
@@ -32,10 +32,24 @@ class RunQueryService:
run, message = rows
result = None
if run.status == "succeeded" and message is not None:
result = {"content": message.content, "tool_calls": message.tool_calls,
# 「转人工标记」从 `tool_calls` 这个 JSON 列里取(与写入侧同一个位置)。
# 兼容两种历史形状:dict 里带 `transfer_required`(新),或 `calls` 裸列表(旧行)——
# 旧行取不到就按 False 处理,不猜、也不因为缺字段让整个响应失败。
transfer_required = False
transfer_reason = None
stored_calls = message.tool_calls
if isinstance(stored_calls, dict):
transfer_required = bool(stored_calls.get("transfer_required", False))
reason = stored_calls.get("transfer_reason")
transfer_reason = str(reason) if reason else None
result = {"content": message.content, "tool_calls": stored_calls,
"intent": message.intent,
"confidence": str(message.confidence) if message.confidence else None,
"source_references": message.source_references or []}
"source_references": message.source_references or [],
# `docs/05` §6.3 规定 `result` 必须含这两个字段,此前未兑现。
# 前端据此判断"这轮要不要转人工",不必再去猜兜底话术的开头。
"transfer_required": transfer_required,
"transfer_reason": transfer_reason}
return RunSnapshot(
run.run_id, run.trace_id, run.status, run.agent_type, run.session_id, result,
run.error_code, run.created_at.isoformat() + "Z",
@@ -13,6 +13,7 @@ from app.core import customer_service_rules as rules
from app.core.customer_service_rules import (
COMPLIANCE_PRIORITY,
COMPLIANCE_REPLY,
CONTACT_HOURS,
CONTACT_PHONE,
P0_REPLY,
P1_REPLY,
@@ -23,6 +24,25 @@ from app.core.customer_service_rules import (
route_message,
)
def test_agent_fallback_uses_the_single_source_hotline() -> None:
"""客服 Agent 的兜底话术必须与安全路由用**同一个**电话号码与工作时间。
这条是回归守卫:这两处曾一度不一致 —— 安全路由出口给真号码 `15936583816`,
而 Agent 的兜底出口给占位符 `400-XXX-XXXX`。后果是同一个客服对不同问题
给出**两个不同的客服电话**,客户按占位符那个永远打不通。
断言方式刻意用"对象同一性"(`is`)而不是"值相等":值相等也能通过
"两边各写一份恰好相同的字符串",而那正是漂移的开始 —— 必须是真的同一个来源。
"""
from app.service.agent.implementations import customer_service as agent
assert agent.HOTLINE is CONTACT_PHONE
assert agent.SERVICE_HOURS is CONTACT_HOURS
# 话术里不能再出现任何占位符形态
assert "XXX" not in agent.FALLBACK_TEMPLATE
assert CONTACT_PHONE in agent.FALLBACK_TEMPLATE
#: 治理层 `review_output()` 的拦截口径(`app/service/agent/governance.py`):
#: 输出只要命中任一 `agent_negative_word` 规则(客服 11 条,含 NEG-007「安全」)
#: 或这 5 个硬编码字面,整条回复就会被替换成兜底话术。
@@ -123,6 +123,67 @@ async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch
assert snapshot.error_code == "AGENT_INTERNAL_ERROR"
# --- `docs/05` §6.3 要求的 `transfer_required` / `transfer_reason` 出参 -----------------
async def test_result_exposes_transfer_marker_when_transferred(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""兜底/转人工分支必须把标记**出参**给客户端。
为什么这条重要:前端判断"这轮要不要转人工"此前只能靠**猜正文里有没有兜底话术开头**
(`docs/24` 自称权宜之计)。`docs/05` §6.3 一直规定 `result` 里有这两个字段,
但此前既没落库也没出参 —— 这个用例把"契约已兑现"钉住。
"""
message = FakeMessage(
content="抱歉,这个问题我暂时无法给出准确答复…",
tool_calls={"calls": [], "transfer_required": True,
"transfer_reason": "置信度不足:score=0.571 gap=0.004"},
)
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
snapshot = await RunQueryService().get("run-1", CONTEXT)
assert snapshot.result is not None
assert snapshot.result["transfer_required"] is True
assert snapshot.result["transfer_reason"] == "置信度不足:score=0.571 gap=0.004"
async def test_result_transfer_marker_defaults_to_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""正常回答:标记为 False、原因为 None(不能因为缺键就返回 None 让客户端混淆)。"""
message = FakeMessage(content="交易日 15:00 前提交…",
tool_calls={"calls": [], "transfer_required": False,
"transfer_reason": None})
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
snapshot = await RunQueryService().get("run-1", CONTEXT)
assert snapshot.result is not None
assert snapshot.result["transfer_required"] is False
assert snapshot.result["transfer_reason"] is None
async def test_result_tolerates_legacy_tool_calls_shape(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""**兼容历史行**:本字段上线前落库的 `tool_calls` 里没有这两个键。
那种行的 `tool_calls` 可能就是裸列表,甚至为 None。读取时必须按 False/None 处理,
**不得抛异常、也不得凭正文内容去猜**——猜错方向会让"不需要转人工"的答复被标成转人工。
"""
for legacy in ({"calls": []}, [], None):
message = FakeMessage(content="稳健型", tool_calls=legacy)
patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message))
snapshot = await RunQueryService().get("run-1", CONTEXT)
assert snapshot.result is not None, legacy
assert snapshot.result["transfer_required"] is False, legacy
assert snapshot.result["transfer_reason"] is None, legacy
def terminal_snapshot(status: str = "succeeded") -> RunSnapshot:
return RunSnapshot(
run_id="run-1",