Files
group_fqcd_jr/tools/qa_source_parser.py

151 lines
6.1 KiB
Python
Raw Permalink 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.
"""解析已审核的 QA 源文件(`客服Agent知识库_QA问答对_v5_RAG发布候选版.txt`)。
为什么需要它:这份 105 条的文本是**业务侧唯一的人工审核产物**,后续入库的知识、
同义问法、以及 Milvus 向量全部由它派生。手工抄写 105 条 × (1 问题 + 若干相似问法 + 1 回答)
既不可审计也不可重放,所以把"解析"单独做成一个无副作用的纯函数模块:解析能被单测固定住,
导入脚本只负责落库。
源文件的真实形状(实测,不是推测):
- 每条固定三行:`[编号]` / `问题:` / `相似问法:`(`|` 分隔)/ `回答:`。
- 体量:**105 条**,其中 `RAG-*` **62** 条、`NF-*` **43** 条;相似问法合计 **421** 条。
- 编号前缀实测有 **12 种**:`RAG-PER`/`RAG-CHAT`/`RAG-PUB`/`RAG-RVW`/`RAG-HUM`/`RAG-CONFIG`/
`RAG-P1` 与 `NF-SVC`/`NF-TRD`/`NF-CMP`/`NF-STS`/`NF-ACC`。
计划里写的"11 种"漏了 `NF-ACC`(2 条:`NF-ACC-001`/`NF-ACC-003`,在"三、真实直销业务场景问答"节内)。
**按前缀白名单解析是错的方向**——只按 `RAG-` 匹配会静默丢掉 43 条 `NF-*`,
所以这里的编号正则刻意不枚举前缀,`[任意字母开头的连字符编号]` 一律接受,
由 `expected_count` 兜底:数量对不上就报错,而不是少导了还说自己成功。
`EXPECTED_RECORD_COUNT=105` 是**默认开启的断言**:调用方必须显式传
`expected_count=None` 才能跳过计数校验——默认安全,测试样例才需要绕过。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from hashlib import sha256
#: 已审核源文件的条目数(业务侧 v5.8 交付物)。验收补充条目另算,不在此数内。
EXPECTED_RECORD_COUNT = 105
#: 编号行:`[RAG-PER-001]` / `[NF-ACC-003]`……**不枚举前缀**,否则会漏记录。
_ID_PATTERN = re.compile(r"^\[([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+)\]$")
#: 三个字段的固定前缀(全角冒号)。
_QUESTION = "问题:"
_SYNONYMS = "相似问法:"
_ANSWER = "回答:"
_FIELD_PREFIXES = (_QUESTION, _SYNONYMS, _ANSWER)
#: 相似问法分隔符。
_SYNONYM_SEPARATOR = "|"
@dataclass(frozen=True)
class QaRecord:
"""一条已审核 QA。`synonyms` 为不可变元组,且在**归一化后**无重复。"""
qa_id: str
question: str
synonyms: tuple[str, ...]
answer: str
def normalize_phrase(phrase: str) -> str:
"""归一化问法:折叠所有空白并转小写。
与 `docs/02` §7.3 L355 的口径一致——`phrase_hash` 算的是**完整**归一化文本的
SHA-256,去重必须在归一化之后做,否则「你 是 谁?」与「你是谁?」会被当成两条。
"""
return " ".join(phrase.split()).strip().lower()
def phrase_hash(phrase: str) -> str:
"""`agent_faq_synonym.phrase_hash`:归一化文本的 SHA-256(hex,64 字符)。"""
return sha256(normalize_phrase(phrase).encode("utf-8")).hexdigest()
def _blocks(text: str) -> list[list[str]]:
"""按编号行切块,每块只保留编号行与三字段行;章节标题、说明文字、分隔线一律丢弃。"""
blocks: list[list[str]] = []
current: list[str] | None = None
for raw in text.splitlines():
line = raw.strip()
if _ID_PATTERN.match(line):
if current is not None:
blocks.append(current)
current = [line]
continue
if current is None or not line:
continue
if line.startswith(_FIELD_PREFIXES):
current.append(line)
if current is not None:
blocks.append(current)
return blocks
def _parse_block(block: list[str], seen: set[str]) -> QaRecord:
match = _ID_PATTERN.match(block[0])
if match is None: # pragma: no cover - `_blocks` 只产出匹配行
raise ValueError(f"非法的编号行:{block[0]}")
qa_id = match.group(1)
if qa_id in seen:
# 重复编号意味着后一条会覆盖前一条,或同义词被挂到错的知识上,必须当场失败。
raise ValueError(f"重复的 qa_id:{qa_id}")
seen.add(qa_id)
fields: dict[str, str] = {}
for line in block[1:]:
for prefix in _FIELD_PREFIXES:
if line.startswith(prefix):
if prefix in fields:
raise ValueError(f"{qa_id} 的 {prefix.rstrip(':')} 出现了多次")
fields[prefix] = line[len(prefix):].strip()
break
question = fields.get(_QUESTION, "")
synonyms_raw = fields.get(_SYNONYMS, "")
answer = fields.get(_ANSWER, "")
if not question:
raise ValueError(f"{qa_id} 缺少问题")
if not synonyms_raw:
raise ValueError(f"{qa_id} 缺少相似问法")
if not answer:
raise ValueError(f"{qa_id} 缺少回答")
synonyms: list[str] = []
digests: set[str] = set()
for item in synonyms_raw.split(_SYNONYM_SEPARATOR):
phrase = item.strip()
if not phrase:
continue
digest = phrase_hash(phrase)
if digest in digests:
# `uk_faq_synonym (knowledge_id, phrase_hash)` 是硬约束:在解析层就去重,
# 免得到库上撞唯一键时报 MySQL 错误(那种报错看不到是哪条 QA 有问题)。
continue
digests.add(digest)
synonyms.append(phrase)
if not synonyms:
raise ValueError(f"{qa_id} 相似问法为空")
return QaRecord(qa_id, question, tuple(synonyms), answer)
def parse_qa_source(
text: str, *, expected_count: int | None = EXPECTED_RECORD_COUNT
) -> list[QaRecord]:
"""解析全文并返回按出现顺序排列的 `QaRecord` 列表。
`expected_count` 默认 `105`:数量不符直接抛 `ValueError`,让"少解析了 43 条
NF-*"这类错误在解析阶段就炸出来,而不是安静地导进去 62 条。
"""
records: list[QaRecord] = []
seen: set[str] = set()
for block in _blocks(text):
records.append(_parse_block(block, seen))
if expected_count is not None and len(records) != expected_count:
raise ValueError(f"记录数不符:期望 {expected_count},实际 {len(records)}")
return records