客服 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:
@@ -14,8 +14,15 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 门禁抽到了 `tools/knowledge_corpus_gate.py`(那边可被单测 import,本脚本一 import
|
||||
# 就会重写 jsonl,测试没法安全加载)。以脚本方式运行时仓库根不在 sys.path 上,这里补。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from tools.knowledge_corpus_gate import assert_corpus_gate # noqa: E402
|
||||
|
||||
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
CLAUSE = re.compile(r"^第[一二三四五六七八九十百]+条")
|
||||
|
||||
@@ -42,11 +49,28 @@ SOURCES: dict[str, dict[str, str]] = {
|
||||
# 只取「客户分层标准」与「各层级专属权益」两章:家族信托、资产配置流程、客户经理
|
||||
# 考核指标、隐私应急预案属内部管理内容,客户咨询用不到,不入库。
|
||||
"product/高净值客户服务规范.md": {
|
||||
"collection": "fin_product_collection", "prefix": "HNW", "visibility": "public",
|
||||
# 档位 = `registered`(**不是** public):`D2.4` §4.4「高净值客户服务规范 ·
|
||||
# 分层权益与增值服务 → registered」+ 附录B `v1.3` 裁定「高净值服务分层门槛
|
||||
# (金卡 50 万+ / 白金 200 万+ / 钻石 600 万+ / 私行 1000 万+)保持 `registered`」
|
||||
# ⇒ 故 `HNW-004`—`HNW-007` 对访客不可见,访客问「高净值客户有什么权益」走
|
||||
# 「引导登录」,**不泄露档位与门槛**。改回 public 前先读那两处裁定。
|
||||
"collection": "fin_product_collection", "prefix": "HNW", "visibility": "registered",
|
||||
"version": "V2.1", "effective_date": "", "doc_no": "",
|
||||
"tags": "高净值,VIP分级,层级权益,费率优惠",
|
||||
"allow_chapters": ["一、", "二、"],
|
||||
},
|
||||
# `乙-7` / `DEC-08`:第 4 集合「金融行业基础信息」。**行业通用常识**,
|
||||
# 不含任何产品参数、费率、门槛或推荐(源文件头部也写了这条边界)。
|
||||
"basic/基金基础知识.md": {
|
||||
"collection": "fin_basic_collection", "prefix": "BAS-CON", "visibility": "public",
|
||||
"version": "V1.0", "effective_date": "2026-06-30", "doc_no": "JR-BAS-2026-001",
|
||||
"tags": "基金基础,概念,分类,净值,分红",
|
||||
},
|
||||
"basic/基金交易与时限常识.md": {
|
||||
"collection": "fin_basic_collection", "prefix": "BAS-TRD", "visibility": "public",
|
||||
"version": "V1.0", "effective_date": "2026-06-30", "doc_no": "JR-BAS-2026-002",
|
||||
"tags": "认购,申购,赎回,T+N,费用,定投,风险等级",
|
||||
},
|
||||
"company/企业信息.md": {
|
||||
"collection": "fin_faq_collection", "prefix": "COMP", "visibility": "public",
|
||||
"version": "", "effective_date": "", "doc_no": "",
|
||||
@@ -55,6 +79,29 @@ SOURCES: dict[str, dict[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
#: FAQ 中判为 `registered` 的**条目序号**(1-based,对应 `D6.1.3` 的行号)。
|
||||
#:
|
||||
#: 依据 `D6.1.2-南方基金-高频问答对.md` §附「四、知识库可见性档位标注」(2026-09-17 定案):
|
||||
#: 唯一判据是「**答案中是否含具体数值型产品要素**」——费率 / 起投金额 / 收益率区间 /
|
||||
#: 产品规模 / 门槛金额 / 具体合作家数。**含即为产品参数 → `registered`。**
|
||||
#: 现行口径 **public 55 / registered 10 = 65**(`W6` 于 2026-09-19 追加 Q65),
|
||||
#: 序号为 Q17、Q20、Q21、Q27、Q28、Q29、
|
||||
#: Q30、Q33、Q47、Q53。**改这份名单前先读该节**:V1.0 与 V2.0 的成员并不相同
|
||||
#: (V1.0 的 Q15 已降为 `public`,Q47 / Q53 是 V2.0 新补的 `registered`),
|
||||
#: 不要按旧名单反推 `public`。
|
||||
FAQ_REGISTERED_QIDS: frozenset[int] = frozenset({17, 20, 21, 27, 28, 29, 30, 33, 47, 53})
|
||||
|
||||
#: FAQ 源文件应有的问答对条数(`D6.1.3` 的 64 组 + `W6` 补的 1 组 = 65)。
|
||||
#:
|
||||
#: **为什么是 65 而不是 64**:`W5` 复跑实测 `A-07`(「T 日和 T+1 是什么意思?」)时发现,
|
||||
#: 旧库里的专条「什么是T日、T+1?」在现行 628 块语料中**已不存在**,FAQ 家族只剩 4 条
|
||||
#: 顺带提到 `T+1`,检索 top1 只有 0.481、FAQ 家族根本进不了 top10 ⇒ 该金标条目在当前
|
||||
#: 语料下**不可达**(路由怎么改都救不回来)。这是**语料缺口**,补条目才是根因修复。
|
||||
#: 新条目**必须追加在末尾**:`FAQ_REGISTERED_QIDS` 是 1-based 序号,中间插入会让其后
|
||||
#: 所有条目的序号平移、档位整体错位(`H-05` 的档位隔离会被无声破坏)。
|
||||
FAQ_EXPECTED_COUNT = 65
|
||||
|
||||
|
||||
def leaf_split_points(marks: list[tuple[int, int, str]]) -> set[int]:
|
||||
"""叶子标题的行号集合:其后没有更深标题的标题。"""
|
||||
points: set[int] = set()
|
||||
@@ -284,10 +331,89 @@ for order, chunk in enumerate(
|
||||
"tags": "高频问答,FAQ",
|
||||
"doc_no": "", "version": "", "effective_date": "", "expire_date": "",
|
||||
"source_url": "", "reviewer": "",
|
||||
"source_file": "faq/高频问答对.txt", "visibility": "public",
|
||||
"source_file": "faq/高频问答对.txt",
|
||||
"visibility": "registered" if order in FAQ_REGISTERED_QIDS else "public",
|
||||
"chars": len(chunk["content"]),
|
||||
})
|
||||
|
||||
_faq_records = [record for record in records if str(record["doc_id"]).startswith("FAQ-")]
|
||||
_faq_registered = [record for record in _faq_records if record["visibility"] == "registered"]
|
||||
if len(_faq_records) != FAQ_EXPECTED_COUNT:
|
||||
raise SystemExit(
|
||||
f"FAQ 条数不符:实际 {len(_faq_records)},期望 {FAQ_EXPECTED_COUNT}。"
|
||||
"`knowledge/faq/高频问答对.txt` 应为 `D6.1.3` 的 64 组 + `W6` 补 1 组 = 65 组版本;"
|
||||
"条数不符通常是镜像被换成了旧版(V1.x 只有 44 条)。已中止,未写入 jsonl。"
|
||||
)
|
||||
if len(_faq_registered) != len(FAQ_REGISTERED_QIDS):
|
||||
raise SystemExit(
|
||||
f"FAQ 档位不符:registered 实际 {len(_faq_registered)},期望 {len(FAQ_REGISTERED_QIDS)}。"
|
||||
"档位名单见 `D6.1.2` §附「四、知识库可见性档位标注」。已中止,未写入 jsonl。"
|
||||
)
|
||||
|
||||
# ---- v1.4(2026-09-18)三个派生字段:family_id / param_class / intent ----------------
|
||||
#
|
||||
# 为什么加:`D2.4` 附录F 的「同族合并」「计算型参数位」「意图标签」三条能力依赖它们,
|
||||
# 而此前切片件**不含**这三个字段 ⇒ 设计里写了、数据里没有。三个字段全部**从既有信息
|
||||
# 派生**,不改任何源文件、不引入新的人工标注,因此重跑本脚本即得。
|
||||
#
|
||||
# · `family_id` —— **同族 = 同一个父块**。行级子块(`POL-AST-009-01`)挂在父块
|
||||
# (`POL-AST-009`)下,父块编号就是族号,故去掉行级子块后缀(`-NN`)即得。
|
||||
# 用途:同族并列时上层应**合并作答**,而不是把它们当成"两个互不相干的候选"
|
||||
# 去算 top1/次优差(那会把真实答案判成"并列"→转人工)。
|
||||
# ⚠️ 后缀只认**两位数字**:`FAQ-0026` / `COMP-001` 是父块本身(四位/三位数字),
|
||||
# 不能被误削成 `FAQ-` / `COMP-`。
|
||||
# · `param_class` —— 块里**最主要**的数值型产品要素,供计算型出口定位参数位。
|
||||
# 判据是「关键字在正文中**最早出现**的那一类」(块的开口主语),同类再按规则表顺序;
|
||||
# 刻意**不用固定优先级**,否则「费率表里顺带写了一句起投金额」会被整块判成 threshold。
|
||||
# · `intent` —— 业务意图,按集合映射。`INTENT_BY_QA_PREFIX`(`app/core/
|
||||
# knowledge_contracts.py`)管的是 `RAG-*` 前缀问答;本脚本**不 import 应用层**
|
||||
# (它是一次性切片脚本,要保持能单独跑),故在此镜像同一份取值 —— **改一处要改两处**。
|
||||
FAMILY_SUFFIX = re.compile(r"-\d{2}$")
|
||||
|
||||
INTENT_BY_COLLECTION: dict[str, str] = {
|
||||
"fin_basic_collection": "basic_explain",
|
||||
"fin_faq_collection": "faq",
|
||||
"fin_product_collection": "product_inquiry",
|
||||
"fin_policy_collection": "policy_explain",
|
||||
}
|
||||
|
||||
#: (参数类型, 关键字)。顺序 = 同位置时的优先级。
|
||||
PARAM_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("rate", ("费率", "管理费", "托管费", "申购费", "赎回费", "销售服务费", "业绩报酬", "折扣")),
|
||||
("threshold", ("起投", "起购", "起点", "门槛", "万元", "万+", "元起", "认购起点", "专户起点", "合格投资者")),
|
||||
("scale", ("规模", "募集", "亿元")),
|
||||
("count", ("家数", "家机构", "只基金", "网点")),
|
||||
)
|
||||
|
||||
|
||||
def family_of(doc_id: str) -> str:
|
||||
"""同族标识:去掉**两位**行级子块后缀;父块自身即族号。"""
|
||||
return FAMILY_SUFFIX.sub("", doc_id)
|
||||
|
||||
|
||||
def classify_param(content: str) -> str:
|
||||
"""块里最主要的数值型产品要素类型;一类都没命中时返回 `none`。"""
|
||||
best: tuple[int, int, str] | None = None
|
||||
for rank, (name, keywords) in enumerate(PARAM_CLASS_RULES):
|
||||
found = [content.find(keyword) for keyword in keywords]
|
||||
first = min((position for position in found if position >= 0), default=-1)
|
||||
if first < 0:
|
||||
continue
|
||||
candidate = (first, rank, name)
|
||||
if best is None or candidate[:2] < best[:2]:
|
||||
best = candidate
|
||||
return best[2] if best is not None else "none"
|
||||
|
||||
|
||||
for _record in records:
|
||||
_doc_id = str(_record["doc_id"])
|
||||
_record["family_id"] = family_of(_doc_id)
|
||||
_record["param_class"] = classify_param(str(_record["content"]))
|
||||
_record["intent"] = INTENT_BY_COLLECTION.get(str(_record["collection"]), "faq")
|
||||
|
||||
del _record, _doc_id
|
||||
|
||||
assert_corpus_gate(records)
|
||||
assert_no_duplicate_contents(records)
|
||||
|
||||
(Path("knowledge") / "_chunks.jsonl").write_text(
|
||||
|
||||
Reference in New Issue
Block a user