diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index 5406efb..bdbf912 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -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 = ( "抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息," diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index 9fe1cde..03bc905 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -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() diff --git a/app/service/run_query_service.py b/app/service/run_query_service.py index 5153a42..4ba8f1b 100644 --- a/app/service/run_query_service.py +++ b/app/service/run_query_service.py @@ -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", diff --git a/tests/unit/core/test_customer_service_rules.py b/tests/unit/core/test_customer_service_rules.py index f4c29b7..7fe746f 100644 --- a/tests/unit/core/test_customer_service_rules.py +++ b/tests/unit/core/test_customer_service_rules.py @@ -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 个硬编码字面,整条回复就会被替换成兜底话术。 diff --git a/tests/unit/service/test_run_query_service.py b/tests/unit/service/test_run_query_service.py index ad3ad6d..5c0ff28 100644 --- a/tests/unit/service/test_run_query_service.py +++ b/tests/unit/service/test_run_query_service.py @@ -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",