feat(skill): design-story-foundation v2 重组——固定三级章节树与串行合并

v1 的「前三章/前十章/前五十章」节奏候选改为固定三级章节树(九章,每章下
三级标题固定且顺序不得调整)。SKILL.md 重写「根设定与候选章节树的分工」
并把章节内容规范独立到 references/chapter-content-spec.md,避免任务包里
重复保存章节合同;candidate_tree.json 作为该规范的固化实例。

候选生成合同(candidate-contract.md)扩到 v2:候选逐字同构、四字段(回答/
应写/不写/关联)固定、章节内不允四标题、二级标题下不可直放 S 条目。
串行合并合同(serial-merge-contract.md)+ serial_merge.py 落地多作品合订
的统合步骤,旧 test_contract.py 由 test_validate_candidates.py 与
test_serial_merge.py 取代。

测试 7+4 全绿(test_validate_candidates + test_serial_merge)。
This commit is contained in:
zizi 2026-08-19 21:04:52 +08:00
parent 56b81b14f2
commit 8ac41a2071
8 changed files with 359 additions and 260 deletions

View File

@ -1,6 +1,6 @@
---
name: design-story-foundation
description: 在正式规划前固化作品根设定,并按前三章、前十章、前五十章的追读节奏生成可比较的前期设计候选。用户仍在单文档前期设计阶段时使用;不写正文、不落库、不替用户定稿。
description: 在正式规划前固化作品根设定,按固定三级章节树生成可比较的前期设计候选。用户仍在单文档前期设计阶段时使用;不写正文、不落库、不替用户定稿。
---
# 作品设定初始化
@ -13,24 +13,35 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
## 元数据驱动:输入与权威顺序
每次执行先冻结一份 `design-story-foundation/v1` 任务包,至少包含:
每次执行先冻结一份 `design-story-foundation/v2` 任务包,至少包含:
1. 作品名、前期设计 SoT 路径和本轮候选数量。
2. 根设定全文,以及尚未解决的冲突和问题。
3. 与本书有关的完整用户对话记录及决策状态。
4. 前三章、前十章、前五十章和全书阶段的节奏要求。
5. 参考书证据、禁止照搬项、候选目录、编号号段和篇幅门槛。
4. 前三章、前十章、前五十章和全书阶段的节奏要求;用户指定阶段模型时一并写入。
5. 参考书证据、禁止照搬项、候选目录、编号号段;如有数量或字数门槛,仅在被显式指定时写入。
6. 每个候选的唯一编号与输出路径;除此之外,各子代理输入完全相同。
冲突时按“用户最新明确要求 > 根设定中的已定事实 > 用户已接受方案 > 待定建议”处理。AI 自己说过但用户未接受的方案不能升格为根设定。
任务包的字段、状态和示例见 [候选生成合同](references/candidate-contract.md)。冻结后再派发;执行期间收到的新要求先更新任务包,旧候选随即失效,不允许边生成边暗改口径。
任务包的字段、状态和示例见 [候选生成合同](references/candidate-contract.md);三级章节各自应写什么见 [章节内容规范](references/chapter-content-spec.md)。冻结后再派发;执行期间收到的新要求先更新任务包,旧候选随即失效,不允许边生成边暗改口径。
本阶段产物不属于 23 型正式作品结构,字段权威就是 `design-story-foundation/v1` 候选合同。用户选定方案后,`plan-story` 才按 `meta/schemas/` 把内容转换成正式 Shadow 规划。
本阶段产物不属于 23 型正式作品结构,字段权威就是 `design-story-foundation/v2` 候选合同。用户选定方案后,`plan-story` 才按 `meta/schemas/` 把内容转换成正式 Shadow 规划。
## 根设定合同
## 根设定与候选章节树的分工
前期设计 SoT 的第一章固定为“根设定”。这里只放作品自身的稳定事实和叙事硬约束,包括标题承诺、主角前提、能力边界、成长台阶、披露节奏、正文表达要求和不能触碰的内容。
前期设计 SoT 的第一章固定为“根设定”。根设定与三级候选树分两层,不互相吞并:
```text
根设定 上游稳定输入,回答“这部作品绝对不能偏离什么”
三级候选树 本 Skill 的主要输出,回答“这部作品怎样被设计出来”
```
- 根设定只放作品自身的稳定事实和叙事硬约束:标题承诺、主角前提、能力边界、成长台阶、披露节奏、正文表达要求和不能触碰的内容。
- 根设定不占九章目录;阶段状态、事件树、伏笔表和文风规范不得塞进根设定。
- 候选中被用户确认的稳定事实,才回写根设定或进入后续正式规划。
根设定本身的书写规则:
- 不写设计过程、代理分工、提示词、工具、候选比较或方案来源。
- 每个点只写一句完整话,连同标签控制在 30—60 个可见字符。
@ -40,6 +51,32 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
根设定只回答“这部作品必须是什么样”。候选如何产生、谁来产生、生成几份,属于本 Skill,不得回写根设定。
## 固定三级章节树
候选文档使用固定的九章三级树,完整标题、职责与内容要点见 [章节内容规范](references/chapter-content-spec.md):
```text
一、作品定位与核心命题
二、故事世界与社会系统
三、角色与关系
四、故事机制与关键实体
五、阶段与状态变化
六、情节架构与全书大纲
七、主题与读者体验弧线
八、信息、悬念与伏笔管理
九、叙事执行与一致性
```
层级硬规则:
1. 一级章节是一个创作设计领域;二级章节是该领域要回答的一组问题;三级章节是可以独立填写、审查和引用的内容单元;`S` 条目必须挂在三级章节下。
2. 九个一级章节和全部二级、三级标题固定,顺序不得调整,所有候选逐字同构。
3. 不允许四级标题,不允许在二级标题下直接放 `S` 条目。
4. 三级标题不按“初期 / 中期 / 后期”复制;阶段变化统一由第五章状态矩阵和实体卡阶段字段表达。
5. 阶段数量由作品决定:本书可用初期、中期、后期,其他作品可用两阶段、四阶段或卷、篇、幕,但必须写清阶段边界。
6. 三级章节无适用内容时必须显式标注“不适用及原因”,不得留空。
7. `## 目录`可以存在,不属于内容章节,不参加号段和内容判定。
## 先定节奏,再铺设定
设定必须从追读节奏反推。先回答各阶段读者为什么翻下一章,再设计能支撑这些事件的能力、资源、人物和世界规则。
@ -51,7 +88,7 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
| 前五十章 | 完成初期主冲突与阶段高潮,让主角获得下一阶段资格 | 回收早期承诺,引入中期入口;不能把全书核心一次讲尽 |
| 全书阶段 | 逐级扩大个人、组织、战争与文明尺度 | 每次只揭开下一阶段必需的一层真相,并保留后续问题 |
每项关键设定都要同时写清“作者掌握的总设定”和“读者在各阶段看到什么”。终局真相可以在作者侧完整存在,正文披露点必须服从根设定,不能因为候选写得完整就在前三章泄底。
每项关键设定都要同时区分三条信息线:世界实际是什么、角色当前知道什么、读者当前看到什么。终局真相可以在作者侧完整存在,正文披露点必须服从根设定,不能因为候选写得完整就在前三章泄底。
## 参考书证据
@ -66,15 +103,15 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
## 冻结候选合同
派发前由主会话一次性冻结候选结构,所有候选使用完全相同的二级标题、顺序和编号号段。结构冻结后,子代理不得自行增删章节或改号段。
派发前由主会话一次性冻结候选结构。所有候选使用完全相同的三级标题、顺序和编号号段。结构冻结后,子代理不得自行增删章节或改号段。
- 每章按内容写 10—50 项设定,不固定写 10 项,也不为凑上限拆碎同一规则。
- 每份候选不少于 100 项设定;当前任务另有字数要求时同时执行,未明说时不得私自降低已冻结门槛。
- 当前长篇设定候选的默认有效字符下限为 50000;用户明确修改时以任务包为准。
- 每项使用唯一 `S` 编号,编号必须落在本章预留号段内,且在全文中递增、不重复。
- 候选只能用被冻结的两级目录;具体机制、例子和剧情用途写在设定项正文里。
- `S` 编号全文连续、唯一、递增;号段按一级章节预留,三级章节不再单独分号段。
- 每条 `S` 必须位于一个三级标题下;一个三级章节可以有一条或多条 `S`。
- 每个三级章节开头写四字段内容说明(回答/应写/不写/关联),再放 `S` 条目或不适用标注。
- 默认不设每项数量、总项数和字数门槛;任务包显式指定时以任务包为准,未指定时不得私自补加,也不得借门槛灌水。
- 完成与否以结构完成判定为准,见 [候选生成合同](references/candidate-contract.md) 的完成判定节。
同构只用于比较,不要求五份候选得出相同答案。每份方案必须有自己的故事发动机、阶段冲突、能力成本、关系推进和舞台扩张路径。
同构只用于比较,不要求多份候选得出相同答案。每份方案必须有自己的故事发动机、阶段冲突、能力成本、关系推进和舞台扩张路径。
## 独立生成
@ -82,11 +119,11 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
1. 每个子代理只负责一份候选,只能读取共同输入和自己的工作文件。
2. 候选之间不共享草稿、提纲、评价和中间结论;文件隔离由工作目录或沙箱保证,不能只靠提示词提醒。
3. 长文按冻结目录逐章续写,同一候选沿用自己的设计账本;续跑仍不能读取兄弟候选。
3. 长文按冻结三级树逐章续写,同一候选沿用自己的设计账本;续跑仍不能读取兄弟候选。
4. 子代理先在内部检查设定咬合,再落完整条目;主会话不替它补创意,只做编排和机械验证。
5. 任一候选未达到合同,不得先拿已完成候选做综合,避免后写方案被前案污染。
受控 Claude 调用遵守 `execute-claude-task` 的 fresh process、沙箱、期限和模型要求,并由 `record-run-evidence` 保存回执。模型超时或截断时保留该候选已完成的合法章节,从最后一个完整设定项继续;禁止用空话补足字数。
受控 Claude 调用遵守 `execute-claude-task` 的 fresh process、沙箱、期限和模型要求,并由 `record-run-evidence` 保存回执。模型超时或截断时保留该候选已完成的合法章节,从最后一个完整内容单元继续;禁止用空话补足篇幅。
## 去 AI 味与语义复核
@ -96,7 +133,8 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
- 根设定是否全部兑现,是否暗加用户未授权的硬设定。
- 前三章、前十章、前五十章是否各有目标、兑现、代价和新问题。
- 作者总设定与读者阶段认知是否分开,是否提前泄露终局答案。
- 世界事实、角色知情、读者披露三条线是否分开,是否提前泄露终局答案。
- 每个核心实体是否有稳定定义和阶段状态;每个阶段是否有进入条件、状态矩阵和退出条件。
- 能力、资源、等级、敌人和组织是否互相咬合,有无无成本万能解。
- 设定能否通过事件、差异和后果呈现,是否只能靠旁白说明。
- 参考作品是否只借了方法,是否换名照搬了专属骨架。
@ -108,11 +146,11 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
```bash
.venv/bin/python .claude/skills/design-story-foundation/scripts/validate_candidates.py \
--root-doc docs/design/<作品>-前期设计.md \
--min-candidates 5 --min-chars 50000 \
--min-candidates 5 \
docs/design/candidates/*.md
```
校验器检查根设定句长和结构、候选目录一致性、号段、编号唯一性、每章 10—50 项、总项数、有效字符数和占位符。任一文件失败,整组状态为 `SETTING_INIT_VALIDATION_FAILED`,不得声称候选齐备。
校验器只检查机械易错且人眼难以抽查的项:三级标题树与 `scripts/candidate_tree.json` 同构、号段与 `S` 编号唯一递增不越界、占位符残留、根设定句长与格式。它不检查数量、字数或内容质量;内容是否完整由语义复核按完成判定逐项核对,机械绿不等于完成。任一文件失败,整组状态为 `SETTING_INIT_VALIDATION_FAILED`,不得声称候选齐备。其中**章节名(三级标题树)同构是常驻硬门禁**,不接受任务包或临时指令放宽;其余检查项随合同版本调整。
## 用户选择与交接
@ -120,9 +158,9 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
只有用户明确选择或给出修改方向后,才开始收敛。用户可以直接指定主案,也可以要求把多份候选逐章统合;后一种方式必须遵守 [串行统合合同](references/serial-merge-contract.md),不能一次性把所有章节交给同一代理拼接。
逐章统合时,每一章使用一个 fresh 高推理代理。第一个代理只比较五份候选的第一章;主会话审定并写入统合候选后,第二个代理必须读取最新统合前文,再比较五份候选的第二章。此后依次推进,权威顺序固定为“最新根设定 > 已统合前文 > 当前五份来源章节”。后章不得推翻前章已经确定的因果、人物关系、数值、专名和披露节点。
逐章统合以一级章节为单位,每一章使用一个 fresh 高推理代理。第一个代理只比较来源候选的第一章;主会话审定并写入统合候选后,第二个代理必须读取最新统合前文,再比较来源候选的第二章。此后依次推进,权威顺序固定为“最新根设定 > 已统合前文 > 当前来源章节”。后章不得推翻前章已经确定的因果、人物关系、数值、专名和披露节点。
统合代理负责筛选、识别冲突和提出当前章草案,主会话负责消歧、定名、补齐根设定和最终落文档。不能按票数机械取多数,也不能把五案互斥的发动机全部叠加。每章落盘后先检查结构与语义,再生成下一章任务包;失败时停在当前章,不得让后续代理基于未审定草案继续。
统合代理负责筛选、识别冲突和提出当前章草案,主会话负责消歧、定名、补齐根设定和最终落文档。不能按票数机械取多数,也不能把多案互斥的发动机全部叠加。每章落盘后先检查结构与语义,再生成下一章任务包;失败时停在当前章,不得让后续代理基于未审定草案继续。
统合完成后仍是待选候选,不自动进入唯一前期设计 SoT。用户确认统合方向后,才把内容整理回 SoT;其余候选删除,由 Git 历史追溯。用户明确说“前期设计定稿”后,才把选定方案交给 `plan-story`,按正式 schema 生成 Shadow 规划。
@ -133,4 +171,4 @@ description: 在正式规划前固化作品根设定,并按前三章、前十
- 数据库:不读不写任何表;这些文件是产品链之外的用户协作草稿,在系统视角中不是正式内容。
- Git:不执行 `add`、`commit` 或删除候选,除非用户对相应动作另行明确授权。
缺根设定、对话冲突未标出、参考范围不完整、候选之间发生污染或机械门失败时,返回稳定失败码并停在候选阶段:`SETTING_INIT_INPUT_INCOMPLETE`、`SETTING_INIT_CONTRACT_DRIFT`、`SETTING_INIT_CANDIDATE_CONTAMINATED` 或 `SETTING_INIT_VALIDATION_FAILED`。不得用部分结果冒充完成。
缺根设定、对话冲突未标出、参考范围不完整、候选之间发生污染、候选结构偏离冻结三级树或机械门失败时,返回稳定失败码并停在候选阶段:`SETTING_INIT_INPUT_INCOMPLETE`、`SETTING_INIT_CONTRACT_DRIFT`、`SETTING_INIT_CANDIDATE_CONTAMINATED` 或 `SETTING_INIT_VALIDATION_FAILED`。不得用部分结果冒充完成。

View File

@ -1,6 +1,6 @@
# 候选生成合同(design-story-foundation/v1)
# 候选生成合同(design-story-foundation/v2)
本合同在派发作品设定候选前读取。它定义共同输入、候选结构和复核口径,不保存任何具体作品设定。
本合同在派发作品设定候选前读取。它定义共同输入、候选结构、层级编号和完成判定,不保存任何具体作品设定。各章节“应该写什么”的内容合同见 [章节内容规范](chapter-content-spec.md)。
## 一、冻结任务包
@ -8,7 +8,7 @@
| 字段 | 内容 |
|---|---|
| `contractVersion` | 固定为 `design-story-foundation/v1` |
| `contractVersion` | 固定为 `design-story-foundation/v2` |
| `workTitle` | 当前作品名 |
| `sotPath` | 唯一前期设计文档路径 |
| `rootSetting` | 根设定全文,不用摘要替代 |
@ -16,10 +16,11 @@
| `decisionLedger` | 每项标为已接受、已否决、待定或被新要求覆盖 |
| `unresolvedQuestions` | 会实质改变故事方向、仍需用户拍板的问题 |
| `retentionMilestones` | 前三章、前十章、前五十章及后续阶段落点 |
| `disclosureRules` | 作者总设定与读者阶段认知的分界 |
| `disclosureRules` | 世界事实、角色知情、读者披露三线分界 |
| `phaseRequirements` | 用户指定的阶段模型;为空时由候选自行划分并写明边界依据 |
| `referenceEvidence` | 大纲直证、正文归纳、可借鉴、不可照搬四栏 |
| `sectionContract` | 统一二级标题、顺序、预留编号号段 |
| `quantityContract` | 每章 10—50 项、全文至少 100 项、有效字符下限 |
| `sectionContract` | 固定九章三级树(引用 chapter-content-spec),含各一级章节预留号段 |
| `thresholdOverrides` | 用户显式指定的数量或字数门槛;机械门不查数量,为空时不设任何数量要求 |
| `candidateId` | 当前子代理唯一编号 |
| `outputPath` | 当前子代理唯一输出文件 |
@ -27,28 +28,58 @@
## 二、统一文档形状
每份候选只允许一个一级标题。正文使用任务包冻结的二级标题,不增设三级标题。每个内容章紧跟一个号段标记:
每份候选只允许一个一级标题。`## 目录`可选,不参与号段和判定。正文使用固定的二级、三级标题,顺序逐字一致:
```markdown
## 一、示例章节
# 候选标题
## 一、作品定位与核心命题
<!-- S001-S050 -->
### 1.1 作品形态与定位
> 回答:这是什么样的作品、写给谁、以什么形态连载、与同类有何差别。
> 应写:题材标签、载体与读者、篇幅与连载结构、差异化定位。
> 不写:故事内部设定、情节、角色。
> 关联:1.2、1.4、6.2。
### 1.1.1 题材、载体与目标读者
> 回答:题材元素、发布载体与目标读者分别是什么。
> 应写:题材标签、载体与阅读场景、读者群体与其期待。
> 不写:篇幅结构归 1.1.2,差异比较归 1.1.3。
> 关联:1.1.2、1.1.3。
- **S001|设定名称:** 设定正文。
### 1.1.2 篇幅、连载方式与结构规模
> 不适用:本作品尚未确定连载形态,待用户确认后补充。
```
目录名称、顺序、号段必须逐字一致。候选序号可以出现在一级标题,不能进入共同目录。每章实际使用 10—50 个编号;空号允许,越界、倒序和重复不允许。
规则:
一项设定可以写多段,但必须围绕同一规则。正文自然交代以下内容,不使用整齐划一的表单腔:
1. 四字段说明中“回答、应写、关联”必填,“不写”用于标出最常见的越界写法;内容以 chapter-content-spec 为准,子代理不得自行改写合同语义。
2. 每个三级章节要么至少一条 `S` 条目,要么一条“不适用及原因”标注;两者不得同时缺省。
3. 一项 `S` 可以写多段并包含表格,但必须围绕同一内容单元;阶段状态矩阵、伏笔登记、时间线核对允许以表格为主体。
- 规则是什么,在什么条件下生效。
- 谁因此获利,谁承担成本,失控时会发生什么。
- 它会在哪个阶段进入故事,通过什么事件让读者看见。
- 它怎样连接角色关系、资源压力、冲突或下一阶段入口。
## 三、层级与编号规则
达到篇幅门槛靠机制、差异、案例、后果和边界,不靠同义改写、套话、总结段或重复背景。
```text
一级标题:一个创作设计领域
二级标题:这个领域要回答的一组问题
三级标题:可以独立填写、审查和引用的内容单元
S 条目:三级标题下的具体设定、规则、实体、关系、阶段状态或事件
```
## 三、节奏与披露
1. 号段按一级章节预留,写在章标题之后;三级章节不单独分号段,避免号段过碎。
2. `S` 编号全文连续、唯一、递增,必须落在本章号段内;空号允许,越界、倒序和重复不允许。
3. 禁止四级标题;禁止在二级标题下直接放 `S` 条目。
4. 阶段名称(初期、中期、后期或卷、篇、幕)只能出现在卡片正文、表格列和第五章中,不得成为二、三级标题。
5. 所有候选标题逐字同构;候选序号只出现在一级标题,不进入共同目录。
## 四、节奏与披露
候选先完成追读链,再扩写设定。三个早期里程碑都要同时具备“当期问题、行动目标、实际兑现、付出代价、章末新问题”。
@ -57,28 +88,48 @@
- 前五十章完成初期主冲突和一次身份或能力抬升,再打开更大舞台。
- 后续阶段继续扩大问题尺度;早期劳动、训练、资源或关系线要换规模延续,不能用完即丢。
总设定写作者掌握的真相;阶段设定写读者当时能确认的事实。一个谜底可以在作者侧确定,但它的征兆、误判、局部解释和正式揭示必须分开放置。
信息分三线记录:世界实际状态、角色知道的状态、读者已看到的状态。一个谜底可以在作者侧确定,但它的征兆、误判、局部解释和正式揭示必须分开放置,归口见 chapter-content-spec 第七章。
## 四、独立性
## 五、独立性
候选子代理只能读取任务包、共同参考证据和自己的文件。不得搜索候选目录,不得读取其他候选,不得询问主会话“前一份怎么写”。主会话也不能把某份候选的优点转述给尚未完成的代理。
长文需要多轮时,在同一候选内部维护简短设计账本:已经确定的因果、数值、人物关系、阶段披露点和未完成章节。账本只属于该候选,续跑时与任务包一起提供。
## 五、表达复核
## 六、表达复核
完成内容复核后再处理语言。表达层重点清理:
- 空泛评价代替具体事件,例如只说“极具张力”“层层递进”。
- 每段都用“不是……而是……”或三项排比制造伪力度。
- 所有设定项使用相同句式、相同结尾或固定总结句。
- 所有条目使用相同句式、相同结尾或固定总结句。
- 频繁使用“同时、此外、值得注意的是、总而言之”等连接词。
- 用旁白宣布人物多强、世界多危险,却没有任务、损失和对比支撑。
- 为了显得完整,提前解释终局真相或最终敌人的全貌。
复核可以改句子长短、用词和段落节奏,不能改事实、编号、目录、量级、代价或披露节点。
## 六、交付报告
## 七、完成判定
一份候选只有同时满足以下条件才算完成。机械可查项由校验器把关,其余由语义复核逐项核对:
1. 九个一级章节齐全,全部二级、三级标题齐全且顺序一致。
2. 每个三级章节有内容说明,并有 `S` 条目或显式不适用标注。
3. 每个核心实体都有稳定定义和阶段状态。
4. 每个阶段都有进入条件、核心问题、状态矩阵和退出条件。
5. 每个阶段转变都有事件或明确原因。
6. 主要关系都有初始状态、变化触发和后续状态。
7. 主要规则都有触发、边界、代价和可观察结果。
8. 主要事件都改变至少一项状态或推进一项承诺。
9. 世界事实、角色知情和读者披露三条线没有混写。
10. 前三章、前十章、前五十章分别有目标、兑现、代价和新问题。
11. 没有把终局答案提前泄露给不该知道的角色或读者。
12. 没有重复定义同一事实,也没有同名异义。
13. 数字、单位、术语和时间线一致。
14. 候选之间结构相同,但故事方案可以不同。
15. 任务包指定了数量或字数门槛时同时满足;未指定时不以篇幅论完成。
## 八、交付报告
所有候选过门后才生成比较报告。报告逐案回答:
@ -86,12 +137,13 @@
2. 前十章兑现了什么,暴露了什么限制。
3. 前五十章在哪个事件形成初期高潮。
4. 初期机制如何换规模进入中期和后期。
5. 这套方案最大的收益、代价和失速风险是什么。
5. 阶段状态矩阵是否闭合,转段事件是否成立。
6. 这套方案最大的收益、代价和失速风险是什么。
报告只供用户选择,不宣告胜者。用户没有拍板前,不合并、不落库、不写正文。
## 七、用户授权后的逐章统合
## 九、用户授权后的逐章统合
用户明确要求综合多份候选时,独立生成阶段结束,进入串行统合阶段。统合不再要求来源隔离,但每个代理仍只处理一个当前章节,不能提前读取后续来源章节。详细输入、权威顺序、输出格式与失败边界见 [串行统合合同](serial-merge-contract.md)。
用户明确要求综合多份候选时,独立生成阶段结束,进入串行统合阶段。统合以一级章节为节点,代理必须读取该章完整的三级结构。统合不再要求来源隔离,但每个代理仍只处理一个当前章节,不能提前读取后续来源章节。详细输入、权威顺序、输出格式与失败边界见 [串行统合合同](serial-merge-contract.md)。
串行链的每个节点都要生成 fresh 会话。当前章只有经主会话审定并写入统合候选后,才可作为下一节点的权威前文。未审定的代理输出、分析摘要和舍弃方案不能进入下一节点上下文。

View File

@ -1,10 +1,10 @@
# 候选逐章串行统合合同(design-story-foundation/serial-merge-v1)
# 候选逐章串行统合合同(design-story-foundation/serial-merge-v2)
本合同用于用户明确要求综合多份已完成候选的场景。它只生成一份新的统合候选,不直接修改作品前期设计 SoT,不写正文,不落数据库。
本合同用于用户明确要求综合多份已完成候选的场景。它只生成一份新的统合候选,不直接修改作品前期设计 SoT,不写正文,不落数据库。统合以一级章节为节点;每章含完整三级结构,内容合同见 [章节内容规范](chapter-content-spec.md)。
## 一、串行拓扑
统合严格按冻结目录从前往后执行,一章一个 fresh 高推理代理。当前章审定落盘之前,不启动下一章。每个节点只接收三类作品内容:最新根设定全文、统合候选已经完成的已统合前文、所有来源候选的当前章节。
统合严格按冻结三级树从前往后执行,一个一级章节一个 fresh 高推理代理。当前章审定落盘之前,不启动下一章。每个节点只接收三类作品内容:最新根设定全文、统合候选已经完成的已统合前文、所有来源候选的当前章节(含全部二级、三级标题与条目)。
禁止把来源候选的后续章节提前交给当前代理。禁止复用上一节点会话,防止舍弃方案和未审定分析越过主会话进入后章。代理输出只是建议;主会话完成冲突检查、必要改写和落盘后,落盘版本才成为下一节点输入。
@ -14,7 +14,7 @@
1. 用户最新确认的根设定。
2. 统合候选已经审定落盘的前文章节。
3. 当前五份来源章节中与前两项兼容的高质量设定。
3. 当前来源章节中与前两项兼容的高质量设定。
4. 为补齐因果所需的最小新增连接内容。
来源候选没有投票权。三份写法相同也不能压过根设定,一份写法更完整也可以被采用。当前章可以合并同方向条目、删去重复条目、改写名称与数值以消除冲突,但不能偷偷更换已经确定的故事发动机。
@ -23,9 +23,9 @@
优先保留能直接产生剧情、代价、差异和后续接口的设定。单纯正确但没有故事用途的百科说明降级;只靠旁白成立的强度宣告降级;与前文重复、只换说法的条目删除。
每个保留项至少完成两件事:说清规则或事实;说明它如何通过事件、人物选择、资源损失、对手反应或阶段兑现进入故事。涉及底牌时要同步写清读者在当前阶段能知道的边界。
每个保留项至少完成两件事:说清规则或事实;说明它如何通过事件、人物选择、资源损失、对手反应或阶段兑现进入故事。涉及底牌时要同步写清读者在当前阶段能知道的边界。阶段状态只保留一份:实体卡内写与自身相关的阶段字段,跨实体汇总进第五章矩阵,不得两处各写一套。
不把五案的互斥卖点全部叠加。故事只能有一条主发动机,其他候选的优点只能作为服务主线的机制、角色资产或事件结构进入。新增内容以补缝为限,不另造第六套世界观。
不把多案的互斥卖点全部叠加。故事只能有一条主发动机,其他候选的优点只能作为服务主线的机制、角色资产或事件结构进入。新增内容以补缝为限,不另造第六套世界观。
## 四、当前章输出
@ -36,22 +36,32 @@
主轴、主要取舍、发现的前文冲突及处理方式。
</decision>
<chapter>
## 冻结的当前章标题
## 冻结的当前一级章标题
<!-- 冻结号段 -->
### x.y 冻结的二级标题
> 回答:… 应写:… 不写:… 关联:…
### x.y.z 冻结的三级标题
> 回答:… 应写:… 不写:… 关联:…
- **S001|设定名:** 设定正文。
</chapter>
```
章节只使用一个二级标题,不增设三级标题。编号必须在冻结号段内递增且不重复,每章 10—50 项。篇幅靠规则、事件、代价和边界获得,不靠同义扩写。不得在正文候选中记录代理、模型、统合过程或来源票数。
章节必须完整保留冻结的二级、三级标题与顺序,不增设四级标题,不删并三级章节。无适用内容的三级章节写“不适用及原因”。编号必须在本章号段内递增且不重复。篇幅靠规则、事件、代价和边界获得,不靠同义扩写;数量或字数仅在任务包指定时作为约束。不得在正文候选中记录代理、模型、统合过程或来源票数。
## 五、主会话审定
主会话逐项检查根设定覆盖、与前文的名称和数值一致性、因果闭合、阶段披露、故事负荷及 AI 模板腔。发现冲突时以最小改动修正当前章,不能为了保留当前好点子反向改掉已审定前文;确实需要改前文时必须停下来向用户说明,而不是自行回写。
主会话逐项检查根设定覆盖、与前文的名称和数值一致性、因果闭合、三线披露(世界事实、角色知情、读者披露)、阶段状态归属、故事负荷及 AI 模板腔。发现冲突时以最小改动修正当前章,不能为了保留当前好点子反向改掉已审定前文;确实需要改前文时必须停下来向用户说明,而不是自行回写。
机械结构通过、语义冲突清零后,主会话才将 `<chapter>` 内容写入统合候选。`<decision>` 留在临时审计文件,不进入作品文档。下一节点读取的是落盘后的完整统合候选,不读取原始输出。
## 六、完成条件
全部章节完成后,统合候选必须通过与来源候选相同的目录、号段、数量、篇幅和占位符门禁,再进行一次全篇交叉检查。重点检查早期承诺是否在后章换尺度延续,人物与组织是否串位,能力成本是否被后章绕开,以及新增宇宙格局是否遵守既定冲突升级顺序。
全部章节完成后,统合候选必须通过与来源候选相同的机械门(三级标题树同构、号段、编号唯一递增、占位符残留),任务包如指定数量或字数门槛由主会话人工核对,再按 candidate-contract 的完成判定做一次全篇语义复核。重点检查早期承诺是否在后章换尺度延续,人物与组织是否串位,能力成本是否被后章绕开,阶段矩阵与实体卡阶段字段是否互相一致,以及新增宇宙格局是否遵守既定冲突升级顺序。
统合完成仍不等于用户定稿。未经用户确认,不合并进前期设计 SoT,不删除五份来源候选,不进入 `plan-story`。
统合完成仍不等于用户定稿。未经用户确认,不合并进前期设计 SoT,不删除来源候选,不进入 `plan-story`。

View File

@ -12,7 +12,7 @@ import sys
H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
H3_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
DEEP_HEADING_RE = re.compile(r"^#{4,6}\s+", re.MULTILINE)
RANGE_RE = re.compile(r"<!--\s*S(\d+)\s*[-—–]\s*S(\d+)\s*-->")
SETTING_RE = re.compile(r"^\s*[-*]\s+(?:\*\*)?(S\d{3,})(?=[^\d])", re.MULTILINE)
CHAPTER_RE = re.compile(r"<chapter>\s*(.*?)\s*</chapter>", re.DOTALL)
@ -120,10 +120,16 @@ def build_packet(args: argparse.Namespace) -> None:
def run_luna(args: argparse.Namespace) -> None:
requirements = []
if args.min_settings is not None and args.max_settings is not None:
requirements.append(f"选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定")
if args.min_chars is not None:
requirements.append(f"章节有效字符不少于 {args.min_chars}")
requirement_sentence = f"{','.join(requirements)}。" if requirements else ""
prompt = (
f"你是当前第 {args.index} 章的独立串行统合代理。完整阅读合同与输入包,"
f"只处理“{args.heading}”。选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定,"
f"章节有效字符不少于 {args.min_chars}。严格遵守根设定与已统合前文,不读取或猜测后续章节。"
f"只处理“{args.heading}”。{requirement_sentence}"
"严格遵守根设定与已统合前文,不读取或猜测后续章节。"
"输出必须且只能包含 <decision> 与 <chapter> 两个区块,不加代码围栏。"
)
command = [
@ -171,9 +177,9 @@ def parse_raw(
heading: str,
range_start: int,
range_end: int,
min_settings: int,
max_settings: int,
min_chars: int,
min_settings: int | None = None,
max_settings: int | None = None,
min_chars: int | None = None,
) -> tuple[str, str, list[int]]:
chapter_matches = CHAPTER_RE.findall(raw)
decision_matches = DECISION_RE.findall(raw)
@ -184,8 +190,8 @@ def parse_raw(
chapter_sections = sections(chapter)
if len(chapter_sections) != 1 or chapter_sections[0].heading != heading:
raise SerialMergeError(f"章节标题必须为“{heading}”且不能出现其他二级标题")
if H3_RE.search(chapter):
raise SerialMergeError("章节草案出现三级或更深标题")
if DEEP_HEADING_RE.search(chapter):
raise SerialMergeError("章节草案出现四级或更深标题")
found_ranges = RANGE_RE.findall(chapter)
normalized_ranges = [(int(start), int(end)) for start, end in found_ranges]
expected_range = (range_start, range_end)
@ -198,11 +204,11 @@ def parse_raw(
raise SerialMergeError("设定编号必须递增且不重复")
if any(value < range_start or value > range_end for value in ids):
raise SerialMergeError("设定编号越出冻结号段")
if not min_settings <= len(ids) <= max_settings:
if min_settings is not None and max_settings is not None and not min_settings <= len(ids) <= max_settings:
raise SerialMergeError(
f"当前章 {len(ids)} 项,要求 {min_settings}—{max_settings} 项"
)
if effective_chars(chapter) < min_chars:
if min_chars is not None and effective_chars(chapter) < min_chars:
raise SerialMergeError(
f"当前章 {effective_chars(chapter)} 有效字符,低于 {min_chars}"
)
@ -238,9 +244,9 @@ def shared_output_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--heading", required=True)
parser.add_argument("--range-start", type=int, required=True)
parser.add_argument("--range-end", type=int, required=True)
parser.add_argument("--min-settings", type=int, default=10)
parser.add_argument("--max-settings", type=int, default=50)
parser.add_argument("--min-chars", type=int, default=4200)
parser.add_argument("--min-settings", type=int, default=None)
parser.add_argument("--max-settings", type=int, default=None)
parser.add_argument("--min-chars", type=int, default=None)
def parser() -> argparse.ArgumentParser:
@ -264,9 +270,9 @@ def parser() -> argparse.ArgumentParser:
run.add_argument("--cwd", type=Path, required=True)
run.add_argument("--model", default="catproxy-openai/gpt-5.6-luna")
run.add_argument("--thinking", default="max")
run.add_argument("--min-settings", type=int, default=12)
run.add_argument("--max-settings", type=int, default=18)
run.add_argument("--min-chars", type=int, default=4200)
run.add_argument("--min-settings", type=int, default=None)
run.add_argument("--max-settings", type=int, default=None)
run.add_argument("--min-chars", type=int, default=None)
run.add_argument("--timeout", type=int, default=1200)
run.set_defaults(handler=run_luna)

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""design-story-foundation 与消费者、项目入口的离线合同测试。"""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[4]
SKILL = (ROOT / ".claude/skills/design-story-foundation/SKILL.md").read_text(encoding="utf-8")
REFERENCE = (ROOT / ".claude/skills/design-story-foundation/references/candidate-contract.md").read_text(encoding="utf-8")
SERIAL_MERGE = (ROOT / ".claude/skills/design-story-foundation/references/serial-merge-contract.md").read_text(encoding="utf-8")
PLANNER = (ROOT / ".claude/agents/planner.md").read_text(encoding="utf-8")
AGENTS = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
CHAINS = (ROOT / "meta/chains/README.md").read_text(encoding="utf-8")
class SettingInitContractTest(unittest.TestCase):
def test_single_purpose_and_handoff_are_explicit(self) -> None:
self.assertIn("正式规划前", SKILL)
self.assertIn("不写正文、不落库、不替用户定稿", SKILL)
self.assertIn("用户明确说“前期设计定稿”", SKILL)
def test_root_and_rhythm_contracts_are_present(self) -> None:
for phrase in ("30—60", "前三章", "前十章", "前五十章", "作者掌握的总设定"):
self.assertIn(phrase, SKILL)
def test_candidate_quantity_is_a_range(self) -> None:
self.assertIn("10—50", SKILL)
self.assertIn("不少于 100 项", SKILL)
self.assertNotIn("每章固定 10 项", SKILL)
def test_independent_generation_contract_is_frozen(self) -> None:
self.assertIn("candidateId", REFERENCE)
self.assertIn("只有 `candidateId` 和 `outputPath`", REFERENCE)
self.assertIn("不得搜索候选目录", REFERENCE)
def test_serial_merge_is_strictly_chapter_ordered(self) -> None:
self.assertIn("一章一个 fresh 高推理代理", SERIAL_MERGE)
self.assertIn("最新根设定", SERIAL_MERGE)
self.assertIn("已统合前文", SERIAL_MERGE)
self.assertIn("当前章节", SERIAL_MERGE)
self.assertIn("不能按票数机械取多数", SKILL)
def test_planner_and_inventory_expose_the_skill(self) -> None:
self.assertIn("`design-story-foundation`", PLANNER)
self.assertIn("`design-story-foundation`", AGENTS)
self.assertIn("setting_init 作品设定初始化", CHAINS)
self.assertIn("候选不落库、不进 Canonical", CHAINS)
if __name__ == "__main__":
unittest.main()

View File

@ -82,12 +82,30 @@ class SerialMergeTest(unittest.TestCase):
<chapter>
## 二、世界
<!-- S051-S100 -->
### 2.1 稳定世界规则
> 回答:世界底层法则。
- **S051|规则一:** 规则有明确条件、事件用途、失败代价以及后续阶段接口,不靠旁白成立。
- **S052|规则二:** 另一条规则与前文兼容,并通过人物行动和资源损失进入故事。
</chapter>"""
_, _, ids = parse_raw(raw, "二、世界", 51, 100, 2, 3, 20)
self.assertEqual([51, 52], ids)
def test_raw_output_rejects_fourth_level_heading(self) -> None:
raw = """<decision>保留规则。</decision>
<chapter>
## 二、世界
<!-- S051-S100 -->
### 2.1 稳定世界规则
#### 不允许的细分
- **S051|规则一:** 规则有明确条件、事件用途、失败代价以及后续阶段接口。
</chapter>"""
with self.assertRaises(SerialMergeError):
parse_raw(raw, "二、世界", 51, 100)
if __name__ == "__main__":
unittest.main()

View File

@ -1,51 +1,52 @@
#!/usr/bin/env python3
"""前期设计候选校验器的离线测试。"""
"""前期设计候选校验器的离线测试:只覆盖机械门禁自身的正确性。"""
import json
from pathlib import Path
import tempfile
import unittest
from validate_candidates import ValidationConfig, validate_candidates, validate_root
TREE = json.loads((Path(__file__).with_name("candidate_tree.json")).read_text(encoding="utf-8"))
BLOCK = "> 回答:本节回答什么。\n> 应写:本节写什么。\n> 不写:本节不写什么。\n> 关联:其他节。\n"
ITEM = "- **S{sid:03d}|条目:** 这是带条件、代价和剧情用途的完整设定内容。"
def make_candidate(second_heading: str = "二、资源规则", out_of_range: bool = False) -> str:
first_ids = range(1, 3)
second_ids = (101, 52) if out_of_range else range(51, 53)
first = "\n".join(f"- **S{value:03d}|规则:** 这一条有明确条件、代价和剧情用途。" for value in first_ids)
second = "\n".join(f"- **S{value:03d}|规则:** 这一条有明确条件、代价和剧情用途。" for value in second_ids)
return f"""# 候选
## 目录
def chapter_bands() -> list[tuple[int, int]]:
"""按每章实际条目数切分号段,保证全局连续编号都落在本章号段内。"""
bands = []
cursor = 1
for chapter in TREE["chapters"]:
count = sum(len(section["units"]) for section in chapter["sections"])
bands.append((cursor, cursor + count - 1))
cursor += count
return bands
- 第一章
- 第二章
## 一、故事发动机
BANDS = chapter_bands()
<!-- S001-S050 -->
{first}
## {second_heading}
<!-- S051-S100 -->
{second}
"""
def make_candidate() -> str:
parts = ["# 候选\n\n## 目录\n\n- 章节列表\n"]
counter = 1
for index, chapter in enumerate(TREE["chapters"]):
start, end = BANDS[index]
parts.append(f"## {chapter['heading']}\n\n<!-- S{start:03d}-S{end:03d} -->\n")
for section in chapter["sections"]:
parts.append(f"### {section['heading']}\n\n{BLOCK}")
for unit in section["units"]:
parts.append(f"### {unit}\n\n{BLOCK}")
parts.append(ITEM.format(sid=counter))
counter += 1
return "\n".join(parts)
class CandidateValidatorTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.config = ValidationConfig(
min_candidates=2,
min_settings=4,
min_section_settings=2,
max_section_settings=3,
min_chars=0,
min_item_chars=10,
)
self.config = ValidationConfig(min_candidates=2, min_item_chars=10)
def tearDown(self) -> None:
self.temp_dir.cleanup()
@ -55,27 +56,34 @@ class CandidateValidatorTest(unittest.TestCase):
path.write_text(content, encoding="utf-8")
return path
def codes(self, *contents: str) -> set[str]:
paths = [self.write(f"c{index}.md", content) for index, content in enumerate(contents)]
_, problems = validate_candidates(paths, self.config)
return {problem.code for problem in problems}
def test_two_same_shape_candidates_pass(self) -> None:
paths = [self.write("a.md", make_candidate()), self.write("b.md", make_candidate())]
reports, problems = validate_candidates(paths, self.config)
self.assertEqual(2, len(reports))
self.assertEqual([], problems)
self.assertEqual(set(), self.codes(make_candidate(), make_candidate()))
def test_heading_drift_fails_group(self) -> None:
paths = [
self.write("a.md", make_candidate()),
self.write("b.md", make_candidate(second_heading="二、人物关系")),
]
_, problems = validate_candidates(paths, self.config)
self.assertIn("CANDIDATE_STRUCTURE_MISMATCH", {problem.code for problem in problems})
def test_missing_unit_heading_fails(self) -> None:
broken = make_candidate().replace("### 1.1.3 同类作品中的差异\n", "")
self.assertIn("TREE_MISMATCH", self.codes(make_candidate(), broken))
def test_out_of_range_and_order_are_rejected(self) -> None:
paths = [self.write("a.md", make_candidate()), self.write("b.md", make_candidate(out_of_range=True))]
_, problems = validate_candidates(paths, self.config)
codes = {problem.code for problem in problems}
self.assertIn("SETTING_ID_OUT_OF_RANGE", codes)
def test_deep_heading_fails(self) -> None:
broken = make_candidate().replace(
"### 1.1.1 题材、载体与目标读者\n", "### 1.1.1 题材、载体与目标读者\n\n#### 细分\n"
)
self.assertIn("CANDIDATE_STRUCTURE", self.codes(make_candidate(), broken))
def test_duplicate_and_out_of_range_ids_fail(self) -> None:
broken = make_candidate().replace("S002|条目:", "S001|条目:").replace("S003|条目:", "S002|条目:")
codes = self.codes(make_candidate(), broken)
self.assertIn("SETTING_ID_DUPLICATE", codes)
self.assertIn("SETTING_ORDER", codes)
def test_placeholder_fails(self) -> None:
broken = make_candidate().replace("完整设定内容", "TODO")
self.assertIn("CANDIDATE_PLACEHOLDER", self.codes(make_candidate(), broken))
def test_root_contract_accepts_clean_items(self) -> None:
root_doc = self.write(
"root.md",
@ -89,8 +97,7 @@ class CandidateValidatorTest(unittest.TestCase):
## 二、定盘
""",
)
problems = validate_root(root_doc, self.config)
self.assertEqual([], problems)
self.assertEqual([], validate_root(root_doc, self.config))
def test_root_rejects_process_language(self) -> None:
root_doc = self.write(
@ -104,8 +111,7 @@ class CandidateValidatorTest(unittest.TestCase):
## 二、定盘
""",
)
problems = validate_root(root_doc, self.config)
self.assertIn("ROOT_PROCESS_LEAK", {problem.code for problem in problems})
self.assertIn("ROOT_PROCESS_LEAK", {problem.code for problem in validate_root(root_doc, self.config)})
if __name__ == "__main__":

View File

@ -1,5 +1,10 @@
#!/usr/bin/env python3
"""校验作品设定初始化的根设定与同构候选,不调用模型、不访问数据库。"""
"""校验作品设定初始化的根设定与同构候选。
只检查机械易错且人眼难以抽查的项:三级标题树同构、S 编号唯一递增不越号段、
占位符残留、根设定格式与句长。内容是否完整由语义复核按完成判定核对,
本脚本不做数量、字数或内容质量门槛。
"""
from __future__ import annotations
@ -11,26 +16,23 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
NESTED_HEADING_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
TREE_PATH = Path(__file__).with_name("candidate_tree.json")
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
RANGE_RE = re.compile(r"<!--\s*S(\d+)\s*[-—–]\s*S(\d+)\s*-->")
SETTING_RE = re.compile(r"^\s*[-*]\s+(?:\*\*)?(S\d{3,})(?=[^\d])", re.MULTILINE)
ROOT_ITEM_RE = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*(.+)$")
PLACEHOLDER_RE = re.compile(
r"(?:\bTODO\b|\bTBD\b|\[待填\]|待补充|待生成|在此填写|PLACEHOLDER)",
r"(?<![A-Za-z0-9])(?:TODO|TBD|PLACEHOLDER)(?![A-Za-z0-9])|\[待填\]|待补充|待生成|在此填写",
re.IGNORECASE,
)
ROOT_ITEM_RE = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*(.+)$")
ROOT_H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
ROOT_NESTED_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
PROCESS_TERMS = ("Claude", "claude", "子代理", "候选文档", "提示词", "工具调用", "设计过程", "方案来源")
@dataclass(frozen=True)
class ValidationConfig:
min_candidates: int = 1
min_settings: int = 100
min_section_settings: int = 10
max_section_settings: int = 50
min_chars: int = 50000
min_item_chars: int = 20
root_min_chars: int = 30
root_max_chars: int = 60
@ -54,31 +56,40 @@ class SectionReport:
@dataclass(frozen=True)
class CandidateReport:
file: str
effective_chars: int
setting_count: int
headings: tuple[str, ...]
sections: tuple[SectionReport, ...]
def effective_chars(text: str) -> int:
"""按非空白字符计数,避免用空行填充篇幅。"""
return len(re.sub(r"\s+", "", text))
def visible_chars(text: str) -> int:
"""去掉常见 Markdown 标记后统计可见字符。"""
"""去掉常见 Markdown 标记后统计非空白可见字符。"""
cleaned = re.sub(r"[*_`#>\[\]()]", "", text)
return effective_chars(cleaned)
return len(re.sub(r"\s+", "", cleaned))
def _problem(code: str, path: Path, message: str) -> Problem:
return Problem(code=code, file=str(path), message=message)
def load_tree(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def expected_heading_sequence(tree: dict) -> list[str]:
"""按冻结树返回 章→节→单元 的扁平标题序列。"""
sequence: list[str] = []
for chapter in tree["chapters"]:
sequence.append(chapter["heading"])
for section in chapter["sections"]:
sequence.append(section["heading"])
sequence.extend(section["units"])
return sequence
def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
problems: list[Problem] = []
text = path.read_text(encoding="utf-8")
headings = list(H2_RE.finditer(text))
headings = list(ROOT_H2_RE.finditer(text))
root_index = next((index for index, match in enumerate(headings) if "根设定" in match.group(1)), None)
if root_index is None:
return [_problem("ROOT_SECTION_MISSING", path, "未找到包含“根设定”的二级标题")]
@ -87,7 +98,7 @@ def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
end = headings[root_index + 1].start() if root_index + 1 < len(headings) else len(text)
body = text[start:end]
if NESTED_HEADING_RE.search(body):
if ROOT_NESTED_RE.search(body):
problems.append(_problem("ROOT_STRUCTURE", path, "根设定内部出现三级或更深标题"))
for term in PROCESS_TERMS:
@ -125,38 +136,73 @@ def validate_root(path: Path, config: ValidationConfig) -> list[Problem]:
return problems
def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateReport, list[Problem]]:
def _parse_candidate(path: Path, tree: dict, config: ValidationConfig) -> tuple[CandidateReport, list[Problem]]:
text = path.read_text(encoding="utf-8")
problems: list[Problem] = []
h2_matches = list(H2_RE.finditer(text))
headings = tuple(match.group(1).strip() for match in h2_matches)
if not h2_matches:
problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选没有二级标题"))
if NESTED_HEADING_RE.search(text):
problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选出现三级或更深标题,违反统一两级结构"))
if PLACEHOLDER_RE.search(text):
problems.append(_problem("CANDIDATE_PLACEHOLDER", path, "候选仍含待填占位文字"))
sections: list[SectionReport] = []
headings = [(len(match.group(1)), match.group(2).strip(), match.start(), match.end()) for match in HEADING_RE.finditer(text)]
h1_count = sum(1 for level, _, _, _ in headings if level == 1)
if h1_count != 1:
problems.append(_problem("CANDIDATE_STRUCTURE", path, f"候选应有且只有一个一级标题,实际 {h1_count} 个"))
deep = [heading for level, heading, _, _ in headings if level >= 4]
if deep:
problems.append(_problem("CANDIDATE_STRUCTURE", path, f"候选出现四级或更深标题:{deep[0]}"))
h2_list = [(heading, start, end) for level, heading, start, end in headings if level == 2]
toc_seen = False
chapter_index = 0
all_ids: list[int] = []
previous_range_end = 0
sections: list[SectionReport] = []
chapter_titles = [chapter["heading"] for chapter in tree["chapters"]]
for index, heading_match in enumerate(h2_matches):
start = heading_match.end()
end = h2_matches[index + 1].start() if index + 1 < len(h2_matches) else len(text)
body = text[start:end]
ranges = RANGE_RE.findall(body)
setting_matches = list(SETTING_RE.finditer(body))
for position, (heading, start, end) in enumerate(h2_list):
body_end = h2_list[position + 1][1] if position + 1 < len(h2_list) else len(text)
body = text[end:body_end]
if not ranges and not setting_matches and heading_match.group(1).strip() == "目录":
if heading == "目录":
toc_seen = True
if re.search(r"^###\s+", body, re.MULTILINE):
problems.append(_problem("CANDIDATE_STRUCTURE", path, "目录下不得出现三级标题"))
continue
if len(ranges) != 1:
if chapter_index >= len(chapter_titles):
problems.append(_problem("TREE_MISMATCH", path, f"多出章节标题:{heading}"))
continue
expected_title = chapter_titles[chapter_index]
if heading != expected_title:
problems.append(
_problem("SECTION_RANGE", path, f"章节“{heading_match.group(1).strip()}”必须且只能有一个号段标记")
_problem("TREE_MISMATCH", path, f"第 {chapter_index + 1} 章应为“{expected_title}”,实际为“{heading}”")
)
chapter_index += 1
continue
chapter_index += 1
expected_subs: list[str] = []
for section in tree["chapters"][chapter_index - 1]["sections"]:
expected_subs.append(section["heading"])
expected_subs.extend(section["units"])
actual_subs = [sub_heading for level, sub_heading, sub_start, _ in headings if level == 3 and start < sub_start < body_end]
if actual_subs != expected_subs:
expected_set = set(expected_subs)
missing = [item for item in expected_subs if item not in set(actual_subs)]
extra = [item for item in actual_subs if item not in expected_set]
detail = []
if missing:
detail.append(f"缺 {len(missing)} 个(首个:{missing[0]})")
if extra:
detail.append(f"多 {len(extra)} 个(首个:{extra[0]})")
if not detail:
detail.append("顺序与冻结树不一致")
problems.append(_problem("TREE_MISMATCH", path, f"章节“{heading}”标题树漂移:{';'.join(detail)}"))
ranges = RANGE_RE.findall(body)
if len(ranges) != 1:
problems.append(_problem("SECTION_RANGE", path, f"章节“{heading}”必须且只能有一个号段标记"))
continue
range_start, range_end = (int(value) for value in ranges[0])
if range_start > range_end:
problems.append(_problem("SECTION_RANGE", path, f"章节号段倒置:S{range_start:03d}-S{range_end:03d}"))
@ -164,10 +210,9 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
problems.append(_problem("SECTION_RANGE", path, "章节号段未按顺序递增或发生重叠"))
previous_range_end = max(previous_range_end, range_end)
setting_matches = list(SETTING_RE.finditer(body))
ids = [int(match.group(1)[1:]) for match in setting_matches]
all_ids.extend(ids)
if ids != sorted(ids):
problems.append(_problem("SETTING_ORDER", path, f"章节“{heading_match.group(1).strip()}”的编号未递增"))
for setting_id in ids:
if not range_start <= setting_id <= range_end:
problems.append(
@ -177,17 +222,6 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
f"S{setting_id:03d} 不在章节号段 S{range_start:03d}-S{range_end:03d} 内",
)
)
count = len(ids)
if not config.min_section_settings <= count <= config.max_section_settings:
problems.append(
_problem(
"SECTION_SETTING_COUNT",
path,
f"章节“{heading_match.group(1).strip()}”有 {count} 项,要求 {config.min_section_settings}—{config.max_section_settings}",
)
)
for item_index, setting_match in enumerate(setting_matches):
item_end = setting_matches[item_index + 1].start() if item_index + 1 < len(setting_matches) else len(body)
item_text = body[setting_match.start():item_end]
@ -195,36 +229,32 @@ def _parse_candidate(path: Path, config: ValidationConfig) -> tuple[CandidateRep
problems.append(
_problem("SETTING_ITEM_TOO_SHORT", path, f"{setting_match.group(1)} 内容过短,疑似只有标题或占位句")
)
sections.append(
SectionReport(
heading=heading_match.group(1).strip(),
range_start=range_start,
range_end=range_end,
setting_count=count,
SectionReport(heading=heading, range_start=range_start, range_end=range_end, setting_count=len(ids))
)
if chapter_index < len(chapter_titles):
problems.append(
_problem(
"TREE_MISMATCH",
path,
f"缺少章节:{ '、'.join(chapter_titles[chapter_index:]) }",
)
)
if toc_seen and chapter_index == 0:
problems.append(_problem("CANDIDATE_STRUCTURE", path, "候选只有目录,没有内容章节"))
duplicates = sorted({setting_id for setting_id in all_ids if all_ids.count(setting_id) > 1})
if duplicates:
display = ", ".join(f"S{setting_id:03d}" for setting_id in duplicates[:10])
problems.append(_problem("SETTING_ID_DUPLICATE", path, f"全文编号重复:{display}"))
char_count = effective_chars(text)
if char_count < config.min_chars:
problems.append(
_problem("CANDIDATE_LENGTH", path, f"有效字符 {char_count},低于门槛 {config.min_chars}")
)
if len(all_ids) < config.min_settings:
problems.append(
_problem("CANDIDATE_SETTING_COUNT", path, f"全文共 {len(all_ids)} 项设定,低于门槛 {config.min_settings}")
)
if any(later <= earlier for earlier, later in zip(all_ids, all_ids[1:])):
problems.append(_problem("SETTING_ORDER", path, "全文 S 编号必须严格递增"))
report = CandidateReport(
file=str(path),
effective_chars=char_count,
setting_count=len(all_ids),
headings=headings,
headings=tuple(heading for heading, _, _ in h2_list),
sections=tuple(sections),
)
return report, problems
@ -234,6 +264,7 @@ def validate_candidates(
paths: Iterable[Path],
config: ValidationConfig,
root_doc: Path | None = None,
tree_path: Path = TREE_PATH,
) -> tuple[list[CandidateReport], list[Problem]]:
candidate_paths = sorted((Path(path) for path in paths), key=lambda item: str(item))
problems: list[Problem] = []
@ -253,19 +284,18 @@ def validate_candidates(
else:
problems.extend(validate_root(root_doc, config))
tree = load_tree(tree_path)
for path in candidate_paths:
if not path.is_file():
problems.append(_problem("CANDIDATE_FILE_MISSING", path, "候选文件不存在"))
continue
report, file_problems = _parse_candidate(path, config)
report, file_problems = _parse_candidate(path, tree, config)
reports.append(report)
problems.extend(file_problems)
if reports:
if len(reports) > 1:
baseline = reports[0]
baseline_shape = tuple(
(section.heading, section.range_start, section.range_end) for section in baseline.sections
)
baseline_shape = tuple((section.heading, section.range_start, section.range_end) for section in baseline.sections)
for report in reports[1:]:
shape = tuple((section.heading, section.range_start, section.range_end) for section in report.sections)
if report.headings != baseline.headings or shape != baseline_shape:
@ -284,13 +314,10 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("files", nargs="+", type=Path, help="候选 Markdown 文件")
parser.add_argument("--root-doc", type=Path, help="含根设定的唯一前期设计文档")
parser.add_argument("--min-candidates", type=int, default=1)
parser.add_argument("--min-settings", type=int, default=100)
parser.add_argument("--min-section-settings", type=int, default=10)
parser.add_argument("--max-section-settings", type=int, default=50)
parser.add_argument("--min-chars", type=int, default=50000)
parser.add_argument("--min-item-chars", type=int, default=20)
parser.add_argument("--root-min-chars", type=int, default=30)
parser.add_argument("--root-max-chars", type=int, default=60)
parser.add_argument("--tree", type=Path, default=TREE_PATH, help="冻结三级树 JSON")
parser.add_argument("--json", action="store_true", help="输出稳定 JSON")
return parser
@ -298,22 +325,16 @@ def _build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
if args.min_section_settings > args.max_section_settings:
parser.error("--min-section-settings 不能大于 --max-section-settings")
if args.root_min_chars > args.root_max_chars:
parser.error("--root-min-chars 不能大于 --root-max-chars")
config = ValidationConfig(
min_candidates=args.min_candidates,
min_settings=args.min_settings,
min_section_settings=args.min_section_settings,
max_section_settings=args.max_section_settings,
min_chars=args.min_chars,
min_item_chars=args.min_item_chars,
root_min_chars=args.root_min_chars,
root_max_chars=args.root_max_chars,
)
reports, problems = validate_candidates(args.files, config, args.root_doc)
reports, problems = validate_candidates(args.files, config, args.root_doc, args.tree)
status = "ok" if not problems else "invalid"
code = "SETTING_INIT_OK" if not problems else "SETTING_INIT_VALIDATION_FAILED"
payload = {
@ -328,7 +349,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(f"{code}: {len(reports)} 份候选,{len(problems)} 个问题")
for report in reports:
print(f"- {report.file}: {report.setting_count} 项,{report.effective_chars} 有效字符")
print(f"- {report.file}: {report.setting_count} 项")
for problem in problems:
print(f"[{problem.code}] {problem.file}: {problem.message}")
return 0 if not problems else 1