设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。
## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
+ NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)
## 改动(重点)
- customer_service.py:
· _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
· drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
(旧黑名单会误删权重:A-06 业绩基准公式整行消失)
· allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
(该 422 是**子集**校验而非数量上限,根因即此项漏配)
· 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
+ is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)
## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活
## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)
## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Any, Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
|
||
class AgentRequestMetadata(BaseModel):
|
||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||
|
||
locale: str | None = None
|
||
client_version: str | None = None
|
||
ui_entry: str | None = None
|
||
# 仅由受理服务写入 Outbox,客户端 API 不接收该字段。
|
||
chitchat_streak: int = Field(default=0, ge=0, le=5)
|
||
# 客服澄清轮次只来自服务端会话行,客户端不得提交或覆盖。
|
||
clarification_round: int = Field(default=0, ge=0, le=2)
|
||
# 仅供客服在当前短期会话内消解指代的已脱敏上下文,不是长期记忆或客户画像。
|
||
session_context: tuple[str, ...] = Field(default_factory=tuple, max_length=6)
|
||
|
||
|
||
class ConversationTurn(BaseModel):
|
||
"""会话中的一轮对话(短期记忆)。
|
||
|
||
只保留角色与正文:模型用它解析指代("那它风险高吗"里的"它"指哪只基金),
|
||
不需要意图、置信度这类内部字段——把内部字段一并喂给模型既增加噪声,
|
||
也扩大了"模型看到不该看的东西"的面。
|
||
|
||
`subject` 是 `E-05` 的**显式主题声明**:由产生这轮回答的出口声明(产品 /
|
||
类目 / 条款名),读侧直接取用,不再从回答正文反解。默认为空串——存量消息
|
||
(本次改动之前落库的行)没有该字段,读侧会回落到原来的文本反解,因此
|
||
老数据与既有用例的行为一字不变。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
role: Literal["user", "assistant"]
|
||
content: str
|
||
subject: str = ""
|
||
|
||
|
||
class AgentRequest(BaseModel):
|
||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||
|
||
agent_type: str
|
||
message: str
|
||
session_id: str
|
||
idempotency_key: str
|
||
metadata: AgentRequestMetadata = Field(default_factory=AgentRequestMetadata)
|
||
# 本会话中**本次之前**的对话,按时间正序(旧 → 新)。
|
||
# 默认空元组:既有构造点(API 受理路径、单测、验收脚本)无需改动即可继续工作。
|
||
history: tuple[ConversationTurn, ...] = ()
|
||
|
||
@field_validator("message")
|
||
@classmethod
|
||
def message_must_not_be_blank(cls, value: str) -> str:
|
||
if not value.strip():
|
||
raise ValueError("message must not be blank")
|
||
return value
|
||
|
||
@field_validator("idempotency_key")
|
||
@classmethod
|
||
def idempotency_key_must_be_valid(cls, value: str) -> str:
|
||
if not 16 <= len(value) <= 128 or not value.replace("-", "").replace("_", "").isalnum():
|
||
raise ValueError("idempotency_key must be 16-128 alphanumeric characters")
|
||
return value
|
||
|
||
|
||
class RequestContext(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
user_id: str
|
||
trace_id: str
|
||
roles: tuple[str, ...] = ()
|
||
customer_ids: tuple[str, ...] = ()
|
||
data_scope: str = "self"
|
||
portal: str = "api"
|
||
clarification_round: int = Field(default=0, ge=0, le=10)
|
||
permissions: tuple[str, ...] = ()
|
||
permission_scopes: dict[str, str] = Field(default_factory=dict)
|
||
|
||
|
||
class AgentDefinition(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
agent_type: str
|
||
version: str
|
||
allowed_tools: tuple[str, ...] = ()
|
||
allowed_roles: tuple[str, ...] = ()
|
||
allowed_portals: tuple[str, ...] = ()
|
||
supported_intents: tuple[str, ...] = ("general",)
|
||
requires_model_intent_classification: bool = True
|
||
# 长期/画像记忆属于客户数据能力;默认保留既有 Agent 行为,客服需显式关闭。
|
||
recalls_customer_memory: bool = True
|
||
|
||
|
||
class ResolvedAgentConfig(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
config_version: str
|
||
prompt_version: str
|
||
model_endpoint: str
|
||
allowed_tools: tuple[str, ...] = ()
|
||
timeout_seconds: int = Field(default=60, gt=0)
|
||
release_id: int | None = None
|
||
allowed_tools_by_intent: dict[str, tuple[str, ...]] = Field(default_factory=dict)
|
||
negative_rules: tuple[tuple[str, str], ...] = ()
|
||
|
||
|
||
class RecalledMemory(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
memory_uuid: str
|
||
customer_id: str
|
||
content: str
|
||
|
||
|
||
class SourceReference(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
source_type: Literal["knowledge", "memory", "relationship", "tool"]
|
||
source_id: str
|
||
title: str | None = None
|
||
score: float | None = Field(default=None, ge=0, le=1)
|
||
|
||
|
||
class ToolCallRecord(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
tool_name: str
|
||
status: Literal["succeeded", "failed", "denied"]
|
||
input_summary: dict[str, Any] = Field(default_factory=dict)
|
||
output_summary: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class IntentResult(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
intent: str
|
||
confidence: float = Field(ge=0, le=1)
|
||
needs_clarification: bool = False
|
||
|
||
|
||
class CoreResult(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
text: str
|
||
# 业务 Agent 可返回结构化结果;默认为空,兼容只返回文本的其他 Agent。
|
||
data: dict[str, Any] = Field(default_factory=dict)
|
||
sql: str | None = None
|
||
intent: IntentResult | None = None
|
||
source_references: tuple[SourceReference, ...] = ()
|
||
tool_calls: tuple[ToolCallRecord, ...] = ()
|
||
# 请求澄清时由持久化层安全递增会话轮次;达到上限后必须改为人工转接。
|
||
clarification_required: bool = False
|
||
transfer_required: bool = False
|
||
transfer_reason: str | None = None
|
||
# `E-05`:本轮答复在说哪个主语(出口声明,不是读侧反解)。
|
||
# ✅ `W27`:本轮命中的**出口码**(`E0`—`E6` / `P0`—`P2` / `LOGIN` / `CONTACT` / `CHAT`,
|
||
# 定义见 `app/core/exit_codes.py`)。**声明式**字段:由出口自己填,读侧不反解文本。
|
||
# 唯一消费方是门禁 `F5` 的**免责声明分档**(`DEC-W27-10`):
|
||
# 业务出口附完整投资免责话术,非业务出口(闲聊 / 联系方式 / 引导登录 / 澄清)只附轻量提示。
|
||
# 为什么用出口码而不用“文本里有没有数字”这类间接判据:后者会把 `E5b` 空答误判成非业务,
|
||
# 从而静默掉档(客户拿不到本应拿到的投资风险提示)。
|
||
exit_code: str | None = None
|
||
# 落库进消息的 `tool_calls` JSON 列,下一轮作为 `ConversationTurn.subject` 读回。
|
||
topic: str | None = None
|
||
|
||
|
||
class AgentResult(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
run_id: str
|
||
result: CoreResult
|
||
usage: dict[str, int] = Field(default_factory=dict)
|
||
|
||
|
||
class RunProgressEvent(BaseModel):
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
event_type: Literal["start", "tools", "delta", "replace", "done", "error"]
|
||
run_id: str
|
||
payload: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DomainEvent:
|
||
event_id: str
|
||
event_type: str
|
||
aggregate_type: str
|
||
aggregate_id: str
|
||
trace_id: str
|
||
payload: dict[str, Any]
|
||
occurred_at: datetime
|