Files
group_fqcd_jr/app/service/run_query_service.py
qyqy a7e2d3eac0 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 错。
2026-09-11 21:17:47 +08:00

73 lines
3.4 KiB
Python

import asyncio
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import RunNotFoundError
from app.infrastructure.db import SessionFactory
from app.repository.conversation_repository import ConversationRepository
@dataclass(frozen=True)
class RunSnapshot:
run_id: str
trace_id: str
status: str
agent_type: str
session_id: str
result: dict[str, Any] | None
error_code: str | None
created_at: str
completed_at: str | None
class RunQueryService:
async def get(self, run_id: str, context: RequestContext) -> RunSnapshot:
async with SessionFactory() as session:
rows = await ConversationRepository(session).run_result(run_id, int(context.user_id))
if rows is None:
raise RunNotFoundError("运行不存在或不可见")
run, message = rows
result = None
if run.status == "succeeded" and message is not None:
# 「转人工标记」从 `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 [],
# `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",
run.completed_at.isoformat() + "Z" if run.completed_at else None,
)
async def watch(
self, initial: RunSnapshot, context: RequestContext
) -> AsyncIterator[RunSnapshot | None]:
settings = get_settings()
loop = asyncio.get_running_loop()
deadline = loop.time() + settings.sse_max_connection_seconds
snapshot = initial
while True:
yield snapshot
if snapshot.status in {"succeeded", "failed", "cancelled"} or loop.time() >= deadline:
return
yield None
await asyncio.sleep(max(0, min(settings.sse_heartbeat_seconds, deadline - loop.time())))
snapshot = await self.get(initial.run_id, context)