Files
group_fqcd_jr/tools/build_knowledge_chunks.py
T
张胜宇 d5e813b726 feat(model-gateway)+docs(W19): embedding 端点唯一性配置守卫 + 三份完整版/收敛版索引口径补注 + 门槛口径更正 + D3.7 难例口径统一
一、代码(2 文件 + 2 工具脚本注记)
* app/service/model_gateway.py:DatabaseModelEndpointResolver.resolve() 加配置守卫
  —— required == "embedding" 且 len(matched) > 1 时 logger.warning(只告警、不改行为)。
  多个 embedding 端点会让索引向量与查询向量可能来自不同模型(维度同为 1024、不报错),
  COSINE 相似度整体失真,表现为"越答越差"的哑故障。顺手删掉重复的 return endpoints(死代码)。
* tests/unit/service/test_model_gateway.py:新增 2 条单测(多端点告警且返回顺序不变 / 单端点静默)。
* tools/configure_embedding_endpoint.py:加「已废弃,勿重跑」标注 —— 它写的是
  qwen-embedding / qwen3.7-text-embedding-flash,与现役端点 knowledge-embedding-qwen-v3 /
  text-embedding-v3 不一致,重跑会凭空多出一个 embedding 端点。
* tools/build_knowledge_chunks.py:删掉与新口径冲突的注释「不泄露档位与门槛」,
  改为「registered 的依据是权益明细而非门槛;门槛属公开宣传口径」。

二、文档(8 份;D-1 选乙 + D-3 统一为 18)
* D2.4 v1.6 → v1.7:§4.4 + 附录B 更正「门槛金额不再单独构成 registered 的理由」
  (public 的 FAQ-0014 已完整给出五档门槛、FAQ-0050 含钻石门槛);
  HNW-004—HNW-007 保持 registered,依据收窄为"各层级权益明细";HNW-* 档位不动(分区键)。
* D3.1 v2.5 → v2.6:§5.3 加索引口径落地注(覆盖 §2.5 决策表 / FR-CS-007 / 排期 T4)
  + 补「字段表同属初稿」(实库 18 字段全 NOT NULL、doc_id 主键、无 metadata JSON)。
* D3.2 v1.2 → v1.6:§4.1 加同口径注 + 版本位追平(顶栏 v1.1 / doc-meta v1.2 落后于自身记录 v1.5)。
* D2.2 v2.6 → v2.7:§1.4.2 域 B 加注(TopK / 阈值 / 度量 / 集合选择均未变 ⇒ 不影响验收)。
* D3.7:§3 难例口径统一 —— 难例 32 条(改写 8 + 口语 16 + 多轮 4 + 禁忌 4)为定义式总数,
  M-2b 分母 = 其中带期望证据家族的 18 条;并补正 §3 初稿表格条数(以 cases_46.json 为准)。
* D1.1 v1.8 → v1.9:新增 §28;四处版本位同步;顺带修正两处历史遗留
  (D2.4 版本位长期停在 v1.3、D2.2 日期列停在 2026-09-17)。
* D1.6:新增 §4.47(含自我失误留痕)。
* D2.1 v6.33 → v6.34:新增本轮修订要点段。

三、实测门口(本机)
* tests/unit/service/test_model_gateway.py:10 passed
* pytest -q -p no:cacheprovider(全量,跑前已停 Worker):1917 passed / 3 skipped / 0 failed
* tools/check_authoritative_docs.py:54 文档无编号冲突(exit 0)
* _consistency.py:失效锚点 0、交叉引用全 ✅(exit 0)
* _fe_boundary_http.py(重建件):12/12 符合预期
* 服务已重启:/internal/health/ready 三依赖全绿(mysql / redis / milvus)

四、如实留痕(自我失误)
本轮清理临时文件时删除判据过宽,误删 _consistency.py(已原样恢复)、
_legacy_customer_service.py(已按 f72a545 逐字节重建,40,554 字节)、
_fe_boundary_http.py(原件不可恢复,已按既有判据重建并实跑 12/12)与若干历史轮次原始日志。
详见 D1.6 §4.47 五。
2026-09-20 17:53:06 +08:00

455 lines
24 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.
"""把 knowledge/ 下的文档切成可检索知识块,输出 JSONL(临时脚本,跑完即删)。
切片粒度决定检索质量。这里采用**叶子标题**策略:一个标题若其后没有更深的标题,
它就是一个切分点。相比"按固定级别切",这个策略同时满足了三种真实情况:
- 有的条款带 `#### X.Y` 子条款(如反洗钱第九条 9.1–9.4)→ 按子条款切,粒度更细;
- 有的条款没有子条款(如反洗钱第十一条)→ 自己就是叶子,单独成块,不会被并进上一块;
- 有的章没有小节(如企业信息「一、公司基本信息」)→ 章本身就是叶子,内容不会丢。
标题路径保留完整上级链;若切分点本身不是「第X条」(例如反洗钱第十三条下的
`### 第一类:资金流转异常`),会把最近的条款名补进路径,避免块失去归属。
纯「目录」块直接丢弃。
"""
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"^第[一二三四五六七八九十百]+条")
# 每个文件的入库配置:集合、编号前缀、可见性、版本与生效日期(取自文档头部)
SOURCES: dict[str, dict[str, str]] = {
"policy/个人投资者适当性管理指南.md": {
"collection": "fin_policy_collection", "prefix": "POL-AST", "visibility": "public",
"version": "V3.2", "effective_date": "2024-01-15", "doc_no": "JR-AST-2024-001",
"tags": "适当性,C1-C5,R1-R5,双录,冷静期",
},
# 反洗钱合规操作手册**不入客服知识库**:本业务只做公募基金,不涉及银行转账与资金划付,
# 反洗钱属后续风控模块职责;且该手册标注「内部机密」、第十六条禁止向客户透露可疑交易
# 信息,混入面向客户的知识库存在制度性冲突。
"policy/理财产品销售管理办法.md": {
"collection": "fin_policy_collection", "prefix": "POL-SPM", "visibility": "public",
"version": "V3.0", "effective_date": "2024-02-01", "doc_no": "JR-SPM-2024-003",
"tags": "理财产品销售,双录,冷静期,费率,投诉",
},
"product/个人理财产品手册.md": {
"collection": "fin_product_collection", "prefix": "PROD", "visibility": "public",
"version": "V2.8", "effective_date": "", "doc_no": "",
"tags": "基金,银行理财,保险,费率,申赎",
},
# 只取「客户分层标准」与「各层级专属权益」两章:家族信托、资产配置流程、客户经理
# 考核指标、隐私应急预案属内部管理内容,客户咨询用不到,不入库。
"product/高净值客户服务规范.md": {
# 档位 = `registered`(**不是** public):依据是 `D2.4` §4.4「高净值客户服务规范 ·
# 分层权益与增值服务 → registered」——**依据是「权益明细」,不是「门槛」**。
# `D2.4` v1.7 已更正(`D-1` 选乙):**分层体系与门槛属公开宣传口径** ——
# `public` 的 `FAQ-0014` 已完整给出五档门槛、`FAQ-0050` 含「600 万元以上钻石客户」,
# 故门槛**不构成** `registered` 的理由(附录B `v1.3` 裁定的「理由②」已作废)。
# ⇒ 故 `HNW-004`—`HNW-007` 对访客不可见,访客问「高净值客户有什么权益」走
# 「引导登录」。改档位前先读 `D2.4` §4.4 与附录B —— `visibility` 是**分区键**,
# 改档位须**重建集合**,不是改一个字段那么简单。
"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": "",
"tags": "公司信息,金融牌照,资质",
},
}
#: 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()
for position, (index, level, _title) in enumerate(marks):
following = marks[position + 1] if position + 1 < len(marks) else None
if following is not None and following[1] > level:
continue # 有子标题,不是叶子
points.add(index)
return points
def chunk_markdown(text: str) -> list[dict[str, str]]:
lines = text.splitlines()
marks: list[tuple[int, int, str]] = []
for index, line in enumerate(lines):
match = HEADING.match(line)
if match:
marks.append((index, len(match.group(1)), match.group(2).strip()))
marks_by_line = {index: (level, title) for index, level, title in marks}
split_points = leaf_split_points(marks)
stack: dict[int, str] = {}
chunks: list[dict[str, str]] = []
buffer: list[str] = []
meta: dict[str, str] | None = None
last_clause = ""
def has_body() -> bool:
return any(not HEADING.match(line) and line.strip() for line in buffer)
def flush() -> None:
nonlocal buffer, meta
if meta is not None and has_body():
body = "\n".join(buffer).strip()
if body:
chunks.append({**meta, "content": body})
buffer = []
for index, line in enumerate(lines):
mark = marks_by_line.get(index)
if mark is not None:
level, title = mark
stack[level] = title
for deeper in [key for key in stack if key > level]:
del stack[deeper]
if CLAUSE.match(title):
last_clause = title
if index in split_points:
flush()
# 所属章取「最近的上级标题」,而不是最外层文档标题(sorted 后取首个会拿到 h1)
ancestors = [key for key in stack if key < level]
chapter = stack[max(ancestors)] if ancestors else ""
path = [stack[key] for key in sorted(stack) if key <= level]
if not CLAUSE.match(title) and last_clause and last_clause not in path:
path = [item for item in (chapter, last_clause) if item] + [title]
meta = {"title": " · ".join(path), "chapter": chapter, "section": title}
buffer.append(line)
flush()
return [chunk for chunk in chunks if chunk["section"].strip() != "目录"]
def chunk_qa(text: str) -> list[dict[str, str]]:
chunks: list[dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or "\t" not in line:
continue
question, _, answer = line.partition("\t")
chunks.append({
"title": question.strip(),
"chapter": "高频问答",
"section": question.strip(),
"content": f"问:{question.strip()}\n答:{answer.strip()}",
})
return chunks
TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
LEADING_NUMBER = re.compile(r"^\d+(?:\.\d+)*\s*")
def expand_table_rows(chunk: dict[str, str], parent_id: str) -> list[dict[str, object]]:
"""把 Markdown 表格的每一行拆成自解释的小块(父块照旧保留)。
为什么需要:现在的粒度是"一个叶子标题 = 一块",产品手册里就是**整个产品小节**
(表格 + 说明)成一块。于是客户问「起投多少」和问「风险高吗」命中同一块、拿到
**完全相同**的整节内容——客户会觉得客服没听懂问题,只是把说明书重贴一遍。
顺带地,整节几百字的向量是"整节的混合语义",与"起投多少"这种具体小问题相似度
天然偏低(实测该问句向量 top1 只有 0.6291,够不到 0.75 门槛)。
小块必须**自解释**:只回「1万元」客户不知道说的是哪个产品,所以带上产品名与行标签。
父块保留,客户问「这个产品怎么样」时仍要能拿到完整一节。
## 2026-09-15 修掉的 bug:表头被当成数据行
原实现用「**第一个非分隔行**」当表头(`if not header: header = cells`),而 `header`
从不重置。于是**同一节里出现第二张表格时,它的表头行被当成数据行**,产出形如
「第九条 问卷内容及评分标准:选项 分值」的**零信息量碎片**:
《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 15 条碎片,且**正文逐字相同**。
检索时它们必然互相打平(实测把「风险评估问卷怎么评分」的 top1/次优差压到 **0.002**,
客服按"中置信需领先 ≥0.07"判并列 → 转人工),把真正有内容的块挤到第 5 名。
修法:markdown 表格的表头**只可能是紧邻分隔行 `|---|---|` 之前的那一行**,所以按分隔行
认表头,用 `prev` 延迟一行判断。修后块数 636 → 617,正文完全相同的组从 1 组 15 块降到 0。
"""
blocks: list[dict[str, object]] = []
name = LEADING_NUMBER.sub("", chunk["section"]).strip() or chunk["section"]
def emit(cells: list[str]) -> None:
if len(cells) < 2:
return
label, value = cells[0], cells[1]
if not label or not value:
return
blocks.append({
"title": f"{chunk['title']} · {label}",
"chapter": chunk["chapter"],
"section": f"{name} · {label}",
"content": f"{name}:{label} {value}",
"parent_id": parent_id,
})
prev: list[str] | None = None
for line in chunk["content"].splitlines():
match = TABLE_ROW.match(line.strip())
if not match:
continue
cells = [cell.strip() for cell in match.group(1).split("|")]
if all(set(cell) <= {"-", ":", " "} for cell in cells):
# 分隔行 |---|:紧邻它之前的那一行(`prev`)是**表头**,不能当数据行 → 丢弃。
prev = None
continue
if prev is not None:
emit(prev) # 没被分隔行认领为表头的行 = 数据行
prev = cells
if prev is not None:
emit(prev) # 收尾:最后一行也要处理(没有分隔行收尾的表格)
return blocks
def assert_no_duplicate_contents(records: list[dict[str, object]]) -> None:
"""自带守卫:**正文完全相同的块必须为 0**,否则直接失败退出、不生成 jsonl。
为什么用这一条当守卫:表头被误当数据行时的直接后果就是"**多张表格产出逐字相同的块**"
(第九条那 16 张问卷表 → 15 条一模一样的「…:选项 分值」)。这类块在检索里必然互相
打平,把 top1/次优差压到 0.07 门槛之下(实测 0.002)→ 客服判并列转人工。
本脚本是**一次性灌库脚本**、没有单测覆盖(这正是该 bug 活下来的原因),
所以把守卫放在脚本自己的执行路径上:**每次重灌都会跑一遍**。
为什么不是"块长度下限":短块本身是设计的一部分(「评审标准:管理人资质 15%」13 字,
但它是真实的数据行、是有效答案)。**内容逐字重复**才是缺陷特征,长度不是。
"""
seen: dict[str, list[str]] = {}
for record in records:
seen.setdefault(str(record["content"]), []).append(str(record["doc_id"]))
duplicated = {content: ids for content, ids in seen.items() if len(ids) > 1}
if duplicated:
detail = "\n".join(
f" {len(ids)} 份:{content[:60]!r} → {ids[:6]}"
for content, ids in list(duplicated.items())[:5]
)
raise SystemExit(
f"知识块自检失败:有 {len(duplicated)} 组正文完全相同的块。\n"
"这类块在检索里必然互相打平(把 top1/次优差压到 0.07 之下 → 客服转人工),"
"通常是**表格表头被当成了数据行**(见 `expand_table_rows` 的 docstring)。\n"
f"{detail}\n已中止,未写入 jsonl。"
)
records: list[dict[str, object]] = []
for relative, config in SOURCES.items():
text = (Path("knowledge") / relative).read_text(encoding="utf-8")
chunks = chunk_markdown(text)
# 章节白名单:只保留指定章下的块(用于剔除内部管理章节,如高净值规范只留分级与权益)
allowed = config.get("allow_chapters")
if isinstance(allowed, list):
chunks = [
chunk for chunk in chunks
if any(chunk["chapter"].startswith(prefix) for prefix in allowed)
]
for order, chunk in enumerate(chunks, 1):
parent_id = f"{config['prefix']}-{order:03d}"
records.append({
"doc_id": parent_id,
"collection": config["collection"],
"title": chunk["title"],
"content": chunk["content"],
"chapter": chunk["chapter"],
"section": chunk["section"],
"tags": config["tags"],
"doc_no": config["doc_no"],
"version": config["version"],
"effective_date": config["effective_date"],
"expire_date": "", "source_url": "", "reviewer": "",
"source_file": relative,
"visibility": config["visibility"],
"chars": len(chunk["content"]),
})
# 行级子块:挂在父块 doc_id 下(PROD-007-01 这种),父块编号不受新增子块影响,
# 因此反复重跑本脚本得到的 doc_id 是稳定的。
for row_order, block in enumerate(expand_table_rows(chunk, parent_id), 1):
records.append({
"doc_id": f"{parent_id}-{row_order:02d}",
"collection": config["collection"],
"title": block["title"],
"content": block["content"],
"chapter": block["chapter"],
"section": block["section"],
"tags": config["tags"],
"doc_no": config["doc_no"],
"version": config["version"],
"effective_date": config["effective_date"],
"expire_date": "", "source_url": "", "reviewer": "",
"source_file": relative,
"visibility": config["visibility"],
"chars": len(str(block["content"])),
})
for order, chunk in enumerate(
chunk_qa((Path("knowledge") / "faq/高频问答对.txt").read_text(encoding="utf-8")), 1
):
records.append({
"doc_id": f"FAQ-{order:04d}",
"collection": "fin_faq_collection",
"title": chunk["title"], "content": chunk["content"],
"chapter": chunk["chapter"], "section": chunk["section"],
"tags": "高频问答,FAQ",
"doc_no": "", "version": "", "effective_date": "", "expire_date": "",
"source_url": "", "reviewer": "",
"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(
"\n".join(json.dumps(record, ensure_ascii=False) for record in records), encoding="utf-8"
)
lines: list[str] = [f"总块数:{len(records)}\n"]
by_collection: dict[str, list[dict[str, object]]] = {}
for record in records:
by_collection.setdefault(str(record["collection"]), []).append(record)
for name, group in sorted(by_collection.items()):
sizes = sorted(int(record["chars"]) for record in group)
lines.append(
f"{name}: {len(group)} 块,字符数 最小 {sizes[0]} / 中位 {sizes[len(sizes)//2]} / 最大 {sizes[-1]}"
)
lines.append("\n各文件块数:")
by_file: dict[str, int] = {}
for record in records:
by_file[str(record["source_file"])] = by_file.get(str(record["source_file"]), 0) + 1
for name, count in sorted(by_file.items()):
lines.append(f" {name}: {count}")
over = [record for record in records if int(record["chars"]) > 1200]
lines.append(f"\n超过 1200 字符的块:{len(over)} 个")
for record in over[:10]:
lines.append(f" {record['doc_id']} {record['chars']} 字符 {str(record['title'])[:64]}")
lines.append("\n反洗钱手册的块标题(核对「第一类」归属是否带上了第十三条):")
for record in records:
if str(record["doc_id"]).startswith("POL-AML"):
lines.append(f" {record['doc_id']} {int(record['chars']):>5} 字符 {str(record['title'])[:70]}")
Path("_chunks_report.txt").write_text("\n".join(lines), encoding="utf-8")
print(f"已生成 {len(records)} 块 → knowledge/_chunks.jsonl;报告见 _chunks_report.txt")