本章目标:理解流水线最关键的一跳——md 怎么变成实体树。学完你能回答:
为什么要先提取实体再生成用例?LLM 逐节提取的两遍式结构是什么?
哪三类"不是功能的内容"如何被剥离?怎么判定一个实体是原子(可直接测)的?
假如跳过实体提取直接"文档 → 用例",LLM 会按段落顺序输出一堆用例——没有结构、没有归属、
没法回答"这个功能测全了吗"。而测试工程的第一问恰恰是覆盖:文档定义的每个功能都有要点吗?
所以先要把文档整理成一棵功能实体树:
GameSystem(文档根:挂机二期)
├─ 挂机战斗(GameplayFeature)
│ ├─ 战斗倍速(子功能)
│ └─ 自动挑战规则(子功能)
├─ 队伍管理(GameplayFeature)
│ ├─ 队伍人数上限(原子功能 ← 要点生成目标)
│ └─ 队长退出与队伍解散(原子功能)
└─ ...
generator 拿到 SRC md 后的第一步(extractors/doc_preprocessor.py 的 parse_markdown,
实体提取的唯一入口)是按标题切成 section 列表。切分前先跑固定顺序的预处理四连——顺序不能乱:
① 剥 SOURCE 标签 md 首行的 "SOURCE_TYPE: word/excel" 标记拆下来(决定后续走 word 路还是 excel 路)
② 提富媒体块 <ImgThinking>/<ExcelTableThinking>/<FlowChartThinking> 用正则提出为附件,
原位换成 IMG_REF/ETT_REF 占位符
③ 清噪 删"这是一张…"残留、目录行、内链等噪声
④ 切约定区 读 PREAMBLE_END 标记截掉头部约定区(见 3.6)
切出的每个 section 带:标题层级、标题、内容、header_path(标题面包屑,如"挂机二期 > 挂机战斗 > 战斗倍速",
后续向量化入库和层级修复都靠它)、以及全量附着的富媒体附件(img_contexts 等)。
附件为什么全量附节、不在切分期归属?因为"这张图属于哪个小节"是语义判断,切分期做 NLP 归属容易错;
正确姿势是消费期按占位符就近匹配——提取某节时,占位符落在哪节就带哪节的附件(两跳协议)。
实体提取的骨架在 extractors/entity_extractor.py 的 extract_entities,结构是"先搭骨架、再填肉、
最后后处理":
第一遍·搭骨架:从 section 标题直接建 skeleton 实体树(栈式 CONTAINS)。
搭法具体是:
_SRC_时间戳 后缀,防生成以源文件名命名的"幽灵根"),类型 GAME_SYSTEM;CONTAINS 边、子写 parent_id,骨架的双通道就此建立;设计方案/首加载 ≠ 整体流程/首加载)。第二遍·填肉:5 线程并发,逐 section 调 LLM 提取功能实体。每趟调用的 prompt 组装顺序:
[模板章节守卫约束块(前置)] ← 4.4 节
[系统角色设定 + 输出格式说明]
[aux 背景(overview 摘要 ≤500 字)] ← 4.4 节
[该节内容 + 就近匹配的图片理解块]
[输出 JSON 结构示例]
第三遍·后处理(五步,顺序是 spec 契约):关系对称化(BELONGS_TO↔CONTAINS 互认)→
孤儿挂接(无父实体挂回来源章节)→ Pass2 跨节依赖("依赖/需要/消耗"关键词预筛 + 轻量 LLM 确认,
默认拒绝建边——宁缺毋滥)→ 原子性标注(4.5 节)→ 守卫软清理 + 指导块落 metadata。
第 1 章说过,策划文档里混着大量"不是功能定义"的内容。三件套各管一类:
convert2md 已经在产物里落了 PREAMBLE_END 标记,generator 消费侧三级识别:
标记优先(直读行号)→ 无标记时头部连续白名单标题推断 → LLM 兜底(默认 off)。
提取路丢弃约定区(A/B 区不产实体);同时指导路把它分档过滤后存进 metadata.preamble_guidance
反哺后续生成(3.6 节已讲)。词表和算法的单一真相源在 modules/common/template_chapters.py——
convert2md 落标、generator 切分、dashboard 回显三方共用,禁止复制副本。
约定区之外的正文里还有辅助章节("设计核心提要""前言""更新记录")。命中 modules/common/aux_sections.py
词表的章节按两档分流:
2026-09 之前,实体树层级屡出事故(一级平铺十几个、子节点挂空)。复盘结论:层级事实分散在
四个通道(BELONGS_TO/CONTAINS/parent_id/children_ids),由"LLM 提取 + 多个删并后处理"逐步塑造,
每个删除者都是潜在孤儿工厂。逐点打补丁必复发,于是上了机制化的末端门禁
(extractors/hierarchy_gate.py 的 ensure_hierarchy,跑在 extract_entities 最末端):
四条不变量:无悬挂(引用不存在的父 → 沿标题面包屑祖先链重挂,回落文档根)、无孤儿
(无人认领 → 挂靠候选)、单根收敛(全树必须收敛到唯一文档根)、通道一致(四个通道互相同步 +
死引用清理)。门禁只重挂/挂靠/清死边,不增删实体(谁该删由守卫决定,门禁保证删后无孤);
健康树零修复零日志;幂等(二跑零变更)。G10 验证用真实事故标本复放:811 实体的破损树过门禁后
根 3→1、悬挂 0。生产已开启。
树建好了,谁是可以直接写用例的"原子功能"?判定函数 _is_atomic_functional_node 三层:
is_atomic=True 直通(人工标注永远赢,连排除类型都可覆盖);每层返回 reason 写进 atomic_functional_reason——可审计,"为什么这个实体没生成要点"永远答得出来。
判定结果写回 is_atomic_functional 字段,成为下游全链(要点生成目标集/完整性门/评估覆盖率分母/
dashboard 展示)的单一真相源。一个判定函数三处消费(提取预写/要点筛选/dashboard),改它要跑全量回归。