客服 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:
+102
-82
@@ -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 越大越相似
|
||||
|
||||
Reference in New Issue
Block a user