Files
group_fqcd_jr/app/core/contracts.py
张胜宇 3e24033f72 feat(W27): L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单 + 免责声明分档(设计与代码同轮完成)
设计与依据:新增 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)仍沿用
2026-09-21 22:32:39 +08:00

194 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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