Files
group_fqcd_jr/tools/setup_milvus_knowledge_collections.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

267 lines
12 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 集合(Task 6)。
用法::
.\\.venv\\Scripts\\python.exe tools\\setup_milvus_knowledge_collections.py
安全口径(共享 Milvus 实例,实例里还有别的项目在用的集合):
1. **幂等**:集合已存在则**直接跳过**,不重建、不覆盖、不清数据;
2. **不覆盖不同结构**:同名集合已存在但字段/维度与本脚本定义不同时,**停下来报告**
并以非零退出码结束 —— 宁可人工确认,也不动别人的数据;
3. 只处理 `ALLOWED_COLLECTIONS` 里的四个集合,集合名**不接受外部参数**。
schema 四个集合逐字相同,且**本模块是唯一权威定义**(`H-05` ④「两套建表脚本收敛为一套」):
| 字段 | 类型 / 长度 | 角色 |
|---|---|---|
| `doc_id` | VARCHAR(64) | **主键** |
| `title` / `content` | VARCHAR(1024) / VARCHAR(16384) | 检索与输出 |
| `chapter` / `section` / `tags` | VARCHAR(512) | 定位与分组 |
| `doc_no` / `version` / `effective_date` / `expire_date` | VARCHAR(64/32/32/32) | 溯源与时效 |
| `source_url` / `reviewer` / `source_file` | VARCHAR(512/64/128) | 溯源与合规留痕 |
| `family_id` / `param_class` / `intent` | VARCHAR(64/16/32) | 同族 / 参数类型 / 意图(v1.4) |
| `visibility` | VARCHAR(16) | **分区键(档位隔离)** |
| `embedding` | FLOAT_VECTOR(dim=1024) | 向量 |
索引 `AUTOINDEX` + `metric_type="COSINE"`。**为什么收敛到这一套**:`app/core/knowledge_schema.py`
的 `FIELD_CANDIDATES` 优先取 `doc_id` / `content`(注释即写明"那是灌库脚本的正式设计名"),
`app/infrastructure/milvus_knowledge_writer.py` 的逻辑主键/正文名也是 `doc_id` / `content`,
`knowledge/_chunks.jsonl` 的产物字段同样是这一套 —— 旧脚本里的 `knowledge_id` / `snippet` /
`intent` 属**另一套环境的历史命名**,保留它等于让"同名集合两套字段"继续活着。
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.config import get_settings # noqa: E402
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS, VECTOR_DIM # noqa: E402
PRIMARY_FIELD = "doc_id"
VECTOR_FIELD = "embedding"
INDEX_NAME = "knowledge_autoindex"
#: 档位字段 = **分区键**。集合内按档位物理分桶,检索按该字段过滤时由引擎做分区裁剪,
#: 不可见档位不进候选集 —— 这是"检索层硬隔离",不依赖上层自觉。
#:
#: ⚠️ 两条实测结论(2026-09-18,Milvus v2.5.3):
#: 1. 分区键模式下 **Milvus 禁止手工 `create_partition`**(报
#: `disable create partition if partition key mode is used`)⇒ 新增档位值
#: **不需要任何运维动作**,由引擎按哈希自动路由。设计文档里"档位值变更 = 建分区"
#: 的表述据此修正为"档位值变更 = 无需动作,引擎自动路由"。
#: 2. `upsert` / `insert` / 按分区键过滤的 `search` 在该模式下**均正常**
#: (已用抛废集合实测),所以写入侧(走 `upsert`)不受影响。
PARTITION_KEY_FIELD = "visibility"
#: 分区桶数。**创建后不可改**,所以宁可一次给足:档位枚举预计会增长,16 桶可保证
#: 不同档位值大概率落在不同桶、裁剪真正生效;600+ 行规模下多桶开销可忽略。
NUM_PARTITIONS = 16
#: (字段名, VARCHAR 最大长度);顺序与写入侧口径一致。
#:
#: 这份字段表是**唯一权威**:灌库脚本(`tools/load_knowledge_milvus.py`)从本模块
#: import `VARCHAR_FIELDS` / `FIELD_LIMITS` 做截断,**不再自带第二份定义**(`H-05` ④)。
#: 长度按**实际数据最坏情况**定:`content` 取 16384(实测最长块 2828 字符,留足余量),
#: `title` 取 1024(含完整章节路径)。
VARCHAR_FIELDS: tuple[tuple[str, int], ...] = (
(PRIMARY_FIELD, 64),
("title", 1024),
("content", 16384),
("chapter", 512),
("section", 512),
("tags", 512),
("doc_no", 64),
("version", 32),
("effective_date", 32),
("expire_date", 32),
("source_url", 512),
("reviewer", 64),
("source_file", 128),
# v1.4(2026-09-18):`D2.4` 附录F 的「同族合并 / 计算型参数位 / 意图标签」三条能力
# 需要它们。`family_id` 64 足够(父块编号最长 `POL-AST-012` 这类);`param_class`
# 是固定枚举 `none/rate/threshold/scale/count`;`intent` 是五类业务意图。
("family_id", 64),
("param_class", 16),
("intent", 32),
(PARTITION_KEY_FIELD, 16),
)
#: 字段名 → 最大长度。写入侧据此截断,**与集合定义同源**,避免"脚本截到 500、
#: 集合只给 256"这类只在写入时才暴露的错配。
FIELD_LIMITS: dict[str, int] = dict(VARCHAR_FIELDS)
#: 允许缺省的字段。**空集 = 全部必填**:VARCHAR 由写入侧补空串
#: (`MilvusKnowledgeWriter` 的既有行为),分区键字段则要求**显式声明**——
#: `visibility` 缺省不是"写空串"而是"不计入任何档位",属档位越权风险,
#: 必须在入库门禁(`B-01`)就挡住,不能靠集合默认值兜底。
NULLABLE_FIELDS: frozenset[str] = frozenset()
def expected_fields() -> dict[str, dict[str, Any]]:
"""本脚本期望的字段结构:`{字段名: {type, is_primary, is_partition_key, max_length?, dim?}}`。"""
fields: dict[str, dict[str, Any]] = {}
for name, max_length in VARCHAR_FIELDS:
fields[name] = {
"type": "VARCHAR",
"max_length": max_length,
"is_primary": name == PRIMARY_FIELD,
"is_partition_key": name == PARTITION_KEY_FIELD,
}
fields[VECTOR_FIELD] = {
"type": "FLOAT_VECTOR",
"dim": VECTOR_DIM,
"is_primary": False,
"is_partition_key": False,
}
return fields
def describe_mismatch(described: Any) -> list[str]:
"""比较 Milvus `describe_collection` 结果与期望 schema,返回差异说明(一致时为空)。"""
if not isinstance(described, dict): # pragma: no cover - 防御:返回结构异常
return [f"无法解析集合描述:{type(described).__name__}"]
raw_fields = described.get("fields")
if not isinstance(raw_fields, list): # pragma: no cover - 防御:返回结构异常
return ["集合描述缺少 fields"]
actual: dict[str, dict[str, Any]] = {}
for raw in raw_fields:
if not isinstance(raw, dict):
continue
name = str(raw.get("name", ""))
params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
actual[name] = {
"type": raw.get("type"),
"params": params,
"is_primary": bool(raw.get("is_primary")),
"is_partition_key": bool(raw.get("is_partition_key")),
}
problems: list[str] = []
# 分区桶数也是结构的一部分,而且**创建后不可改** —— 不一致必须报出来,
# 否则"看起来建好了"的集合其实没有按预期分桶、分区裁剪名存实亡。
actual_partitions = described.get("num_partitions")
if isinstance(actual_partitions, int) and actual_partitions != NUM_PARTITIONS:
problems.append(
f"num_partitions 不一致(实际 {actual_partitions},期望 {NUM_PARTITIONS})"
)
for name, spec in expected_fields().items():
found = actual.get(name)
if found is None:
problems.append(f"缺少字段 {name}")
continue
if found["is_primary"] != spec["is_primary"]:
problems.append(f"字段 {name} 主键标记不一致(实际 {found['is_primary']})")
if found["is_partition_key"] != spec["is_partition_key"]:
problems.append(
f"字段 {name} 分区键标记不一致"
f"(实际 {found['is_partition_key']},期望 {spec['is_partition_key']})"
)
if name == VECTOR_FIELD:
dim = found["params"].get("dim")
if not isinstance(dim, int) or dim != VECTOR_DIM:
problems.append(f"字段 {name} 维度不一致(实际 {dim},期望 {VECTOR_DIM})")
continue
length = found["params"].get("max_length")
if length != spec["max_length"]:
problems.append(f"字段 {name} 长度不一致(实际 {length},期望 {spec['max_length']})")
return problems
def build_schema() -> Any:
"""构造四个集合的统一 schema。**公开**:灌库脚本直接 import 本函数,
以保证"建表的字段定义"与"写库的字段填充"是同一份声明(`H-05` ④)。
"""
from pymilvus import DataType, MilvusClient # type: ignore[import-untyped]
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
for name, max_length in VARCHAR_FIELDS:
schema.add_field(
field_name=name,
datatype=DataType.VARCHAR,
max_length=max_length,
is_primary=(name == PRIMARY_FIELD),
# 档位字段是**分区键**(每集合只能有一个,且不可空)。
is_partition_key=(name == PARTITION_KEY_FIELD),
# 只有被显式列为可缺省的字段允许为空;分区键字段永远不可空。
nullable=name in NULLABLE_FIELDS,
)
schema.add_field(field_name=VECTOR_FIELD, datatype=DataType.FLOAT_VECTOR, dim=VECTOR_DIM)
return schema
def build_index_params() -> Any:
"""构造向量索引参数(`AUTOINDEX` + `COSINE`)。**公开**,理由同 `build_schema`。"""
from pymilvus import MilvusClient # type: ignore[import-untyped]
index_params = MilvusClient.prepare_index_params()
index_params.add_index(
field_name=VECTOR_FIELD,
index_name=INDEX_NAME,
index_type="AUTOINDEX",
metric_type="COSINE",
)
return index_params
async def ensure_collections(uri: str, token: str = "") -> tuple[list[str], list[str], list[str]]:
"""确保四个集合存在。返回 `(创建, 已存在, 结构冲突)`;结构冲突时不覆盖。"""
from pymilvus import AsyncMilvusClient # type: ignore[import-untyped]
names = sorted(ALLOWED_COLLECTIONS)
created: list[str] = []
existed: list[str] = []
conflicting: list[str] = []
client = AsyncMilvusClient(uri=uri, token=token or None)
try:
for name in names:
if await client.has_collection(name):
described = await client.describe_collection(name)
problems = describe_mismatch(described)
if problems:
conflicting.append(f"{name}: {';'.join(problems)}")
print(f"[冲突] {name} 已存在但结构不同,未覆盖 -> {';'.join(problems)}")
else:
existed.append(name)
print(f"[跳过] {name} 已存在且结构一致")
continue
await client.create_collection(
collection_name=name,
schema=build_schema(),
index_params=build_index_params(),
num_partitions=NUM_PARTITIONS,
)
await client.load_collection(name)
created.append(name)
print(f"[创建] {name} 已创建并加载")
finally:
await client.close()
return created, existed, conflicting
def main() -> int:
settings = get_settings()
created, existed, conflicting = asyncio.run(
ensure_collections(settings.milvus_uri, settings.milvus_token)
)
print(f"创建 {len(created)} 个:{created}")
print(f"跳过 {len(existed)} 个:{existed}")
if conflicting:
print("存在同名不同结构的集合,已停止且未做任何覆盖,请人工确认:")
for line in conflicting:
print(f" - {line}")
return 2
return 0
if __name__ == "__main__": # pragma: no cover - CLI 入口
raise SystemExit(main())