Files
group_fqcd_jr/app/core/knowledge_contracts.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26: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, ...] = ()