客服 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 生成的本地产物
This commit is contained in:
张胜宇
2026-09-20 14:33:30 +08:00
parent 8643ad1efc
commit 5d0becb67d
258 changed files with 77004 additions and 26099 deletions
+102 -82
View File
@@ -1,90 +1,88 @@
"""把 knowledge/_chunks.jsonl 灌入 Milvus 三个知识集合(临时脚本,跑完即删)。
"""把 knowledge/_chunks.jsonl 灌入 Milvus 四个知识集合(一次性灌库脚本,可反复重跑)。
设计要点:
1. **集合 schema** 按方案 §4.2 的统一字段(10 项),另加 5 个检索与合规必需的字段:
`chapter`/`section`(定位)、`source_file`(溯源)、`doc_no`(内部文件编号)、
`visibility`(反洗钱手册标 internal,客服侧按此过滤)。维度 1024 与
`qwen3.7-text-embedding-flash` 实测一致,索引按方案 §2.4.4:IVF_FLAT + COSINE + nlist=128。
2. **向量输入用「标题 + 正文」**而不是只喂正文:标题里带条款号与章节名(如
1. **集合 schema 不再由本脚本定义** —— 从 `tools/setup_milvus_knowledge_collections.py`
import `build_schema` / `build_index_params` / `NUM_PARTITIONS` / `FIELD_LIMITS`。
2026-09-18 之前这里自带第二份字段表(本脚本 `doc_id`/`content` 一套 vs 建表脚本
`knowledge_id`/`snippet` 一套),**同名集合两套定义**正是 `H-05` ④ 要求收敛的缺陷;
收敛方向取本脚本这一套,理由见建表脚本的 docstring。
2. **`visibility` 是分区键**,写入时必须**显式**给出,且只接受 `public` / `registered`。
本脚本在向量化**之前**先逐条校验(fail-closed),不依赖集合默认值兜底 ——
档位缺省不是"写空串",而是"不计入任何档位",属档位越权风险。
3. **向量输入用「标题 + 正文」**而不是只喂正文:标题里带条款号与章节名(如
「第五章 … 第十三条 … 第一类:资金流转异常」),是比正文更干净的检索信号。
3. **幂等**:用 upsert,同一 doc_id 重复灌库不会产生重复行,可以反复重跑。
4. 灌完立刻做检索自检(拿几个真实客户问题去查),不看自检结果不算灌成功。
4. **幂等**:用 upsert,同一 doc_id 重复灌库不会产生重复行,可以反复重跑。
5. 灌完立刻做检索自检(拿几个真实客户问题去查),不看自检结果不算灌成功。
"""
import asyncio
import json
import os
from collections import defaultdict
import sys
from collections import Counter, defaultdict
from pathlib import Path
import httpx
from dotenv import load_dotenv
from pymilvus import DataType, MilvusClient
from pymilvus import MilvusClient
load_dotenv(override=False)
# 建表脚本与本脚本要能被同一进程 import(`tools/` 不是包,按路径挂进去)。
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import setup_milvus_knowledge_collections as collections_setup # noqa: E402
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS, VECTOR_DIM # noqa: E402
from setup_milvus_knowledge_collections import FIELD_LIMITS # noqa: E402
MILVUS_URI = os.environ.get("MILVUS_URI", "http://127.0.0.1:19530")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN") or None
EMBED_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
EMBED_MODEL = "qwen3.7-text-embedding-flash"
EMBED_KEY = os.environ.get("QWEN_EMBEDDING_API_KEY", "")
DIM = 1024
#: 与 `model_endpoint_config.id=1`(`knowledge-embedding-qwen-v3`)及 `VECTOR_DIM` 契约一致。
#: 2026-09-18 实测:`text-embedding-v3` 与 `qwen3.7-text-embedding-flash` 都可用、都输出 1024 维,
#: 故此处不一致**不致命**,但必须收敛为一种口径 —— 取现库与文档都在用的 `text-embedding-v3`。
EMBED_MODEL = "text-embedding-v3"
#: 密钥名必须与端点的 `secret_ref`(`env:QWEN_API_KEY`)**逐字一致**,否则
#: `EnvironmentSecretResolver` 报"模型密钥未配置",表现为客服答不出 → 强制转人工。
EMBED_KEY = os.environ.get("QWEN_API_KEY", "")
DIM = VECTOR_DIM
BATCH = 10
COLLECTIONS = ["fin_faq_collection", "fin_product_collection", "fin_policy_collection"]
#: 档位取值域。写入侧**只接受**这两个值;空值 / 未知值一律中止灌库(fail-closed)。
VISIBILITY_VALUES = frozenset({"public", "registered"})
# 检索自检用例:(自然语言问题, 期望命中的 doc_id 前缀)
#: 与检索白名单同源,避免"灌进了检索查不到的集合"。
COLLECTIONS = sorted(ALLOWED_COLLECTIONS)
# 检索自检用例:(自然语言问题, 期望命中的 doc_id 前缀)。
#
# ⚠️ 期望值是**语料版本的函数**,改语料就要跟着改:2026-09-18 重灌时(FAQ 由 44 条
# V1.x 换成 `D6.1.3` 的 64 条 V2.0、政策与产品手册同步 V2.0)旧期望值全部失效,
# 表现为「自检只有 3/7 命中」——但逐条看会发现**排序其实是对的**,只是编号变了
# (例如「基金赎回到账」从 `FAQ-0016` 变成 `FAQ-0026`)。这组值即重灌当日的实测基线。
CHECKS = [
("基金赎回到账需要多长时间", "FAQ-0016"),
# 分等级的「能买什么」现在由 FAQ 承接(与 POL-AST 的匹配矩阵是同一份内容,
# 但 FAQ 的问句措辞更接近客户口语,所以问这句话时 FAQ 会排在前面)。
("C1 保守型客户可以买哪些风险等级的产品", "FAQ"),
("C1 客户能买什么", "FAQ"),
("南方季季盈90天的起投金额是多少", "PROD"),
("开户需要准备哪些材料", "FAQ-0021"),
("业绩比较基准是什么意思", "FAQ-0019"),
("高净值客户能享受什么费率优惠", "HNW"),
("基金赎回到账需要多长时间", "FAQ-0026"),
# 分等级的「能买什么」:`POL-AST-012` 的匹配矩阵与 FAQ 是同一份内容,
# 但这一问的措辞更近条款语言,故政策集合排前(实测 0.792)。
("C1 保守型客户可以买哪些风险等级的产品", "POL-AST-012"),
("C1 客户能买什么", "FAQ-0019"),
("南方季季盈90天的起投金额是多少", "PROD-006"),
("开户需要准备哪些材料", "FAQ-0036"),
("业绩比较基准是什么意思", "FAQ-0022"),
("高净值客户能享受什么费率优惠", "HNW-005"),
# `乙-7` 第 4 集合(金融行业基础信息):这三条**只有**新集合能答,
# 用来证明第 4 集合确实灌进去了、也确实进了检索面。
("基金定投是什么", "BAS-TRD-012"),
("场内基金和场外基金有什么区别", "BAS-CON-006"),
# 期望值是 FAQ-0065(`W6` 补的专条「什么是T日、T+1?」)而不是第 4 集合 ——
# 实测 FAQ-0065 得 0.703 胜出,这是**更好**的结果:专条比通用常识更贴题。
# 第 4 集合是「FAQ 答不了时的兜底」,不是"抢答 FAQ 能答的题"。
("T+1 是什么意思", "FAQ-0065"),
]
def build_schema(client: MilvusClient) -> object:
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("doc_id", DataType.VARCHAR, max_length=64, is_primary=True)
schema.add_field("title", DataType.VARCHAR, max_length=1024)
schema.add_field("content", DataType.VARCHAR, max_length=16384)
schema.add_field("chapter", DataType.VARCHAR, max_length=512)
schema.add_field("section", DataType.VARCHAR, max_length=512)
schema.add_field("tags", DataType.VARCHAR, max_length=512)
schema.add_field("doc_no", DataType.VARCHAR, max_length=64)
schema.add_field("version", DataType.VARCHAR, max_length=32)
schema.add_field("effective_date", DataType.VARCHAR, max_length=32)
schema.add_field("expire_date", DataType.VARCHAR, max_length=32)
schema.add_field("source_url", DataType.VARCHAR, max_length=512)
schema.add_field("reviewer", DataType.VARCHAR, max_length=64)
schema.add_field("source_file", DataType.VARCHAR, max_length=128)
schema.add_field("visibility", DataType.VARCHAR, max_length=16)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=DIM)
return schema
def ensure_collections(client: MilvusClient) -> None:
existing = set(client.list_collections())
for name in COLLECTIONS:
if name in existing:
print(f" 集合已存在,跳过创建:{name}")
continue
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding", index_type="IVF_FLAT", metric_type="COSINE",
params={"nlist": 128},
)
client.create_collection(
collection_name=name, schema=build_schema(client), index_params=index_params
)
print(f" 已创建集合:{name}")
async def embed(texts: list[str]) -> list[list[float]]:
async with httpx.AsyncClient(timeout=90) as client:
response = await client.post(
@@ -100,7 +98,7 @@ async def embed(texts: list[str]) -> list[list[float]]:
async def main() -> None:
if not EMBED_KEY:
print("缺少 QWEN_EMBEDDING_API_KEY")
print("缺少 QWEN_API_KEY(必须与 model_endpoint_config.secret_ref 逐字一致)")
return
records = [
@@ -110,15 +108,33 @@ async def main() -> None:
]
print(f"待入库块数:{len(records)}")
# 档位先校验后向量化:非法/缺失时**一条向量都不调**,避免花了钱才发现白灌。
invalid = [r for r in records if str(r.get("visibility", "")) not in VISIBILITY_VALUES]
if invalid:
print(f"档位非法或缺失:{len(invalid)} 条(只接受 {sorted(VISIBILITY_VALUES)}),已中止:")
for record in invalid[:5]:
print(f" {record.get('doc_id')} visibility={record.get('visibility')!r}")
return
print("档位分布:" + str(dict(Counter(str(r['visibility']) for r in records))))
print("\n== 建集合(幂等;结构冲突即中止,绝不覆盖) ==")
_created, _existed, conflicting = await collections_setup.ensure_collections(
MILVUS_URI, MILVUS_TOKEN or ""
)
if conflicting:
print("集合结构冲突,已中止(未做任何覆盖):")
for line in conflicting:
print(f" - {line}")
return
client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
print("\n== 建集合 ==")
ensure_collections(client)
grouped: dict[str, list[dict[str, object]]] = defaultdict(list)
for record in records:
grouped[str(record["collection"])].append(record)
print("\n== 生成向量并写入 ==")
truncated: dict[str, int] = {}
for name, group in grouped.items():
rows: list[dict[str, object]] = []
for start in range(0, len(group), BATCH):
@@ -127,27 +143,27 @@ async def main() -> None:
[f"{record['title']}\n{record['content']}" for record in batch]
)
for record, vector in zip(batch, vectors, strict=True):
rows.append({
"doc_id": record["doc_id"],
"title": str(record["title"])[:500],
"content": str(record["content"])[:8000],
"chapter": str(record["chapter"])[:250],
"section": str(record["section"])[:250],
"tags": str(record["tags"])[:250],
"doc_no": str(record["doc_no"])[:60],
"version": str(record["version"])[:30],
"effective_date": str(record["effective_date"])[:30],
"expire_date": str(record["expire_date"])[:30],
"source_url": str(record["source_url"])[:500],
"reviewer": str(record["reviewer"])[:60],
"source_file": str(record["source_file"])[:120],
"visibility": str(record["visibility"])[:16],
"embedding": vector,
})
# 截断长度**只从集合定义取**(`FIELD_LIMITS` 由建表模块导出):
# 这里若写死数字,就会出现"脚本截到 8000、集合只给 4096"这类
# 只在写入那一刻才暴露的错配,而且改一处必须记得改另一处。
row: dict[str, object] = {}
for field, limit in FIELD_LIMITS.items():
value = str(record[field])
if len(value) > limit:
truncated[field] = truncated.get(field, 0) + 1
value = value[:limit]
row[field] = value
row["embedding"] = vector
rows.append(row)
print(f" {name}: 已向量化 {min(start + BATCH, len(group))}/{len(group)}")
client.upsert(collection_name=name, data=rows)
client.flush(collection_name=name)
if truncated:
print("⚠️ 有字段被截断到集合上限(计数):", truncated)
else:
print("所有字段均在集合定义的长度上限内,无截断。")
print("\n== 各集合条目数 ==")
for name in COLLECTIONS:
stats = client.get_collection_stats(collection_name=name)
@@ -170,7 +186,11 @@ async def main() -> None:
collection_name="fin_product_collection", data=[vector], limit=3,
output_fields=["doc_id", "title"],
)
merged = [item for group in (top, product, results) for item in group[0]]
basic = client.search(
collection_name="fin_basic_collection", data=[vector], limit=3,
output_fields=["doc_id", "title"],
)
merged = [item for group in (basic, top, product, results) for item in group[0]]
merged.sort(key=lambda item: item["distance"], reverse=True)
best = merged[0] if merged else None
# 注意:COSINE 下 pymilvus 返回的 distance 越大越相似