Merge origin/NL_develop:客服画像出口、知识管理三端点、合规语境与知识向量链路

NL 线(含其并入的袁聪场外/推广域)。唯一冲突是 .gitignore —— 双方都往同一区域加了
.workdir/,取对方版本(他的更完整,含 .tmp/ 与说明),顺带修掉我之前用
Add-Content -Encoding utf8 造成的编码混合(read 工具当时报 invalid UTF-8)。

合并后修的问题 —— 都不是"改别人业务逻辑",是让门禁能绿:

1. 缺运行依赖 python-docx。document_parser.py 解析 .docx 用它,但 requirements.txt 与
   pyproject.toml 都没声明 —— 别人环境跑知识入库会直接
   ModuleNotFoundError: No module named 'docx'。已补声明。
2. ruff 7 项:其中 tests/conftest.py 的 F821 Undefined name 'Path'(他的 tmp_path 修复
   写了字符串注解 "Path" 却漏 import,运行时不求值所以没炸,但 mypy/ruff 会抓)、
   tools/publish_customer_service_config.py 的 F841 inherited_keys 死变量(他改同 key
   覆盖、换成 inherited_only 后忘删旧的)、3 处 E501,另 2 项 ruff --fix 自动修复。
3. 合规基线种子未跑:integration 的 test_compliance_seed_mysql 4 个用例要求
   agent_negative_word 有 7 条 active 且已复核、agent_reply_template 覆盖 6 场景。
   跑 tools/seed_compliance_baseline.py(11 条 active 规则 / 6 个场景模板)后 80 passed。

验证:ruff 干净 / mypy 180 文件 0 错 / unit+contract 1140 passed /
integration 80 passed / 表数 68(alembic 已在 20260911_merge_risk_heads)。

唯一失败 tests/unit/repository/test_fund_readonly_contract.py 是双方一致的既有缺陷:
它断言 Base.metadata 里的 fin_* 表集合,而实测为空集 —— 即该测试依赖别的测试先导入模型的
副作用,单独跑必失败。NL 方也明确"不修不报",此处照办,仅记录。
This commit is contained in:
2026-09-11 20:22:59 +08:00
99 changed files with 15778 additions and 461 deletions
+218
View File
@@ -0,0 +1,218 @@
"""知识集合的**运行时字段探测**:把"逻辑字段名"解析成该集合真实的物理字段名。
## 为什么需要它
同一批集合名(`fin_faq_collection` 等)在不同环境里可能是**两套完全不同的 schema**,
这是实测确认的事实(2026-09-11,两个开发环境各自 `describe_collection`):
| 逻辑字段 | 环境甲(本机) | 环境乙(架构师机) |
|---|---|---|
| 文档标识 | `knowledge_id` | `doc_id` |
| 正文 | `snippet` | `content` |
| 可见性 | **无** | `visibility` |
| 来源文件 | **无** | `source_file` |
| 章节 | **无** | `chapter` / `section` / `doc_no` |
| 行数 | 106 / 177 / 73 | 125 / 297 / 214 |
**硬编码任何一套都会打挂另一套**:Milvus 对不存在的字段直接报错
(`field doc_id not exist`)→ 三个集合全失败 → `degraded=True` → 客服一律"引导人工"。
把字段名换成探测之后,两套 schema 都能跑,**没有需要改回去的东西**,也不需要迁移或重灌。
## 设计要点
1. **只探测一次**:`detect_schema()` 带缓存,`describe_collection` 是纯元数据调用、不查数据;
探测失败不抛异常,返回"该集合不可用",由调用方走降级路径(与检索服务一贯口径一致)。
2. **缺失字段不报错、只记录**:`chapter`/`section`/`visibility` 缺失时,依赖它们的增强逻辑
(父子块选择、内部资料过滤)自然退化为"不启用",而不是让整条检索失败。
3. **关键字段缺失才失败**:既没有文档标识字段、又没有正文字段的集合,**必须明确报出来**
(`missing_required`)——那种集合检索不出任何有意义的结果,静默零召回比报错更难定位。
4. **不 import 检索服务**:本模块只依赖 `collections.abc`/`dataclasses`/`typing`,
避免与 `knowledge_search_service` 形成循环依赖。
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
#: 逻辑字段名 → 该字段在各环境里**可能**的物理名(按优先级排列)。
#:
#: 顺序有意义:第一个命中的即采用。例:某环境同时有 `doc_id` 与 `knowledge_id` 时优先 `doc_id`
#: (那是灌库脚本的正式设计名),保持与架构师那套一致的语义。
FIELD_CANDIDATES: Mapping[str, tuple[str, ...]] = {
"doc_id": ("doc_id", "knowledge_id"),
"content": ("content", "snippet"),
"title": ("title",),
"tags": ("tags",),
"version": ("version",),
"intent": ("intent",),
# 以下为**可选**增强字段:缺了就退化为"不启用",不影响检索可用性
"visibility": ("visibility",),
"source_file": ("source_file",),
"chapter": ("chapter",),
"section": ("section",),
"doc_no": ("doc_no",),
}
#: 缺了就无法检索的字段(既无标识、又无正文的集合没有可用结果)
REQUIRED_LOGICAL_FIELDS: tuple[str, ...] = ("doc_id", "content")
class SchemaProbe(Protocol):
"""只依赖 `describe_collection`(pymilvus 的 `MilvusClient`/`AsyncMilvusClient` 都有)。"""
def describe_collection(self, **kwargs: Any) -> Any: ...
@dataclass(frozen=True)
class CollectionSchema:
"""一个集合的字段解析结果。
`fields` 是「逻辑名 → 物理名」的映射,**只含实际存在的字段**。
`missing_optional` 记录缺失的可选增强字段(用于日志与诊断,不影响检索)。
`missing_required` 非空表示这个集合不可用(调用方应记 `degraded`)。
"""
collection: str
fields: Mapping[str, str]
physical_names: frozenset[str]
missing_optional: tuple[str, ...] = ()
missing_required: tuple[str, ...] = ()
error: str | None = None
#: 是否具备检索的最低字段条件
@property
def usable(self) -> bool:
return not self.missing_required and self.error is None
def resolve(self, logical: str) -> str | None:
"""逻辑名 → 物理名;该集合没有这个字段时返回 None(调用方据此跳过)。"""
return self.fields.get(logical)
@property
def output_fields(self) -> tuple[str, ...]:
"""本次检索应从 Milvus 取回的物理字段(只取存在的,避免"字段不存在"报错)。"""
return tuple(self.fields.values())
def has(self, logical: str) -> bool:
return logical in self.fields
def _physical_names(description: Any) -> frozenset[str]:
"""从 `describe_collection` 的返回里取出字段名集合。
pymilvus 不同版本返回形状略有差异(`{"fields": [{"name": ...}]}` 或带 `schema` 包装),
这里做兼容解析;解析不出任何字段名时返回空集合 → 调用方会判定为不可用。
"""
if not isinstance(description, Mapping):
return frozenset()
fields = description.get("fields")
if fields is None:
schema = description.get("schema")
fields = schema.get("fields") if isinstance(schema, Mapping) else None
if not isinstance(fields, Sequence):
return frozenset()
names: list[str] = []
for item in fields:
if isinstance(item, Mapping):
name = item.get("name")
if isinstance(name, str) and name:
names.append(name)
return frozenset(names)
def resolve_schema(collection: str, description: Any) -> CollectionSchema:
"""把一次 `describe_collection` 结果解析成 `CollectionSchema`(纯函数,不抛异常)。
单独抽出来是为了让单测能直接喂两套 schema 的替身描述,不必连 Milvus。
"""
names = _physical_names(description)
if not names:
return CollectionSchema(
collection=collection, fields={}, physical_names=frozenset(),
missing_required=REQUIRED_LOGICAL_FIELDS,
error="describe_collection 未返回字段信息",
)
resolved: dict[str, str] = {}
missing_optional: list[str] = []
missing_required: list[str] = []
for logical, candidates in FIELD_CANDIDATES.items():
found = next((c for c in candidates if c in names), None)
if found is not None:
resolved[logical] = found
continue
if logical in REQUIRED_LOGICAL_FIELDS:
missing_required.append(logical)
else:
missing_optional.append(logical)
return CollectionSchema(
collection=collection,
fields=resolved,
physical_names=names,
missing_optional=tuple(missing_optional),
missing_required=tuple(missing_required),
)
class SchemaCache:
"""按集合名缓存探测结果(进程内,一次探测)。
缓存的是**元数据**:集合重建(换 schema)后需要重启进程或调用 `invalidate()`。
这是刻意的取舍——检索是热路径,不能每次调用都打一次 describe_collection。
"""
def __init__(self) -> None:
self._cache: dict[str, CollectionSchema] = {}
def get(self, collection: str) -> CollectionSchema | None:
return self._cache.get(collection)
def put(self, schema: CollectionSchema) -> None:
self._cache[schema.collection] = schema
def invalidate(self, collection: str | None = None) -> None:
if collection is None:
self._cache.clear()
else:
self._cache.pop(collection, None)
def detect_schema(
client: Any, collection: str, *, cache: SchemaCache | None = None
) -> CollectionSchema:
"""探测一个集合的字段映射。**不抛异常**:任何失败都转成不可用的 `CollectionSchema`。
`client` 需要提供 `describe_collection(collection_name=...)`(同步或异步都可——
这里只调用同步形式;pymilvus 的 `MilvusClient` 是同步的,检索服务用的就是它)。
"""
if cache is not None:
cached = cache.get(collection)
if cached is not None:
return cached
probe = getattr(client, "describe_collection", None)
if probe is None:
schema = CollectionSchema(
collection=collection, fields={}, physical_names=frozenset(),
missing_required=REQUIRED_LOGICAL_FIELDS,
error="客户端不支持 describe_collection",
)
if cache is not None:
cache.put(schema)
return schema
try:
description = probe(collection_name=collection)
except Exception as exc: # 探测失败=该集合不可用,由调用方降级
schema = CollectionSchema(
collection=collection, fields={}, physical_names=frozenset(),
missing_required=REQUIRED_LOGICAL_FIELDS, error=f"{type(exc).__name__}: {exc}",
)
if cache is not None:
cache.put(schema)
return schema
schema = resolve_schema(collection, description)
if cache is not None:
cache.put(schema)
return schema