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:
@@ -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
|
||||
Reference in New Issue
Block a user