一、客服 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 生成的本地产物
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""幂等创建知识检索用的四个 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())
|