Files
group_fqcd_jr/app/core/knowledge_contracts.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

134 lines
6.1 KiB
Python
Raw 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.
"""知识检索公共契约。字段稳定,不暴露 Milvus/pymilvus 概念。
本模块同时承载**两条知识链路**的契约,它们共用同一批 Milvus 集合:
- 客服 Agent 的检索出口(`search_knowledge` 工具 → `knowledge_search_service`)用
`KnowledgeSearchInput`:调用方可按名字**收窄到单个集合**。
- 入库 / 向量同步 / 管理面(`knowledge_ingest_service`、`knowledge_vector_worker`、
`knowledge_management_service`、`milvus_adapter`)用下面那组常量和 `KnowledgeQuery`:
集合由**意图**映射,调用方不得直接指定集合名。
两者不是重复实现:前者面向"已发布的问答素材",后者面向"知识生命周期管理"。合并时曾
误删下面那组常量,导致 23 个测试模块收集失败——**删任何一半前先看两份引用点**。
"""
from pydantic import BaseModel, ConfigDict, Field, field_validator
#: 可见性档位取值域与档位推导**全部来自 `app.core.knowledge_tier`**(`G-03`)。
#:
#: 这里保留同名再导出,是为了不改动既有导入点(`knowledge_schema` 的测试、
#: `tools/seed_knowledge_r1r5_faq.py` 等按契约模块导入的调用方);**规则正文在
#: `knowledge_tier.py`**,本文件不再承载推导逻辑。新代码请直接从 `knowledge_tier` 导入。
#:
#: 必须写成 `X as X` 的**显式再导出**,且 ruff 的 isort 规则(`combine-as-imports`
#: 默认关闭)要求**每个别名各占一行**:mypy 严格模式(`implicit_reexport = False`)
#: 与 ruff 的 `F401` 都只认这种形式,写成普通 `import X` 会被判成「导入未使用」,
#: 于是再导出的名字在类型层消失(`knowledge_search_service` 会直接报 attr-defined)。
#: 看起来啰嗦,但这是「再导出」在严格 mypy + ruff 下唯一的合规写法。
from app.core.knowledge_tier import ALL_TIERS as ALL_TIERS
from app.core.knowledge_tier import DEFAULT_TIERS as DEFAULT_TIERS
from app.core.knowledge_tier import PUBLIC_TIER as PUBLIC_TIER
from app.core.knowledge_tier import REGISTERED_TIER as REGISTERED_TIER
from app.core.knowledge_tier import TIERS_BY_SUBJECT as TIERS_BY_SUBJECT
from app.core.knowledge_tier import tiers_for_roles as tiers_for_roles
from app.core.knowledge_tier import visibility_expression as visibility_expression
#: 只有这四个集合允许被检索;调用方不得指定任意集合名。
#:
#: 第 4 个 `fin_basic_collection` 承载「金融行业基础信息」(行业通用概念、术语、
#: T+N 时限、费用构成、风险等级含义),是 `DEC-08` / `乙-7` 的落点。
#: 它**不放进** FAQ 集合:那会污染 FAQ 的档位分布与 `family_id` 语义。
ALLOWED_COLLECTIONS = frozenset({
"fin_basic_collection",
"fin_faq_collection",
"fin_product_collection",
"fin_policy_collection",
})
# 兼容知识生命周期服务的历史命名;两者必须始终指向同一白名单。
ALLOWED_KNOWLEDGE_COLLECTIONS = ALLOWED_COLLECTIONS
#: text-embedding-v3 输出维度。维度不符必须失败关闭。
VECTOR_DIM = 1024
#: QA 编号前缀 → 业务意图。源文件共 105 条、11 种前缀,按语义归类;
#: 只有 fin_faq_collection 一个集合时集合名无法区分 faq 与 chitchat,故需前缀映射。
#: 放在契约层是为了让 Service、Worker 与 tools 脚本共同复用(tools 不应被应用层反向依赖)。
INTENT_BY_QA_PREFIX: dict[str, str] = {
"RAG-PER": "chitchat",
"RAG-CHAT": "chitchat",
"RAG-HUM": "transfer_human",
}
def intent_for_qa_id(qa_id: str) -> str | None:
"""按 QA 编号前缀推断业务意图;未列入前缀表时返回 None,由调用方按集合名推断。"""
prefix = "-".join(qa_id.split("-")[:2])
return INTENT_BY_QA_PREFIX.get(prefix)
class KnowledgeSearchInput(BaseModel):
"""知识库检索入参(客服 Agent 的 `search_knowledge` 工具)。
`collection` 留空表示三个集合全查(客服默认行为);指定单个集合用于意图明确时收窄范围。
放在 `app/core` 而不是 service 里:工具的 `input_model` 会被 ToolExecutor 用于参数校验,
属于跨层契约;放在 service 模块会让 API 层与工具注册处都反向依赖 service 实现。
"""
model_config = ConfigDict(extra="forbid")
query: str = Field(min_length=1, max_length=500)
collection: str = Field(default="", max_length=64)
top_k: int = Field(default=5, ge=1, le=10)
class KnowledgeQuery(BaseModel):
"""工具入参。集合由意图映射,调用方不得直接指定集合名。"""
model_config = ConfigDict(extra="forbid", frozen=True)
query: str = Field(min_length=1, max_length=2000)
intents: tuple[str, ...] = Field(min_length=1, max_length=4)
top_k: int = Field(default=5, ge=1, le=20)
@field_validator("query")
@classmethod
def query_must_not_be_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("query must not be blank")
return value
class KnowledgeHit(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
knowledge_id: str
collection: str
title: str | None = None
snippet: str
# MySQL 权威过滤后附加的完整答案;Milvus 原始命中可不携带该字段。
answer: str | None = None
score: float | None = Field(default=None, ge=0, le=1)
tags: tuple[str, ...] = ()
version: str | None = None
#: 该条知识的业务意图标签(导入时按 QA 编号前缀写入)。
#: 仅 fin_faq_collection 一个集合同时装多种意图,靠集合名无法区分
#: "faq" 与 "chitchat",因此需要这一层显式标签。
intent: str | None = None
@field_validator("collection")
@classmethod
def collection_must_be_allowlisted(cls, value: str) -> str:
if value not in ALLOWED_COLLECTIONS:
raise ValueError(f"知识集合不在白名单内:{value}")
return value
class KnowledgeSearchResult(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
hits: tuple[KnowledgeHit, ...] = ()
degraded: bool = False
degradation_reason: str | None = None
searched_collections: tuple[str, ...] = ()