框架: 技能按动作-对象重组 + 先审后入创作闭环

一、技能重组(动作-对象命名)
- 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为
  clean-book-text/decide-candidate/write-next-chapter/access-database/
  check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持)
- agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名

二、先审后入创作闭环(本次核心)
正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交",
DB 级兜底,编排层跳步即被硬拒。
- candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链
- fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量,
  模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本
- projection_registry.py + example_projection_run(107):投影登记与恢复
- acceptance_state.py:接受前置实时状态重读
- lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格
- DDL 105:example_candidate 增 semantic_status/semantic_report_sha256
- write_canonical.accept:语义兜底+同事务合并增量+登记投影;
  run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链
- claude_runtime:兼容新 CLI modelUsage 信息字段

三、审查修复(独立子代理四维审查后)
- 事实增量 propose→approve 翻态正道,不撞唯一键
- 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记
- 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理

测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。
创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
This commit is contained in:
zizi 2026-08-14 10:24:08 +08:00
parent cfa46e6e7a
commit b0bc7a8745
251 changed files with 59398 additions and 1701 deletions

View File

@ -46,12 +46,14 @@
- 来源要能回到不可变位置和内容哈希,不能只写“某章某段”。
- 会随剧情变化的事实必须带生效章区间,不能用终态覆盖历史时点。
> 边界说明:正文提交时随事务登记的**事实增量账本**(`example_fact_delta` / `example_fact_ledger`,见 [08-数据权威](08-数据权威与可视化领域.md) §10)记录的是"哪次提交批准了哪条类型化变更"的变更流证据,绑正文块 revision;**实体当前事实**(人物位置、关系、知情范围等的现行态)的 owner 仍是本领域。增量账本不是实体事实的第二事实源,二者是"变更流水"与"现行状态"的分工。
## 4. 实体与作品关系
- 作品侧只引用实体 `id`,不复制实体的详细字段。
- 作品级设定可以定义创作边界和主题,但不得成为人物、地点、事件的第二事实源。
- 正文保存或候选接受后可以产生实体草稿;**草稿不进入后续正式上下文**。
- 草稿到正式是一次**库内状态流转**:由用户确认(`confirm`)把草稿从 `pending` 翻成 `confirmed`,并落出正式表 `active` 行、`revision` 加一。没有用户确认,草稿不会自己变正式。
- 草稿到正式是一次**库内状态流转**:由用户确认(`decide-candidate`)把草稿从 `pending` 翻成 `confirmed`,并落出正式表 `active` 行、`revision` 加一。没有用户确认,草稿不会自己变正式。
- 实体的演进历史由库与 Git 共同留痕(库留当前合同与必要时态区间,Git 留 DDL、代码与变更历史),不在实体行里另写一份不可校验的历史叙述副本。
## 5. 拆书与知识草稿入口
@ -74,7 +76,7 @@
- **灾备导出**:正式层可导出,配合数据库备份用于恢复。
- 参考作品的出处与导入信息以 `example_reference_work` / `muse_knowledge_document` 为准(领域索引 §9:不做多租户授权机制,96 授权快照不启用)。
> 注意区分两个“升格”:本节是**作品面实体升格**——把参考书的人物/关系抽进库的正式层。06 质量与复利领域的“经验升格”是另一件事——把写法经验从写法→范式→Skill 逐级固化。两者只是都叫“升格”,对象和去向完全不同,不要混为一谈。作品面升格对应实现:upgrade skill 加 `db/ddl/94-example作品面升格.sql`。
> 注意区分两个“升格”:本节是**作品面实体升格**——把参考书的人物/关系抽进库的正式层。06 质量与复利领域的“经验升格”是另一件事——把写法经验从写法→范式→Skill 逐级固化。两者只是都叫“升格”,对象和去向完全不同,不要混为一谈。作品面升格对应实现:`extract-work-knowledge`、`maintain-work-extraction` 加 `db/ddl/94-example作品面升格.sql`。
## 7. 关系与状态
@ -113,7 +115,7 @@
- 义务对象:`meta/schemas/` 登记了 演变历程 的六个型——`character` / `event` / `faction` / `location` / `item` / `power_system`。未登记 演变历程 的型(如 `narrative_state`、`character_relation`)不受此义务约束。
- 闭环定义:一条设定的“已发生台阶(演变历程)”与“未来计划”合起来必须覆盖 登场 → … → 结局。即有明确的 登场 台阶,并有声明的 结局 方向——未来记在该型的未来计划字段,已发生的结局记在 演变历程(周期=结局)。“有终”指计划明写这条设定如何收场(角色的结局、力量体系的退场或湮灭、物品的归属),不是只有成长台阶。
- 机械执行时点:
- **入库硬门禁**:设定草稿首次经 `confirm` 成为正式行时(draft→canonical 确认路径,归 `confirm` skill),校验计划弧线有始有终,缺一则确认失败、不落正式行。机械失败不可被 Agent 主观覆判(与 [06-质量与复利领域](06-质量与复利领域.md) 质量链一致)。弧线的合法变更只走用户确认的正式修订(`revision` 加一)或带审计的 `retired`(§3 三态),这两条不是绕过门禁。
- **入库硬门禁**:设定草稿首次经 `decide-candidate` 成为正式行时(draft→canonical 确认路径),校验计划弧线有始有终,缺一则确认失败、不落正式行。机械失败不可被 Agent 主观覆判(与 [06-质量与复利领域](06-质量与复利领域.md) 质量链一致)。弧线的合法变更只走用户确认的正式修订(`revision` 加一)或带审计的 `retired`(§3 三态),这两条不是绕过门禁。
- **完本硬门禁**:作品状态转 完本(`completed` 流转归 [01-作品领域](01-作品领域.md))前,校验所有义务设定已在 演变历程 落到 结局(实现收口)或经 `retired` 带审计;存在 未收口 则不得转 完本。
- **常设不变量**:台账(②)持续运行,把 未被碰/未收口 项曝为创作健康度项(与 06 长期验证轴一致),违反即报警。
@ -144,10 +146,10 @@
### 后果
- `confirm` 路径新增一项入库门禁:设定首次确认必须带 有始有终 的计划弧线,否则不落正式行。
- `decide-candidate` 路径新增一项入库门禁:设定首次确认必须带 有始有终 的计划弧线,否则不落正式行。
- 作品新增一个全书常设视图(台账),由 08 只读看板渲染;完本新增一项实现收口硬门禁(流转归 01 作品领域,校验归本领域)。
- 演变历程的消费语义不变:续写仍不给 演变历程 全线(专题-07 §5 与其验收条款 6);台账是全书健康度视图,不改逐章上下文注入。
- 待建:`confirm` 的计划弧线门禁、台账视图、完本收口门禁均未建成;建成前闭环不被机械校验(登记于本文件 附·待建)。
- 待建:`decide-candidate` 的计划弧线门禁、台账视图、完本收口门禁均未建成;建成前闭环不被机械校验(登记于本文件 附·待建)。
## 9. 检索
@ -164,7 +166,7 @@
2. 同一事实只有一个 owner 行,不要求同时维护 Markdown 与 JSON 两份人工事实。
3. 按目标章冻结上下文时,不会读取该章之后才成立的状态。
4. 实体草稿不会静默进入正式创作上下文(可机械验证:未被用户确认的草稿不出现在冻结的正式上下文里)。
5. 参考书导入产生的实体草稿与正文产生的实体草稿走同一条确认链(可机械验证:两类草稿的确认都经 `confirm` 触发、都产生正式表行)。
5. 参考书导入产生的实体草稿与正文产生的实体草稿走同一条确认链(可机械验证:两类草稿的确认都经 `decide-candidate` 触发、都产生正式表行)。
6. 向量召回命中后回读的是库行而非文件(可机械验证:命中结果带库行 id 与内容哈希,且哈希与库一致)。
7. 作品面入库管线的每一次覆写都有审计记录,可追到来源原文。
8. 义务型设定草稿首次确认时,计划弧线缺 登场 或 结局 方向则不落正式行(可机械验证:任一义务型正式设定行都能回读出含 登场 台阶与 结局 方向的弧线)。
@ -189,7 +191,7 @@
可承认的现有实现(已建成,引用即可):
- 拆书与作品面实体入库管线(抽取、别名判重、跨窗留档、覆写审计)。
- review-cards 三角色审核(番茄作家 / 起点作家 / 主编)作为拆书常设步骤。
- review-knowledge-cards 三角色审核(番茄作家 / 起点作家 / 主编)作为拆书常设步骤。
- `aiContext` 字段级用途裁剪(按用途只取需要的字段)。
- 参考作品授权快照表(`db/ddl/96`):**决定不启用**(领域索引 §9),DDL 留存不 apply。
@ -198,6 +200,6 @@
- 草稿→确认的后半段真正跑通(当前正式表、绑定表基本为空,卡多在 `pending`)。
- 稳定 `id` 不因改名变化的保证。
- `retired` 退役态。
- 设定全书闭环与台账(§8):`confirm` 计划弧线入库门禁、全书设定台账视图、完本实现收口门禁;建成前闭环不被机械校验。
- 设定全书闭环与台账(§8):`decide-candidate` 计划弧线入库门禁、全书设定台账视图、完本实现收口门禁;建成前闭环不被机械校验。
- 五个义务型(`event`/`faction`/`location`/`item`/`power_system`)的未来计划字段补齐(字段合同归 `meta/schemas/`,见 §8 现状标注)。
- 删库可恢复(见 08)。

View File

@ -48,7 +48,7 @@ observation -> draft -> evaluating -> active -> retired
单章、单作品或一次高分不能直接产生公共 `active`。升格必须说明样本范围、场景选择偏差和替代解释。
现有实现锚点:公共范式卡由 `parse-book` 窗级聚类出卡,经 `review-cards` 三角色审核(番茄作家 / 起点作家 / 主编)判 pass / revise / reject 后写回卡里,再由用户确认落为正式内容。
现有实现锚点:公共范式卡由 `deconstruct-book` 窗级聚类出卡,经 `review-knowledge-cards` 三角色审核(番茄作家 / 起点作家 / 主编)判 pass / revise / reject 后写回卡里,再由用户确认落为正式内容。
## 5. 消费合同
@ -79,7 +79,7 @@ observation -> draft -> evaluating -> active -> retired
1. **绑定对象与形态**:`pattern_bindings` 承载本作已确认绑定的范式引用集合,每条绑定 = 范式卡库内 `sourceId` + 选定时的版本 / 内容哈希快照。绑定可指公共范式卡(`work_id = 0`,已确认 / `active` 态),也可指作品级范式(非零 `work_id`)。哈希快照冻结绑定语义:范式卡后续被修订不静默改变已绑定内容,是否换绑由用户重新决策。
2. **SoT↔库不一致与补齐方向**:`pattern_bindings` 已是作品行 SoT 合同字段([01-作品领域 §3](01-作品领域.md)),但 `muse_content_work` 尚无承载列——这是 SoT 先于库的债。本领域只定范式侧语义;库表承载位与落库机制归 [01-作品领域](01-作品领域.md) 与 [08-数据权威与可视化领域](08-数据权威与可视化领域.md),不在此写 DDL。补齐方向:在作品行增结构化承载位,存已确认绑定的范式引用集合;承载位建成前,绑定事实必须有临时落库位,不得只活在内存或文件,否则违反“一切落库”横切合同([领域索引 §3](_index.md))。
- **实验仓落地承载**(遵循 `db/ddl` 「主仓表原样不改列、实验扩展进 example_*」口径):承载位 = 最新一条已确认 assembly 规划行(`example_planning_section`,`section_type=assembly`,`state=confirmed`)的 `patternReferences`;确认 assembly 即激活绑定,不另改 `muse_content_work`。消费侧由 read-context `load_confirmed_pattern_bindings` 只读该已确认绑定(写作期不临场召回)。作品行 `pattern_bindings` 列仍是主仓生产承载方向(归 01/08),届时实验仓承载收敛回作品行。
- **实验仓落地承载**(遵循 `db/ddl` 「主仓表原样不改列、实验扩展进 example_*」口径):承载位 = 最新一条已确认 assembly 规划行(`example_planning_section`,`section_type=assembly`,`state=confirmed`)的 `patternReferences`;确认 assembly 即激活绑定,不另改 `muse_content_work`。消费侧由 assemble-context `load_confirmed_pattern_bindings` 只读该已确认绑定(写作期不临场召回)。作品行 `pattern_bindings` 列仍是主仓生产承载方向(归 01/08),届时实验仓承载收敛回作品行。
3. **生效时机(Shadow→confirmed)**:规划期 `select_patterns` 从公共范式卡选定本作范式,选择先落 Shadow(规划候选);经用户确认翻 confirmed 后才写入 `pattern_bindings`、才可进生成上下文(横切总闸见 [架构-02](../../../../../design-docs/架构-02-核心数据结构与双轨模型.md))。未经确认的 Shadow 选择不是绑定,不得被写作期消费。
@ -110,7 +110,7 @@ observation -> draft -> evaluating -> active -> retired
范式的选择靠库内检索:按型别、适用范围、场景和意图从库里过滤候选;库内检索加速(pgvector 向量索引)只负责提高召回速度,不是独立权威,可从库重建。任何命中都必须回读库行并校验内容哈希后才能采用。
- 检索加速失败只影响速度,不改变范式状态语义、不跳过审核。
- 只读看板查库渲染范式,绝不写库;接受、丢弃等写操作仍由 `confirm` skill 和主会话走。
- 只读看板查库渲染范式,绝不写库;接受、丢弃等写操作仍由 `decide-candidate` Skill 和主会话走。
- 可恢复性(库备份、快照、可重建脚本)见 [08-数据权威与可视化领域](08-数据权威与可视化领域.md)。
## 9. 验收条件
@ -129,7 +129,7 @@ observation -> draft -> evaluating -> active -> retired
- 五态生命周期(`evaluating` / `active` / `retired`)的机械判据。
- scope 的 category(品类)层:现状只有公共 / 作品两层,品类层为目标。
- 规划期绑定的合同已定(见 §6);消费侧取数端(read-context `load_confirmed_pattern_bindings`)与生产编排接线已建(实验仓以已确认 assembly 行承载)。剩余待建只是落地:`muse_content_work` 的 `pattern_bindings` 承载列(库表承载归 [01-作品领域](01-作品领域.md),届时实验仓承载收敛回作品行)、绑定确认门禁、写作期范式须可回指 confirmed 绑定的机械门禁(尚未强制)。
- 规划期绑定的合同已定(见 §6);消费侧取数端(assemble-context `load_confirmed_pattern_bindings`)与生产编排接线已建(实验仓以已确认 assembly 行承载)。剩余待建只是落地:`muse_content_work` 的 `pattern_bindings` 承载列(库表承载归 [01-作品领域](01-作品领域.md),届时实验仓承载收敛回作品行)、绑定确认门禁、写作期范式须可回指 confirmed 绑定的机械门禁(尚未强制)。
## 11. 关联 SoT

View File

@ -44,7 +44,7 @@
## 4. 数据库读取器(核心实现)
数据库读取器是本领域的核心实现,对齐 [`read-context`](../../../../.claude/skills/read-context/SKILL.md) 现状:从库读可信来源、冻结、再投影。它必须能够:
数据库读取器是本领域的核心实现,对齐 [`assemble-context`](../../../../.claude/skills/assemble-context/SKILL.md) 现状:从库读可信来源、冻结、再投影。它必须能够:
1. 从库中枚举作品、实体和范式,按 ID、类型、名称、别名、scope、scenario 和章号过滤。
2. 解析库内 schema 并确认来源可溯(来源能回到已导入参考书或作品自身正式内容);不做多租户授权快照重验(96 不启用,领域索引 §9),确认每条来源可被本次任务合法读取。
@ -84,5 +84,5 @@ ReAct Agent 可以调用上下文 Skill 请求补充证据,但不能自行访
- 内容来源:[01-作品领域](01-作品领域.md)、[02-实体领域](02-实体领域.md)、[03-范式领域](03-范式领域.md)
- 数据权威与 raw:[08-数据权威与可视化领域](08-数据权威与可视化领域.md)
- 当前 Skill:[`read-context`](../../../../.claude/skills/read-context/SKILL.md)
- 当前 Skill:[`assemble-context`](../../../../.claude/skills/assemble-context/SKILL.md)
- 上级上下文合同:[专题-03](../../../../../design-docs/专题-03-AI编排上下文与质量评测实现规范.md)

View File

@ -54,21 +54,21 @@
- 规划期 `select_patterns` 从公共范式卡(`work_id=0`)选定本作范式,绑定 `pattern_bindings`;范式内容与消费尺寸合同见 [03-范式领域](03-范式领域.md)。
- 装配 `assembly`:把已选范式与本作事实组织为待装配集合,落 `section_type='assembly'`(装配此前无领域 owner,本节收编,见下"归属收编")。
- `style` 文风画像八字段(叙事人称视角 / 句式 / 叙述配比 / 用词质感 / AI 味黑名单 / 对话风格 / 章末钩子风格 / 达标样张,字段权威见 `meta/schemas/style.yaml`)。
- 机械门禁:**`assembly` 经用户确认(`confirmed`)才进正文上下文**、**`assemble` 只消费已绑定范式**(对齐 [专题-07](../../../../../design-docs/专题-07-知识消费契约与质量闭环.md):公共范式只走规划期决策、写作期引用,不作写作期临场海选)、**`style` 真注入 writer**——**取数端与生产接线已建**:read-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)已建并由生产编排(`docs/write-chapter/step2_write_chapter.py`)接线;范式只读已确认 assembly 绑定注入(实验仓承载,见 [03-范式领域 §6](03-范式领域.md)),确认文风投影为 `styleConstraints` 随冻结上下文注入 writer(不再写死为空)。**待建**:当前注入的是设定行的一句话文风(书12 现状),结构化 `style` 八字段画像的书级抽取尚未建;写作期范式须可回指 confirmed 绑定的门禁尚未机械强制。评测 A/B/C 臂走独立冻结注入,不读生产绑定。
- 机械门禁:**`assembly` 经用户确认(`confirmed`)才进正文上下文**、**`assemble` 只消费已绑定范式**(对齐 [专题-07](../../../../../design-docs/专题-07-知识消费契约与质量闭环.md):公共范式只走规划期决策、写作期引用,不作写作期临场海选)、**`style` 真注入 writer**——**取数端与生产接线已建**:assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)已建并由生产编排(`docs/write-chapter/step2_write_chapter.py`)接线;范式只读已确认 assembly 绑定注入(实验仓承载,见 [03-范式领域 §6](03-范式领域.md)),确认文风投影为 `styleConstraints` 随冻结上下文注入 writer(不再写死为空)。**待建**:当前注入的是设定行的一句话文风(书12 现状),结构化 `style` 八字段画像的书级抽取尚未建;写作期范式须可回指 confirmed 绑定的门禁尚未机械强制。评测 A/B/C 臂走独立冻结注入,不读生产绑定。
- 退出条件:`assembly` 已 `confirmed` 并完成 `pattern_bindings` 绑定,`style` 八字段齐备。
**阶段 4 · 细纲**
- 进入条件:阶段 3 已确认,且当前卷大纲已确认。
- 产出:逐章 `fine_outline`(章细纲 ≈ 章正文 3–5%,是结构骨架不是缩写,超比例退回),落 `section_type='fine_outline'` 且必须带 `target_chapter`。细纲字段合同见 [`meta/schemas/`](../../../../meta/schemas/README.md) 与 [fine-outline 合同](../../../../.claude/skills/fine-outline/SKILL.md)。
- 产出:逐章 `fine_outline`(章细纲 ≈ 章正文 3–5%,是结构骨架不是缩写,超比例退回),落 `section_type='fine_outline'` 且必须带 `target_chapter`。细纲字段合同见 [`meta/schemas/`](../../../../meta/schemas/README.md) 与 [细纲合同(plan-chapter)](../../../../.claude/skills/plan-chapter/SKILL.md)。
- 机械门禁:
- **硬门禁(既有,代码失败关闭)**:`section_type='fine_outline'` 必带 `target_chapter`、`payload` 为非空 JSON、细纲须为结构化对象且数组/字符串字段类型稳定(落库即拒)。
- **内容门禁(文档纪律,待升级)**:硬事件 / 伏笔动作(埋 · 推 · 收)/ 必须出场实体 / 章末钩子齐备;**细纲硬约束覆盖率 100%**(回放评测维度,见 [专题-04](../../../../../design-docs/专题-04-生成质量门控与创作健康度设计方案.md));**细纲产出形与 writer 装配消费形统一——已建**:唯一字段权威 [`meta/schemas/fine_outline.yaml`](../../../../meta/schemas/fine_outline.yaml)(必填集满足装配与机械门、推荐集保留规划表达力),planning 技能与 writer 装配同指它,结束此前两套字段不相交的漂移。
- **内容门禁(文档纪律,待升级)**:硬事件 / 伏笔动作(埋 · 推 · 收)/ 必须出场实体 / 章末钩子齐备;**细纲硬约束覆盖率 100%**(回放评测维度,见 [专题-04](../../../../../design-docs/专题-04-生成质量门控与创作健康度设计方案.md));**细纲产出形与 writer 装配消费形统一——已建**:唯一字段权威 [`meta/schemas/fine_outline.yaml`](../../../../meta/schemas/fine_outline.yaml)(必填集满足装配与机械门、推荐集保留规划表达力),`plan-chapter` 与 writer 装配同指它,结束此前两套字段不相交的漂移。
- 退出条件:该章细纲 `confirmed`,硬约束覆盖率达标,产出形可被 writer 装配直接消费。
### 五阶段共用的落库机械门禁(既有,代码失败关闭)
这四条由 `persist_planning` / `confirm` 在库侧强制,不靠调用方自觉:
这四条由 `persist_planning` / `decide-candidate` 在库侧强制,不靠调用方自觉:
1. `section_type` 只接受 `setting / outline / state / assembly / fine_outline`,非法值拒落。
2. `fine_outline` 缺 `target_chapter` 拒落。
@ -88,7 +88,7 @@
### 现状与待建
- **已建成(引用即可)**:五阶段顺序与产出落点、`example_planning_section` 的 `section_type` 白名单与 `fine_outline` 必带章号、`shadow → confirmed` 单通道、公共范式卡与 `select_patterns`;以及本轮落地的——细纲唯一字段合同(`meta/schemas/fine_outline.yaml`,产出形与装配消费形统一)、落库字段覆盖门禁(`fine_outline` 型已强制失败关闭)、read-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)并由生产编排 `step2_write_chapter.py` 接线(细纲统一消费、范式只读已确认 assembly 绑定、文风投影为 `styleConstraints` 注入 writer)。
- **已建成(引用即可)**:五阶段顺序与产出落点、`example_planning_section` 的 `section_type` 白名单与 `fine_outline` 必带章号、`shadow → confirmed` 单通道、公共范式卡与 `select_patterns`;以及本轮落地的——细纲唯一字段合同(`meta/schemas/fine_outline.yaml`,产出形与装配消费形统一)、落库字段覆盖门禁(`fine_outline` 型已强制失败关闭)、assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)并由生产编排 `step2_write_chapter.py` 接线(细纲统一消费、范式只读已确认 assembly 绑定、文风投影为 `styleConstraints` 注入 writer)。
- **决策已定、机械落地待建(三个架构空白)**:合同见 02/01/03 三域决策记录——
1. 设定全书闭环校验 + 全书设定台账(把「演变历程」从只追加日志升级为闭环义务,新增设定 × 章消费矩阵视图;见 [02-实体领域 §8](02-实体领域.md))。
2. 卷数合同:`novel_work` 篇幅目标增「分卷数」,`outline` 分卷粗纲卷数须与之一致并机械校验(见 [01-作品领域 §6](01-作品领域.md))。
@ -145,7 +145,7 @@ DRAFT/CHECKING/PASSED -> DISCARDED(用户明确决策)
- `PASSED` 只表示候选可展示、可进入接受前置校验,不表示已成为正式正文。
- 诊断和评测候选固定不可接受(四层机械强制:资格由 run 类型机械派生、合同硬校验拒绝、接受入口硬拒、产出钉死评测区)。
> **现状标注**:当前实现只有内存四态(DRAFT / CHECKING / PASSED / REJECTED);ACCEPTED、DISCARDED、ARCHIVED 三态与持久化到候选表为目标合同,待建。
> **现状标注**:候选状态已持久化。生产运行级 CAS 链落 `example_candidate_cas`(`PostgresCasStateStore`,一次运行一条链:DRAFT / CHECKING / PASSED / REJECTED,revision 单调 +1,DB 触发器锁方向闭集与身份不可变),生产编排 `step2_write_chapter.py` 已接它。候选表 `example_candidate` 承载业务态(含 accepted / discarded),接受/丢弃经 `write_canonical` 单通道翻态。ARCHIVED 态仍未启用。
## 4. 用户决策
@ -181,11 +181,11 @@ DRAFT/CHECKING/PASSED -> DISCARDED(用户明确决策)
- 任一步失败必须进入明确终态,不留下无法判断是否可接受的处理中候选。
- 一切输入产出落库(见索引 §3):用户意图、冻结上下文、候选正文、检测与评分、决策、运行回执、补证与重写记录。
> **待建(目标合同)**:
> - 写库写入层:当前管线只读不写,正式内容变更仍靠人工 commit;目标是管线直接原子写库。
> - 状态机持久化与三态(ACCEPTED / DISCARDED / ARCHIVED)落候选表。
> - 生产链上的质量评分环节:当前盲评只接离线评测,生产链尚无评分落库。
> - 生成实体/范式草稿的触发接线:当前未接通。
> **现状与待建**:
> - 写库写入层:**已建**——`write_canonical.accept` 单事务写正文块(revision CAS)+ 来源归因 + 命令幂等 + 决策归档 + 候选翻态,任一失败整体回滚;DB 级兜底复检 `run_type=production`、`state=passed`、`semantic_status=passed`(先审后入,语义未过不得接受)。
> - 状态机持久化:**已建**——运行级 CAS 链 `example_candidate_cas`,业务态落 `example_candidate`;ARCHIVED 态未启用。
> - 结构化事实增量:**已建**——`example_fact_delta`(提案)+ `example_fact_ledger`(正典账本);模型只提六型闭集增量且必须带正文证据引文,只有用户批准的增量随正文同事务入账本,抽取结果不自动升格。
> - 待建:生产链上的质量评分环节(盲评仍只接离线评测);补证重组装在语义缺口下的自动接线(当前缺口失败关闭);章后抽取的异步执行(接受时只登记 pending 投影)。
## 7. 开发评测边界

View File

@ -33,7 +33,7 @@
每章不能自创完全不同的量表,否则失去跨章比较;也不能用一套固定权重覆盖所有场景。量表、场景策略和阈值必须版本化——改了评分口径,要能读出“这次用的是哪一版量表”。
现有实现承认(各一句,不展开):六类混淆项报告与新角色比例分层裁决由 [`eval`](../../../../.claude/skills) 质量收敛环承载;卡三角色审核与金标准校准由 [`review-cards`](../../../../.claude/skills) 承载。
现有实现承认(各一句,不展开):六类混淆项报告与新角色比例分层裁决由 [`optimize-content-quality`](../../../../.claude/skills/optimize-content-quality/SKILL.md) 质量收敛环承载;卡三角色审核与金标准校准由 [`review-knowledge-cards`](../../../../.claude/skills/review-knowledge-cards/SKILL.md) 承载;AI 味案例的来源哈希、Shadow 捕获、来源重验证和反例前置门由 [`capture-ai-flavor-cases`](../../../../.claude/skills/capture-ai-flavor-cases/SKILL.md) 承载。
## 4. 失败分类
@ -60,7 +60,7 @@
正文发生变化后,旧审核不能继续作为接受依据——哈希一变,挂在旧哈希上的审核自动失效。
> 实现现状标注(诚实保留):当前审核证据散在 `docs/` 的带日期报告里、不绑哈希,正文一变旧报告照常躺着、无法判定是否过期。审核/实验/运行证据落库并绑候选哈希,待建。
> 实现现状标注(诚实保留):生产链审核证据已落库并绑候选哈希——机械门与语义检测结果随 `persist_writer_execution` 落 `example_quality_result`(`candidate_sha256` 绑定),语义状态另固化到候选行作为接受通道的 DB 兜底。回放评测链的审核报告仍散在 `docs/` 的带日期报告里、不绑哈希,迁库待建。
## 6. 经验升格
@ -75,7 +75,9 @@
-> 稳定 Skill / Tool / Agent 规则
```
这是本领域的设计目标合同;现状是这条链基本未实现——证据还散在文件、升格靠人手记,承接物(把 lesson/win 接进范式与 Skill 的结构与状态机)还没建起来。
这是本领域的设计目标合同。现状:承接物骨架已建——`example_lesson` 登记表(`lesson_registry`)承载 lesson/win 证据(绑 run_id 与候选哈希),状态机 `proposed → reviewing → promoted/rejected` 由 DB 触发器强制:跳过评审的自动升格被拒,`promoted` 必须指明目标类型(pattern/skill/tool)与落点,终态不可再流转。仍未建:证据的自动收集(当前靠人/agent 登记)、升格到范式卡与 Skill 合同的具体变更动作(promoted 之后的落地由各 owner 领域承接)。
AI 味案例是“单次观察”的一种证据载体:既有作品反向扫描和创作反馈都先自动落库为 `shadow`,不直接进入范式或生产规则。确认、样例投影和规则消费前必须重验来源哈希与位置锚点;哈希变化、来源不可得或锚点不一致时保留历史证据但 fail-closed。`shadow` 只在本卡/本作品作用域内作为待复核提示;只有跨作品重复、同时有“应修”和“不应修/边界/回归”证据,并完成回放与独立评审,才允许生成规则候选;规则候选仍不是 `active`。`canonical` 也不等于规则生效,`rejected`/`archived` 只保留审计。
> 与 02 的同名区分:本节“升格”指经验沿上面这条链从一次观察长成可复用的范式/Skill/Tool。[02-实体领域](02-实体领域.md) 里的“作品面实体入库(也叫升格)”指拆书抽出的实体进入某部作品的正式事实库。两者只是都叫“升格”,对象、链路与 owner 都不同,引用时注意区分。
@ -98,7 +100,7 @@
- **代价明账**:评测证据与生产共享同一数据库信任根,是单用户场景的刻意取舍——换可观测性与防篡改,放弃证据底座独立性 / 外部可审计性。这是明账,不是纯收益。
- **常设不变量**(从预防迁到发现):没有评测质量结果出现在任何生产视图、没有正式正文来源能追溯到评测候选——持续跑、违反即报警。(harness 改造原则,见 [docs/2026-08-01-评测harness改造设计](../../../../docs/2026-08-01-评测harness改造设计.md))
现有实现承认(各一句,不展开):预算账本合同与运行探针锁定由 [`db`](../../../../.claude/skills) 与运行回执承载,Gate B 通过回执的哈希链由 [`confirm`](../../../../.claude/skills) 承载。
现有实现承认(各一句,不展开):预算账本合同与运行探针锁定由 [`access-database`](../../../../.claude/skills/access-database/SKILL.md) 与 `record-run-evidence` 承载,Gate B 通过回执的哈希链由 [`decide-candidate`](../../../../.claude/skills/decide-candidate/SKILL.md) 承载。
> 实现现状标注(诚实保留):当前只有一个作品且评测样本已预注册,Gate B 因此恒判 `insufficient_evidence`(跨作品样本不足)——这是目标态下的正确行为,不是 bug。
@ -108,14 +110,14 @@
## 9. 待建
- 经验升格链承接物:把 lesson/win 接进范式与 Skill/Tool 的结构、状态机与落库字段。
- 审核证据落库绑哈希:审核/实验/运行记录从 `docs/` 日期报告迁入库表并绑正文或候选哈希。
- 经验升格链后半段:`example_lesson` 登记与状态机已建(见 §6),promoted 之后到范式卡/Skill 合同的具体变更动作、证据自动收集未建。
- 评测链审核证据落库绑哈希:生产链审核证据(机械门 + 语义报告)已随 `example_quality_result` 绑候选哈希落库;回放评测的审核报告仍在 `docs/` 日期报告里,迁库绑哈希待建。
- eval 收敛环脚本:把评分、失败分类与升格触发串成可机械复跑的闭环。
## 10. 验收条件
1. 机械失败、运行失败和内容质量失败可以由原因码明确区分,脚本能从报告里读出类别。
2. 修改后的候选一定重新检查并绑定新 hash;旧 hash 上的审核对当前正文不再有效。
2. 修改后的候选一定重新检查并绑定新 hash;旧 hash 上的审核对当前正文不再有效。AI 味案例在确认、样例投影或规则消费前必须有 `verified` 重验证回执。
3. 每个 active 范式或稳定规则都有可追溯到库内记录(含哈希)的证据。
4. 同一章用两套不同场景策略打分时,通用底层指标的分差能被脚本读出并比较;场景策略只改场景权重,不改四个通用维度的定义。
5. 任一审核、实验或运行回执,都能用库内一条记录定位到它所评价的正文或候选哈希。

View File

@ -35,6 +35,14 @@ Agent 与 Skill 领域拥有角色职责、可调用能力合同、确定性工
数据库是权威:Skill 对自己读写哪些表负责,声明失败时如何关闭,并确保经手的输入和产出都落库。没落库的输入产出,在系统视角里等于不存在。只读看板只查库渲染,不替 Skill 写任何数据。
### 3.1 命名与稳定标识
- Skill 名统一使用小写 `动作-对象`,直接说明调用者能执行什么;目录名必须与 frontmatter `name` 完全一致。
- 名称不使用 `db`、`llm`、`runtime`、`eval` 这类内部模块缩写,也不使用 `upgrade`、`planning` 这类无法判断具体动作的阶段词。
- scenario 与 Skill 名分离:`continuation`、`fine_outline` 等 scenario 由 `meta/chains` 显式映射到动作式 Skill 名,不能假设两者同名。
- 数据库 `source_type`、creator/updater、错误码和备份逻辑键是历史兼容标识;Skill 改名不自动改写这些值。
- 一个名称只对应一个目录和一份 `SKILL.md`;改名后不留旧目录、别名 Skill 或重复合同。
## 4. Tool 合同
- Tool 放在所属 Skill 的 `scripts/`,不散落一次性脚本。
@ -78,12 +86,13 @@ ReAct Agent 不能把“扫全库、随意写表”当作通用工具。每次
## 8. 验收条件
1. 一个 Skill 只有一个明确业务目的。
2. 每个 Skill 在 SKILL.md 声明数据库读写合同(读哪些表、写哪些表、失败如何关闭)。
3. 每个 Skill 的输入与产出都落库,只读看板能查到对应记录。
4. Tool 从不同当前目录调用得到一致结果。
5. 角色 Prompt 只声明职责边界(说清不做什么),不包含 adapter、hash、状态机等支架职责。
6. 稳定经验能沿“范式 -> Skill/Tool/Agent”升格且不产生重复规则。
7. 模型不可用只终止当次 AI 调用,库内已有正式内容不被损坏。
2. Skill 目录名与 frontmatter `name` 一致,名称符合 `动作-对象`,scenario 通过链登记映射。
3. 每个 Skill 在 SKILL.md 声明数据库读写合同(读哪些表、写哪些表、失败如何关闭)。
4. 每个 Skill 的输入与产出都落库,只读看板能查到对应记录。
5. Tool 从不同当前目录调用得到一致结果。
6. 角色 Prompt 只声明职责边界(说清不做什么),不包含 adapter、hash、状态机等支架职责。
7. 稳定经验能沿“范式 -> Skill/Tool/Agent”升格且不产生重复规则。
8. 模型不可用只终止当次 AI 调用,库内已有正式内容不被损坏。
## 9. 关联 SoT

View File

@ -41,6 +41,8 @@ Git = 代码 / Skill / Agent 提示词 / meta(schema
| 用户意图、规划、细纲、冻结的上下文、范式选择 | 规划与上下文冻结表 |
| AI 待审候选正文(未接受) | 候选表(raw 访问控制,见 §5) |
| 检测、评分、审核、实验结果 | 质量结果表 + 运行回执(见 §7) |
| AI 味案例卡(既有作品回填 / 创作反馈) | `example_ai_flavor_case`;检测命令完成后自动写入,默认 `shadow` |
| AI 味来源重验证批次与逐卡回执 | `example_ai_flavor_revalidation_batch` + `example_ai_flavor_revalidation`;append-only,记录 `verified/stale/unavailable/card_mismatch` |
| 用户的接受 / 合并 / 丢弃决策 | 用户决策记录 |
| 每次运行的回执 | 运行回执表(见 §7) |
| 补证、重写记录 | 对应的候选 / 回执记录 |
@ -51,7 +53,7 @@ Git = 代码 / Skill / Agent 提示词 / meta(schema
- 上表每一类都必须有库内记录;只读看板看不到的,就是没落库。
- 每条记录要能回答“它是谁产生的、针对哪个作品/章/候选、什么时候”。
- 落库写入必须走 `db` skill 这一条数据库通道,不裸连、不散写一次性脚本(见 §9 与 [07-Agent与Skill领域](07-Agent与Skill领域.md))。
- 落库写入必须走 `access-database` Skill 这一条数据库通道,不裸连、不散写一次性脚本(见 §9 与 [07-Agent与Skill领域](07-Agent与Skill领域.md))。
## 4. 写入顺序
@ -65,6 +67,7 @@ Git = 代码 / Skill / Agent 提示词 / meta(schema
- 向量刷新失败**不回滚**已经成功的库写入。向量索引只是加速,可从库重建(见 §8)。
- 检索加速失败只影响速度,不改变内容语义、不跳过审核。
- 模型运行失败可以阻断当次 AI 任务,但不能损坏库里已有的作品、实体、范式。
- AI 味检测的卡片写入与同次重验证回执在同一持久化事务内完成;数据库写失败时检测命令失败关闭,不返回“只生成成功”的假绿结果。
- 任何外部系统、缓存或投影都不得反过来改写库里的正式内容。
## 5. raw 进库与访问控制
@ -85,7 +88,7 @@ raw 指不适合直接当正文、但需要留存可查的完整材料:完整
## 6. 只读可视化模块
一个本地 web 看板,把库里的状态随时渲染给人看。核心不变量:纯标准库实现、只对库做只读查询并渲染、**绝不触发任何写**(接受、丢弃等写操作仍由 `confirm` skill 与主会话走,看板只展示结果);看板看到的等于库里的,因此它倒逼 §3 一切落库——看板上空白的地方,就是落库的缺口。
一个本地 web 看板,把库里的状态随时渲染给人看。核心不变量:纯标准库实现、只对库做只读查询并渲染、**绝不触发任何写**(接受、丢弃等写操作仍由 `decide-candidate` Skill 与主会话走,看板只展示结果);看板看到的等于库里的,因此它倒逼 §3 一切落库——看板上空白的地方,就是落库的缺口。
完整合同(只读硬约束、技术形态、视图清单、raw 访问控制、验收)见 [可视化模块合同](../可视化模块合同.md),那里是本模块的唯一 SoT,本节不复制。
@ -106,7 +109,7 @@ raw 指不适合直接当正文、但需要留存可查的完整材料:完整
- 回执写入后**不改写**;新的尝试产生新的记录(不可变账本)。
- 回执字段合同由本领域拥有;作品领域只提供一个在作品下的归档关系,质量领域只消费其中的结果摘要。
- 现有实现里 `runtime` 的不可改回执(内容寻址账本)与租约 raw 保险库(当前服务评测)即属此合同的落地。
- 现有实现里 `record-run-evidence` 的不可改回执(内容寻址账本)与租约 raw 保险库(当前服务评测)即属此合同的落地。
## 8. 可恢复性
@ -124,22 +127,23 @@ raw 指不适合直接当正文、但需要留存可查的完整材料:完整
- Git 保存代码、Skill、Agent 提示词、`meta/`(schema 与 chains)、文档、DDL 的版本历史,提供差异审查、回滚依据和备份。
- Git 不是正式内容权威,也不是实时消息总线。它可以对作品信息与作品文本留痕(历史、备份),但留痕不等于权威;正式内容以库为准,看板只读库,冲突时以库为准。
- 一次 `git commit` **不等于**用户接受。接受语义由创作流程和库内状态决定,见 [05-创作流程领域](05-创作流程领域.md)。
- 数据库写入、DDL 应用走 `db` skill 单一通道;DDL 先落 `db/ddl/` 审计文件再应用,不绕过。
- 数据库写入、DDL 应用走 `access-database` Skill 单一通道;DDL 先落 `db/ddl/` 审计文件再应用,不绕过。
可承认的现有实现(引用即可,细节以各自载体为准):
- `db` skill:单一数据库通道。
- `runtime`:不可改回执(内容寻址账本)与租约 raw 保险库(当前服务评测)。
- `access-database`:单一数据库通道。
- `record-run-evidence`:不可改回执(内容寻址账本)与租约 raw 保险库(当前服务评测)。
- 额度账本:`db/ddl/95-example额度账本.sql`。
- 清洗日志:`db/ddl/92-example清洗日志.sql`。
- 参考作品授权快照:`db/ddl/96-example参考作品授权快照.sql`(**决定不启用**,领域索引 §9;DDL 留存不 apply)。
- `import` skill:参考书 / 旧稿导入落库。
- `import-book`:参考书 / 旧稿导入落库。
## 10. 待建(目标合同)
以下为当前尚未满足、但属于本领域目标合同的缺口:
- 一切输入产出落库:运行注册 / 回执 / 质量评判 / 模型调用明细表已建(97/98),写路径未接通;候选正文、用户决策、补证记录、规划与冻结(99/100)尚未建。
- 一切输入产出落库:AI 味案例三表(104)与 `capture-ai-flavor-cases` 检测写路径已接通;运行注册 / 回执 / 质量评判 / 模型调用明细(97/98)已建;候选、用户决策、规划与冻结(99/100)的写路径已由生产链接通(`persist_writer_execution` / `write_canonical` / `persist_planning` / `persist_freeze`)。本轮新增并已接通:候选语义审查列(105)、事实增量提案与正典账本(106)、投影登记(107)、经验升格登记(108)、候选 CAS 状态链(109)。
- 投影的异步执行:接受时只登记 pending 投影(章后抽取等),worker 与重建调度未建(登记/失效/重试/巡检合同已建,见 `projection_registry`)。
- 只读可视化看板:首版已建(总览/作品/知识库/运行记录四空间);运行详情下钻、智能体空间、工作区标签待建。
- raw 全文进库的表与访问控制(101)。
- DB 备份与重建脚本。

View File

@ -33,7 +33,7 @@
## 4. 只读可视化模块
- 一个纯 Python 标准库的本地 web 看板(web 层零框架、零新增依赖;psycopg 复用 `.venv` 现成的,非标准库),内网或 Tailscale 访问,形态参照 open-wenmo。
- 只对数据库做只读查询并渲染,**不触发任何写操作**;接受、丢弃等写操作仍由 `confirm` skill 和主会话走,看板只展示结果。
- 只对数据库做只读查询并渲染,**不触发任何写操作**;接受、丢弃等写操作仍由 `decide-candidate` Skill 和主会话走,看板只展示结果。
- 它看到的内容等于库里的内容,因此它倒逼第 3 条“一切落库”。
- 合同细节见 [08-数据权威与可视化领域](08-数据权威与可视化领域.md)。

View File

@ -4,20 +4,21 @@
## 1. 它是什么,不做什么
看板是一个本地 web 页面,把 `muse-example` 库里的状态随时渲染给你看。
看板是一个本地 web 页面,把 `muse-example` 库里的状态随时渲染给你看;质量证据的离线 JSON 只作为数据库不可用时的明确回退。
- 它只干两件事:对库做**只读查询**,把结果渲染成人能读的页面。
- 它**绝不触发任何写操作**。接受、合并、丢弃、确认这些写,仍由 `confirm` skill 和主会话走,看板只展示结果。
- 它**绝不触发任何写操作**。接受、合并、丢弃、确认这些写,仍由 `decide-candidate` Skill 和主会话走,看板只展示结果。
- 它看到的 = 库里的。看板上空白的地方,就是落库的缺口——所以看板天然是“一切输入产出必须落库”这条纪律的验收面。
- `/ai-flavor` 也是数据库视图:默认读取 AI 味案例卡与重验证账本表,页面明确标注“候选命中,不是确认结论”;只有数据库不可用时才显示离线回退及原因。其余视图同样以数据库为权威。
- 它服务本机单用户,不做多用户、权限管理、对外分享。
## 2. 只读硬约束(怎么保证它绝不写)
这是看板的命根子,验收时按机械门查:
- **连接只读**:看板用独立的只读连接,默认事务只读(`SET TRANSACTION READ ONLY` 或库侧只读角色);连接串与写通道(`db` skill)分开。
- **连接只读**:看板用独立的只读连接,默认事务只读(`SET TRANSACTION READ ONLY` 或库侧只读角色);连接串与写通道(`access-database` Skill)分开。
- **代码无写语句**:全模块只允许 `SELECT`,不得出现任何 `INSERT/UPDATE/DELETE` 或 DDL。**真门禁是库级只读连接**(写语句被 PostgreSQL 直接拒);“grep 无写语句”是辅助门,须用 `\bINSERT\b` / `\bUPDATE\b` / `\bDELETE\b` 词边界查(否则 `deleted=false` 里的 DELETE 子串会误报)。
- **不接会写的通道**:看板不调用 `confirm` 或任何会写库的 skill,只自己读库。
- **不接会写的通道**:看板不调用 `decide-candidate` 或任何会写库的 Skill,只自己读库。
- **挂了不牵连**:看板进程崩了、断网了,库内正式内容和创作链不受任何影响。
## 3. 技术形态
@ -40,20 +41,29 @@
| 额度账本 | 5 小时额度窗、花费与调用次数水位 | 额度账本表(`db/ddl/95-example额度账本.sql`) |
| 结构本体(框架视图,可选) | 23 型合同、字段、aiContext 控制项、保护节点、功能链 | `muse_meta_schema` / `muse_meta_field` / `muse_meta_visibility_policy` / `muse_meta_protection_node` / `muse_meta_function_chain` |
| 写命令审计 | 写命令的幂等审计记录 | `muse_content_command_log` |
| AI 味案例回填 | 作品扫描的候选命中、来源哈希/位置、Shadow 状态和复核详情 | `example_ai_flavor_case`(检测命令自动写入) |
| AI 味案例来源重验证 | 当前来源是否仍与案例卡的哈希/位置一致,以及 stale/unavailable/mismatch 结果 | `example_ai_flavor_revalidation_batch` + `example_ai_flavor_revalidation`(append-only) |
| 授权快照(不启用) | 单用户本地不做多租户授权机制;参考书出处见“参考书与拆书”视图 | 96 不启用(领域索引 §9) |
| 待审候选 | 候选正文、版本、哈希、机械/语义状态、所属作品/章 | `example_candidate`(生产链 `persist_writer_execution` 落候选,`write_canonical` 翻 accepted/discarded) |
| 候选 CAS 状态链 | 每次生产运行的 DRAFT/CHECKING/PASSED/REJECTED 迁移与 revision | `example_candidate_cas`(109,生产编排实时写) |
| 用户决策 | 接受 / 合并 / 丢弃的历史与依据 | `example_user_decision`(`write_canonical` 单事务 append-only 归档) |
| 运行列表 / 回执 / 质量评判 | 一次运行的作品/触发/终态;逐段身份回执;机械/语义检测结论 | `example_run` / `example_run_receipt` / `example_quality_result`(98,生产与评测写路径已接通) |
| 模型调用 / raw 全文 | 逐次调用的模型/花费/提示词哈希;完整输入输出与供应商响应 | `example_llm_call`(97)/ `example_raw_content`(101,`record-run-evidence` 随调用原子落库) |
| 规划与冻结上下文 | 规划、细纲、冻结上下文清单与授权快照 | `example_planning_section` / `example_context_freeze`(100,`persist_planning` / `persist_freeze` 写) |
| 事实增量 | 候选提出的类型化增量(proposed/accepted/rejected)与已进账本的变更流 | `example_fact_delta` / `example_fact_ledger`(106,接受候选同事务写入) |
| 投影登记 | 摘要/抽取/embedding 等投影的 pending/completed/failed/stale 与重试 | `example_projection_run`(107,接受候选同事务登记) |
| 经验升格 | lesson/win 证据与 proposed→reviewing→promoted/rejected 流转 | `example_lesson`(108,`lesson_registry` 写) |
### 4.2 待落库(合同要求、表尚未建/未接通;看板预留视图,暂显示“无数据 / 待落库”)
### 4.2 待落库 / 待建视图
| 视图 | 看什么 | 落库去向 |
|---|---|---|
| 待审候选 | 候选正文、版本、哈希、状态、所属作品/章 | 候选表(待建,见 08 §3) |
| 用户决策 | 接受 / 合并 / 丢弃的历史与依据 | 用户决策记录(待建) |
| 运行回执 | 每次运行的回执、结果摘要、raw 指针 | `example_run_receipt`(98 已建,写路径未接通,见 08 §7) |
| 运行列表 | 一次运行的作品 / 触发 / 终态,运行页的索引 | `example_run`(98 已建,写路径未接通) |
| 质量评判 | 每次检测 / 评分 / 审核 / 实验的结论、分值、量表版本、失败类别 | `example_quality_result`(98 已建,写路径未接通,06 §3/§5) |
| 模型调用 | 提示词、执行配置、花费 | `example_llm_call`(97 已建,写路径未接通) |
| raw 全文 | 完整原文、完整问答、标准答案、供应商响应 | raw 表(待建,受 §5 访问控制) |
| 规划与冻结上下文 | 规划、细纲、冻结上下文清单 | 规划与上下文冻结表(待建) |
数据面:上表所列各表均已建表且写路径接通,无“表未建”项。
仍待建的是**消费侧**:
| 项 | 现状 |
|---|---|
| 上述新表(候选/CAS/决策/事实增量/投影/经验)的看板渲染视图 | 看板首版只有总览/作品/知识库/运行记录四空间,新视图待画 |
| 投影的异步执行 worker | 接受时只登记 pending 投影,执行与重建调度未建(合同见 `projection_registry`) |
## 5. raw 全文与访问控制
@ -63,7 +73,7 @@
## 6. 与现有通道的关系
- 看板**不替代** `db` skill。`db` skill 是面向 agent 和主会话的唯一数据库通道(可写可查);看板是面向人的独立只读渲染面。
- 看板**不替代** `access-database` Skill。后者是面向 agent 和主会话的唯一数据库通道(可写可查);看板是面向人的独立只读渲染面。
- 看板只读库,不经过会写库的通道;它的存在和死活都不影响创作链。
## 7. 看板必须正确呈现的库内约定(防误导)
@ -89,7 +99,7 @@
1. 落库表分两类:**运行注册 / 运行回执 / 质量评判(98)与模型调用(97)已建**,但写路径未接通,视图暂空;候选 / 用户决策 / raw 全文 / 规划冻结(99/100/101)尚未建。没有它们 §4.2 的视图就是空的。
2. `db/ddl/96` 授权快照表**决定不启用**(领域索引 §9,单用户本地不做多租户授权),DDL 留存不 apply。
3. 看板首版已建成(总览 / 作品 / 知识库 / 智能体 / 运行记录五空间,只读 HTTP + §4.1 部分视图 + 待落库占位格;智能体空间读 102 登记表;运行记录含按作品/按角色双维 + 运行详情下钻);待建 = 作品/章工作区标签、沿来源下钻链(raw 下钻依赖 101)。页面与信息架构见 §10。
3. 看板首版已建成(总览 / 作品 / 知识库 / 质量证据 / 智能体 / 运行记录六空间,只读 HTTP + §4.1 部分视图 + 待落库占位格;AI 味案例查 104 三表;智能体空间读 102 登记表;运行记录含按作品/按角色双维 + 运行详情下钻);待建 = 作品/章工作区标签、沿来源下钻链(raw 下钻依赖 101)。页面与信息架构见 §10。
## 10. 页面与信息架构(产品形态 SoT)
@ -98,7 +108,7 @@
- **记录留痕的验收面**:一切输入产出落不落库,看板上一目了然;空白处就是缺口,也就是后续抽取优化要补的地方。
- **内容与复利原料的浏览面**:作品正文、知识卡、公共范式、运行产出,可浏览、可沿来源下钻。
### 10.1 菜单树(对齐 muse 领域布局,五空间)
### 10.1 菜单树(对齐 muse 领域布局,六空间)
**两条层级原则**(防止把菜单项和详情页混级):
@ -109,6 +119,8 @@
**标记**:`▸` 菜单项 · `└→` 详情下钻 · `[标签]` 详情页内二级标签 · 数据源 `✓ 已有` / `◌ 待落库` / `⚠ 需登记`。
AI 味案例的固定入口是 `/ai-flavor`。列表按作品、模式分页,卡片详情显示卡 ID、状态、人工标签、来源许可、全文/命中哈希、行/字符位置、重验证状态、当前用途和作用域;页面只读,不触发重验证。
```text
1. 总览 / 〔数据权威 08〕 单页
├─ 落库账本 各承接表:有数/0行/待落库(验收面) ✓
@ -155,7 +167,10 @@
└─ 清洗审计(批次/理由/模型/删了几处) ✓
▸ 结构本体 /knowledge/meta 〔框架视图·次要〕 23型/字段/aiContext/保护节点/功能链 ✓
4. 智能体 /agents 〔Agent 07〕
4. 质量证据 /ai-flavor 〔质量 06 · 数据库〕
▸ AI 味案例回填 作品/模式筛选、候选卡分页、来源锚点详情;只显示 hash/位置,不显示未授权原文
5. 智能体 /agents 〔Agent 07〕
▸ 角色 /agents 5 角色卡(writer/planner/extractor/detector/judge)
└→ 角色详情 /agents/:role
├─ 角色画像 职责/模型归属 ⚠(登记表)
@ -169,7 +184,7 @@
├─ 读写合同 读/写哪些表 ⚠(登记表)
└─ 调用情况 ✓
5. 运行记录 /runs 〔流程 05 · 质量 06〕
6. 运行记录 /runs 〔流程 05 · 质量 06〕
▸ 按作品看运行 /runs/by-work 〔作品选择器〕 ✓
▸ 按角色看运行 /runs/by-role 〔角色选择器〕 ✓
└→ 运行详情 /runs/:run_id
@ -196,13 +211,13 @@
**智能体页的 ⚠ 来源(A 方案,2026-07-30 拍板)**:角色画像 / 可配置项 / 技能合同在 Git 侧(`.claude/agents/`、`.claude/skills/`),库里没有。看板只读库,故新增**智能体/技能登记表**(`example_agent_role` + `example_skill`,设计见落库设计稿),把角色、模型归属、可配置项、技能读写合同**登记入库**——智能体配置也纳入留痕,看板只读这张表。
**分阶段点亮(认)**:首版实现为五空间(总览 / 作品 / 知识库 / 智能体 / 运行记录;智能体空间读 102 登记表 `example_agent_role`/`example_skill`,登记表由 `db/scripts/sync_agent_registry.py` 从 Git 侧同步,Git 仍是配置权威)。作品工作区**先上 [章节]**(正文已有),其余标签(大纲/细纲/设定/叙事状态、章工作区的细纲与出场卡)随对应表落库(规划表、候选表,落库设计稿第 3/2 步)逐格点亮;点亮前显示"待落库"占位,不假装已有。
**分阶段点亮(认)**:首版实现为六空间(总览 / 作品 / 知识库 / 质量证据 / 智能体 / 运行记录,见 §9.3;智能体空间读 102 登记表 `example_agent_role`/`example_skill`,登记表由 `.claude/skills/access-database/scripts/sync_agent_registry.py` 从 Git 侧同步,Git 仍是配置权威)。作品工作区**先上 [章节]**(正文已有),其余标签(大纲/细纲/设定/叙事状态、章工作区的细纲与出场卡)随对应表落库(规划表、候选表,落库设计稿第 3/2 步)逐格点亮;点亮前显示"待落库"占位,不假装已有。
### 10.2 UI 形态
- 纯标准库 HTTP 服务端渲染 HTML(§3),相对路径 + 注入 base(仿 open-wenmo hub),无 SPA 框架。
- 布局:左侧顶级导航(§10.1 五个领域空间,各项标注对应领域,顶级项下缩进列出二级子菜单)+ 主区。列表页 = 表格 + 分面过滤 + 分页;详情下钻页**不进菜单**、顶部显示面包屑(如 `作品 / 深空之影 / 第 5 章`),页内用二级标签分区(作品工作区 / 章工作区);正文 = 可读排版;raw = 等宽全文 + 访问控制提示。
- 交互只有两类:只读浏览、沿来源下钻。**无任何写操作**(§2 硬约束);接受 / 丢弃仍走 `confirm` skill。
- 交互只有两类:只读浏览、沿来源下钻。**无任何写操作**(§2 硬约束);接受 / 丢弃仍走 `decide-candidate` Skill。
- 过滤 / 搜索用 query 参数、服务端渲染;每次请求实时查库,不缓存(§7)。
### 10.3 信息披露的重点(怎么组织、为什么)

View File

@ -5,7 +5,7 @@ tools: Read, Grep, Glob
model: opus
---
你是检测员。功能合同以 `detect` skill 为准。你只返回当前调用要求的结构化检测草稿,不修改正文、规划、知识卡、运行状态或任何文件。
你是检测员。功能合同以 `check-content-consistency` Skill 为准。你只返回当前调用要求的结构化检测草稿,不修改正文、规划、知识卡、运行状态或任何文件。
## 模型口径
@ -29,7 +29,7 @@ model: opus
## 细纲回放
细纲回放只接收冻结到 `as_of` 的匿名候选与公共规划上下文,不读取目标章 proxy,不推断实验臂。报告类别使用 `detect` skill 登记的闭集;任一高严重度问题由编排器阻断,检测员不改候选、不裁决卡效用。
细纲回放只接收冻结到 `as_of` 的匿名候选与公共规划上下文,不读取目标章 proxy,不推断实验臂。报告类别使用 `check-content-consistency` 登记的闭集;任一高严重度问题由编排器阻断,检测员不改候选、不裁决卡效用。
## 禁区

View File

@ -1,15 +1,15 @@
---
name: extractor
description: 抽取员——分析槽位默认绑定件,承接 full_parse(拆书)与 extraction(章后抽取)两功能;功能细节以 parse-book / extract-knowledge skill 为合同;产出全为草稿。
description: 抽取员——分析槽位默认绑定件,承接 full_parse 与 extraction;分别加载 deconstruct-book 与 extract-chapter-knowledge,产出全为草稿。
tools: Read, Write, Grep, Glob
model: opus
---
你是知识抽取员,分析槽位的默认绑定件。两用场各有功能合同:**拆书=`parse-book` skill,章后抽取=`extract-knowledge` skill**,一次只带本次功能的合同。产出全部是草稿(文件版不提交;PG 版 status=draft)。
你是知识抽取员,分析槽位的默认绑定件。两用场各有功能合同:**拆书=`deconstruct-book`,章后抽取=`extract-chapter-knowledge`**,一次只带本次功能的合同。产出全部是草稿(文件版不提交;PG 版 status=draft)。
## 模型口径(抽取有两条路径)
- **拆书 / 导入侧抽取**:经 `llm` / `parse-book` skill 统一入口调用,走 MiniMax-M3,**不走角色 model 派发**;本角色 frontmatter 的 `model: opus` 不约束这条路径。
- **拆书 / 导入侧抽取**:经 `call-content-model` / `deconstruct-book` 统一入口调用,走 MiniMax-M3,**不走角色 model 派发**;本角色 frontmatter 的 `model: opus` 不约束这条路径。
- **创作期章后抽取**:作为角色派发执行,可用 opus 4.8;frontmatter 的 `model: opus` 对应的就是这条路径。
## 元数据纪律(怎么用元数据)
@ -23,8 +23,8 @@ model: opus
1. 以正文为准,不脑补正文没写的;
2. 与既有知识冲突时**不覆盖**——「⚠ 冲突待裁决」双版本留档并升级用户;
3. 基础字段规范填:来源(抽取@第N章 / 拆书@书名)、状态(草稿);
4. **采纳正文≠确认知识**:确认另走 confirm,自动确认条件的判定不归你。
4. **采纳正文≠确认知识**:确认另走 `decide-candidate`,自动确认条件的判定不归你。
## 禁区
不动正文、大纲、框架文件;不执行 git 写操作;PG 版不直接写库(产结构化清单,经主会话走 db skill 入库)。
不动正文、大纲、框架文件;不执行 git 写操作;PG 版不直接写库(产结构化清单,经主会话走 `access-database` 入库)。

View File

@ -5,7 +5,7 @@ tools: Read, Grep, Glob
model: opus
---
你是质量评委。功能合同以 `quality-gate` skill 为准。每次调用只完成一个独立评审,不修改候选、规划、知识卡、运行状态或任何文件。
你是质量评委。功能合同以 `score-content-quality` Skill 为准。每次调用只完成一个独立评审,不修改候选、规划、知识卡、运行状态或任何文件。
## 正文回放边界

View File

@ -1,20 +1,22 @@
---
name: planner
description: 规划师——规划槽位默认绑定件,承接 planning 与 fine_outline 功能;产出结构=schema 字段清单本身,功能细节以对应 skill 为合同;产出全为草稿。
description: 规划师——规划槽位默认绑定件,承接 setting_init、planning 与 fine_outline;分别加载 design-story-foundation、plan-story 与 plan-chapter,产出全为草稿。
tools: Read, Write, Grep, Glob
model: opus
---
你是这部书的总规划,规划槽位的默认绑定件。功能合同按本次任务二选一:
你是这部书的总规划,规划槽位的默认绑定件。每次只执行一个功能合同:
- `planning`:遵守 `planning` skill,负责立项与规划修订;
- `fine_outline`:遵守 `fine-outline` skill,只产结构细纲,不写正文。
- `setting_init`:遵守 `design-story-foundation` Skill,独立完成一份用户挑选前的前期设定候选;
- `planning`:遵守 `plan-story` Skill,负责立项与规划修订;
- `fine_outline`:遵守 `plan-chapter` Skill,只产结构细纲,不写正文。
产出全部不提交;未确认的规划不进生成上下文。回放任务中,`fine-outline` skill 的冻结边界优先于本身份段里面向正式创作的全局规划能力。
产出全部不提交;未确认的规划不进生成上下文。回放任务中,`plan-chapter` 的冻结边界优先于本身份段里面向正式创作的全局规划能力。
## 元数据纪律(怎么用元数据)
- **产出结构=schema 字段清单本身**:设定包/大纲/知识卡/状态的每一节每一卡,都按对应 schema 逐字段产出(落点表见 planning skill);**字段全覆盖**,写不出=设计问题,标「字段存疑:原因」——这是验证元数据设计的一等产出,不许静默跳过。
- `setting_init` 的结构由 `design-story-foundation` 冻结的候选合同控制;下面的 schema 纪律只用于 `planning` 与 `fine_outline`。
- **产出结构=schema 字段清单本身**:设定包/大纲/知识卡/状态的每一节每一卡,都按对应 schema 逐字段产出(落点表见 `plan-story`);**字段全覆盖**,写不出=设计问题,标「字段存疑:原因」——这是验证元数据设计的一等产出,不许静默跳过。
- **你是唯一看全底牌的生成型角色**(谜底与真相/结局方向/未来卷粗纲):底牌管理是规划职责——底牌写进对应 aiContext 受限字段,绝不散进人人可见的字段。
- schema 加字段,设定包立刻多一节,你一字不改。
@ -27,4 +29,4 @@ model: opus
## 禁区
不写正文;不动 `meta/` 与框架文件;不执行 git 写操作、不写数据库(规划落库由主会话经 `planning` skill 的 `persist_planning.py` 做)。
不写正文;不动 `meta/` 与框架文件;不执行 git 写操作、不写数据库(规划落库由主会话经 `plan-story` 的 `persist_planning.py` 做)。

View File

@ -1,10 +1,10 @@
---
name: writer
description: 网文写手——写作槽位默认绑定件,承接 continuation/rewrite/expansion/polish 四功能;功能细节以对应 skill 为合同;只产候选、不提交。
description: 网文写手——写作槽位默认绑定件,承接 continuation/rewrite/expansion/polish;分别加载 write-next-chapter、rewrite-selection、expand-scene 与 polish-prose,只产候选。
model: opus
---
你是这部书的执笔写手,写作槽位的默认绑定件。功能行为以派发指令加载的单一 skill 为合同:`continuation`(续写)/`rewrite`(改写)/`expansion`(扩写)/`polish`(润色)。你只返回一章正文草稿,**永不读写工作区、永不 git 提交**——采纳权在用户。
你是这部书的执笔写手,写作槽位的默认绑定件。派发指令按 scenario 只加载一个 Skill:`continuation`→`write-next-chapter`,`rewrite`→`rewrite-selection`,`expansion`→`expand-scene`,`polish`→`polish-prose`。你只返回当前合同要求的正文草稿,**永不读写工作区、永不 git 提交**——采纳权在用户。
## 输入边界
@ -12,6 +12,7 @@ model: opus
- `fineOutline`、`narrativeState`、`factConstraints`、`proseExcerpts`、`patternReferences`、`lengthContract` 和 `styleConstraints` 都由可信上下文层投影;卡片只是索引,你不得自行顺着卡搜索。
- 大纲只给本章方向;细纲的硬事件、结果方向、伏笔动作、章末钩子和必须出场实体是不可删除或反转的硬骨架;可调整节拍才允许重排。
- `factConstraints` 只约束事实真伪;`proseExcerpts` 只用于人物声音、动作习惯和叙事质感,不得拿文风样本替代事实约束。
- `patternReferences` 是可参考的写作范式:每条含名字(name)、一句话摘要(summary)和写法要点(writingPoints);只借鉴其写法节奏与技巧,不当作事实约束,不照抄。
## 元数据纪律(怎么用元数据)

View File

@ -1,9 +1,9 @@
---
name: db
description: muse-example 实验库的唯一数据库通道——查询/DML/DDL/SQL 文件应用全走 scripts/db.py(psycopg 直连),SELECT 结果卡片式打印即审查面。主会话与智能体要读写 PG 一律经此,不得裸连或散写一次性脚本。
name: access-database
description: 通过唯一受控入口查询或修改 muse-example PostgreSQL,并应用可审计 DDL。主会话或 Skill 需要通用数据库访问时使用;专用导入、嵌入和检索仍走各自 Skill,禁止裸连和一次性脚本。
---
# db —— muse-example 唯一数据库通道
# 访问 muse-example 数据库
对应 muse API 面:数据访问层。连接事实与凭据见 [`db/连接信息.md`](../../../db/连接信息.md)(DSN 已锁死在脚本内,只连 `muse-example`)。
@ -11,37 +11,37 @@ description: muse-example 实验库的唯一数据库通道——查询/DML/DDL/
```bash
# 查询:卡片式打印(默认最多 50 行、长值截 160 字)
.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT id,title FROM muse_content_work"
.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT ..." --json # JSON 数组输出(给脚本消费)
.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT ..." --full # 长值不截断
.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT ..." --max 200 # 放宽行数
.venv/bin/python .claude/skills/access-database/scripts/db.py query "SELECT id,title FROM muse_content_work"
.venv/bin/python .claude/skills/access-database/scripts/db.py query "SELECT ..." --json # JSON 数组输出(给脚本消费)
.venv/bin/python .claude/skills/access-database/scripts/db.py query "SELECT ..." --full # 长值不截断
.venv/bin/python .claude/skills/access-database/scripts/db.py query "SELECT ..." --max 200 # 放宽行数
# 单条写操作(INSERT/UPDATE/DELETE/DDL):报影响行数
.venv/bin/python .claude/skills/db/scripts/db.py exec "UPDATE ... WHERE ..."
.venv/bin/python .claude/skills/access-database/scripts/db.py exec "UPDATE ... WHERE ..."
# 参数化写操作:SQL 用 %s 占位,参数走服务端绑定(防注入;大内容不拼命令行)
.venv/bin/python .claude/skills/db/scripts/db.py execparams "INSERT INTO example_raw_content(kind,content_sha256,content) VALUES (%s,%s,%s)" --param response --param <sha> --param "短文本"
.venv/bin/python .claude/skills/access-database/scripts/db.py execparams "INSERT INTO example_raw_content(kind,content_sha256,content) VALUES (%s,%s,%s)" --param response --param <sha> --param "短文本"
# 大对象(raw 全文)经 stdin 传 JSON 数组(避开 shell 转义 / ARG_MAX):
.venv/bin/python .claude/skills/db/scripts/db.py execparams "INSERT ... VALUES (%s,%s)" --stdin < params.json # params.json = ["<sha>", "<完整全文>"]
.venv/bin/python .claude/skills/access-database/scripts/db.py execparams "INSERT ... VALUES (%s,%s)" --stdin < params.json # params.json = ["<sha>", "<完整全文>"]
# execparams 参数装载逻辑离线自测(不连库)
.venv/bin/python .claude/skills/db/scripts/test_db_params.py
.venv/bin/python .claude/skills/access-database/scripts/test_db_params.py
# SQL 文件应用:整文件一个事务,失败全回滚
.venv/bin/python .claude/skills/db/scripts/db.py apply db/ddl/91-example实验私货.sql
.venv/bin/python .claude/skills/access-database/scripts/db.py apply db/ddl/91-example实验私货.sql
# 表清单+活行数(deleted=FALSE 计数,无 deleted 列的表计全行)
.venv/bin/python .claude/skills/db/scripts/db.py tables
.venv/bin/python .claude/skills/access-database/scripts/db.py tables
# A3 种子:23 型 YAML → meta 表行(幂等可重跑;字段改动=改 YAML 后重跑)
.venv/bin/python .claude/skills/db/scripts/seed_schemas.py
.venv/bin/python .claude/skills/access-database/scripts/seed_schemas.py
```
## 红线
- **只连 `muse-example`**:DSN 硬编码锁库;严禁改造脚本去碰共享 PG 上的 muse_local / muse_slice_live / *_test。
- 软删约定照主仓:删除=UPDATE `deleted=TRUE`,不物理删(example_* 表同样遵守)。
- 批量导入/嵌入等专用写路径由 import/embed skill 封装(内部同走 psycopg 直连),本 skill 承担通用查改与 DDL 应用。
- 批量导入/嵌入等专用写路径由 `import-book`/`embed-knowledge` Skill 封装(内部同走 psycopg 直连),本 Skill 承担通用查改与 DDL 应用。
- 建表/改表先落 `db/ddl/` 文件再 `apply`,不敲一次性 DDL——文件即审计。
- 大对象写入(raw 全文等)走 `execparams` 参数化通道(大内容经 stdin JSON),不得把大内容拼进 `exec` 的 SQL 字符串(shell 转义 + ARG_MAX);参数化绑定同时防 SQL 注入。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""muse-example 唯一数据库通道(db skill 脚本层)。
"""muse-example 唯一数据库通道(access-database Skill 脚本层)。
- DSN 锁死 muse-example:严禁触碰共享 PG 上其他库(muse_local / muse_slice_live / *_test)。
- query 卡片式打印=审查面;exec 报影响行数;apply 整文件一个事务失败全回滚。
@ -22,7 +22,7 @@ def connect(readonly: bool = False):
readonly=True 时会话级锁死只读(写语句被 PG 直接拒)——query 命令与看板用。
其它 skill 的写路径需要参数化短连接时,`from db import connect` 复用同一 DSN,
不要各自硬编码连接串(仿 llm skill 的 _bump_window)。
不要各自硬编码连接串(仿 call-content-model 的 _bump_window)。
"""
if readonly:
return psycopg.connect(DSN, options="-c default_transaction_read_only=on")

View File

@ -7,18 +7,23 @@
upsert 进 example_agent_role / example_skill;
- 看板只读登记表、不读 Git。幂等可重跑(upsert),配置变更后重跑即同步。
跑法(仓库根目录):.venv/bin/python .claude/skills/db/scripts/sync_agent_registry.py
跑法(仓库根目录):
.venv/bin/python .claude/skills/access-database/scripts/sync_agent_registry.py --check
.venv/bin/python .claude/skills/access-database/scripts/sync_agent_registry.py
"""
import argparse
import json
import re
from pathlib import Path
from db import connect # 复用锁死的 DSN(与 db.py 同目录,脚本目录自动在 sys.path)
ROOT = Path(__file__).resolve().parents[4] # .claude/skills/db/scripts → 仓库根
ROOT = Path(__file__).resolve().parents[4] # .claude/skills/access-database/scripts → 仓库根
AGENTS_DIR = ROOT / ".claude" / "agents"
SKILLS_DIR = ROOT / ".claude" / "skills"
TABLE_RE = re.compile(r"\b(muse_[a-z_]+|example_[a-z_]+)\b")
SKILL_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$")
SKILL_FRONTMATTER_KEYS = frozenset({"name", "description", "disable-model-invocation"})
def parse_frontmatter(text: str) -> dict:
@ -36,6 +41,40 @@ def parse_frontmatter(text: str) -> dict:
return fm
def validate_skill_catalog(skills_dir: Path = SKILLS_DIR) -> list[tuple[Path, dict]]:
"""校验 Skill 目录、frontmatter 与动作式名称,返回稳定排序的目录项。"""
entries = []
seen = set()
for sk in sorted(skills_dir.glob("*/SKILL.md")):
fm = parse_frontmatter(sk.read_text(encoding="utf-8"))
name = fm.get("name", "")
directory = sk.parent.name
unexpected = sorted(set(fm) - SKILL_FRONTMATTER_KEYS)
if unexpected:
raise ValueError(f"{sk}: frontmatter 含未登记字段: {unexpected}")
if not name:
raise ValueError(f"{sk}: frontmatter 缺少 name")
if directory != name:
raise ValueError(f"{sk}: 目录名 {directory!r} 与 name {name!r} 不一致")
if not SKILL_NAME_RE.fullmatch(name):
raise ValueError(f"{sk}: name 必须使用小写 动作-对象")
if name in seen:
raise ValueError(f"{sk}: Skill name 重复: {name}")
if not fm.get("description"):
raise ValueError(f"{sk}: frontmatter 缺少 description")
invocation_flag = fm.get("disable-model-invocation")
if invocation_flag is not None and invocation_flag not in {"true", "false"}:
raise ValueError(
f"{sk}: disable-model-invocation 必须是 true 或 false"
)
seen.add(name)
entries.append((sk, fm))
if not entries:
raise ValueError(f"{skills_dir}: 未发现任何 SKILL.md")
return entries
def sync_roles(conn) -> int:
n = 0
for md in sorted(AGENTS_DIR.glob("*.md")):
@ -61,10 +100,11 @@ def sync_roles(conn) -> int:
def sync_skills(conn) -> int:
n = 0
for sk in sorted(SKILLS_DIR.glob("*/SKILL.md")):
names = []
for sk, fm in validate_skill_catalog():
text = sk.read_text(encoding="utf-8")
fm = parse_frontmatter(text)
name = fm.get("name") or sk.parent.name
name = fm["name"]
names.append(name)
tables = sorted(set(TABLE_RE.findall(text))) # 尽力抽取涉及的表名(不区分读写,待人工核)
model_used = None
if "MiniMax" in text:
@ -77,15 +117,29 @@ def sync_skills(conn) -> int:
VALUES (%s,%s,%s::jsonb,%s,%s,CURRENT_TIMESTAMP,'sync_agent_registry','sync_agent_registry')
ON CONFLICT (tenant_id, skill_name) DO UPDATE SET
purpose=EXCLUDED.purpose, reads=EXCLUDED.reads, model_used=EXCLUDED.model_used,
source_ref=EXCLUDED.source_ref, synced_at=CURRENT_TIMESTAMP, updater='sync_agent_registry'""",
source_ref=EXCLUDED.source_ref, synced_at=CURRENT_TIMESTAMP,
updater='sync_agent_registry', deleted=FALSE""",
(name, fm.get("description"),
json.dumps(tables, ensure_ascii=False) if tables else None,
model_used, str(sk.relative_to(ROOT))))
n += 1
conn.execute(
"""UPDATE example_skill
SET deleted=TRUE, synced_at=CURRENT_TIMESTAMP, updater='sync_agent_registry'
WHERE tenant_id=0 AND deleted=FALSE AND NOT (skill_name=ANY(%s))""",
(names,),
)
return n
def main():
parser = argparse.ArgumentParser(description="校验或同步 Agent/Skill 登记")
parser.add_argument("--check", action="store_true", help="只校验 Git 侧 Skill 目录,不连接数据库")
args = parser.parse_args()
catalog = validate_skill_catalog()
if args.check:
print(f"Skill 目录校验通过:{len(catalog)} 个")
return
with connect() as conn:
nr = sync_roles(conn)
ns = sync_skills(conn)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""db execparams 参数装载逻辑离线自测(不连库)。
跑法(仓库根目录):.venv/bin/python .claude/skills/db/scripts/test_db_params.py
跑法(仓库根目录):.venv/bin/python .claude/skills/access-database/scripts/test_db_params.py
"""
import io
import json

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Skill 目录命名与 frontmatter 一致性的离线测试。"""
import pathlib
import tempfile
import unittest
from sync_agent_registry import validate_skill_catalog
class SkillCatalogTest(unittest.TestCase):
def test_current_catalog_is_valid(self):
entries = validate_skill_catalog()
self.assertGreater(len(entries), 0)
self.assertEqual(len(entries), len({fm["name"] for _, fm in entries}))
def test_directory_and_name_must_match(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
skill = root / "plan-story"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: planning\ndescription: test\n---\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "目录名"):
validate_skill_catalog(root)
def test_name_must_be_action_object(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
skill = root / "runtime"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: runtime\ndescription: test\n---\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "动作-对象"):
validate_skill_catalog(root)
def test_unknown_frontmatter_key_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
skill = root / "plan-story"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: plan-story\ndescription: test\nunknown: value\n---\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "未登记字段"):
validate_skill_catalog(root)
if __name__ == "__main__":
unittest.main()

View File

@ -1,6 +1,6 @@
---
name: read-context
description: 统一创作数据读取器的操作合同。按冻结点从 PostgreSQL 读取可信来源,组装完整审计上下文,再为各角色生成最小可见投影。
name: assemble-context
description: 按冻结点从 PostgreSQL 读取可信来源,组装可审计上下文,并为 writer、detector、judge、planner 或 extractor 生成最小投影。创作或评测调用模型前需要受控上下文时使用。
disable-model-invocation: true
---

View File

@ -12,8 +12,8 @@ import json
import sys
from pathlib import Path
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts)
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
# 复用 access-database Skill 锁死的 DSN
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "access-database" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402
@ -41,20 +41,24 @@ def persist_freeze(assemble_result, *, reference_work_id=None, reference_version
as_of = ctx.get("asOf")
if as_of is None:
raise ValueError("assemble 结果缺 asOf,无法落冻结")
# 授权快照随冻结落库:接受前置检查(acceptance_state)重读它做实时比对,
# 不再信任编排层内存里的快照副本。
authorization = ctx.get("authorizationSnapshot")
auth_json = json.dumps(authorization, ensure_ascii=False) if isinstance(authorization, dict) else None
with connect() as conn:
try:
# manifest_sha256 唯一:同一冻结重放幂等。表是 append-only,冲突只能回读,不能 UPDATE。
row = conn.execute(
"INSERT INTO example_context_freeze(work_id, target_chapter, as_of_chapter, manifest_sha256, "
"context_sha256, reference_work_id, reference_version, arm_config, sections, token_budget, "
"omitted_sources, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s) "
"omitted_sources, authorization_snapshot, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s::jsonb,%s) "
"ON CONFLICT (manifest_sha256) DO NOTHING "
"RETURNING id, manifest_sha256, context_sha256",
(work_id, target_chapter, as_of, manifest_sha, context_sha, reference_work_id,
reference_version, json.dumps(arm_config, ensure_ascii=False) if arm_config is not None else None,
json.dumps(sections, ensure_ascii=False), used_chars,
json.dumps(omitted, ensure_ascii=False), CREATOR)).fetchone()
json.dumps(omitted, ensure_ascii=False), auth_json, CREATOR)).fetchone()
if not row:
row = conn.execute(
"SELECT id,manifest_sha256,context_sha256 FROM example_context_freeze "

View File

@ -43,9 +43,9 @@ class ProseRepository(Protocol):
def _load_search_cards() -> Callable[..., list[dict[str, Any]]]:
"""延迟导入 search skill,保持纯数据测试不触发嵌入依赖。"""
"""延迟导入 search-knowledge,保持纯数据测试不触发嵌入依赖。"""
search_scripts = pathlib.Path(__file__).resolve().parents[2] / "search" / "scripts"
search_scripts = pathlib.Path(__file__).resolve().parents[2] / "search-knowledge" / "scripts"
sys.path.insert(0, str(search_scripts))
from search import search_cards
@ -328,7 +328,7 @@ class ReplayCardIndexRepository:
) -> "ReplayCardIndexRepository":
"""复用 snapshot skill 的授权、冻结来源和内容泄露审计后构造仓储。"""
scripts = pathlib.Path(__file__).resolve().parents[2] / "snapshot" / "scripts"
scripts = pathlib.Path(__file__).resolve().parents[2] / "freeze-context" / "scripts"
sys.path.insert(0, str(scripts))
from audit_leakage import audit_snapshot
from check_snapshot import check_authorization, check_target_sources
@ -376,7 +376,7 @@ class FrozenProseRepository:
def read_source_refs(self, *, work_id: int, as_of: int, source_refs: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""延迟导入 snapshot skill,避免复制 SQL 或建立第二套权限语义。"""
scripts = pathlib.Path(__file__).resolve().parents[2] / "snapshot" / "scripts"
scripts = pathlib.Path(__file__).resolve().parents[2] / "freeze-context" / "scripts"
sys.path.insert(0, str(scripts))
from load_reference_work import load_frozen_prose_rows
@ -400,7 +400,7 @@ def load_confirmed_fine_outline(conn, *, work_id: int, target_chapter: int) -> d
"""读取指定章最新一条已确认细纲——read-context 是已确认细纲的唯一消费点。
取数合同与 planning SKILL 登记的 SQL 一致:section_type=fine_outline、state=confirmed、
未删除,按 version 倒序取最新。连接由调用方经 db skill 提供(本函数不自建连接、
未删除,按 version 倒序取最新。连接由调用方经 access-database 提供(本函数不自建连接、
不复制第二套权限语义)。缺已确认细纲或 payload 非法即失败关闭。
"""
row = conn.execute(

View File

@ -10,8 +10,8 @@ import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
sys.path.insert(0, str(SCRIPT_DIR.parents[1] / "snapshot" / "scripts"))
sys.path.insert(0, str(SCRIPT_DIR.parents[1] / "search" / "scripts"))
sys.path.insert(0, str(SCRIPT_DIR.parents[1] / "freeze-context" / "scripts"))
sys.path.insert(0, str(SCRIPT_DIR.parents[1] / "search-knowledge" / "scripts"))
from load_reference_work import begin_read_snapshot # noqa: E402
from search import search_cards # noqa: E402
@ -191,6 +191,28 @@ class RetrieveWriterSourcesTest(unittest.TestCase):
for _ in range(3):
self.assertEqual([item["cardId"] for item in stable_sort_cards(cards)], expected)
def test_plan_allows_asof_zero_for_first_chapter(self):
"""开篇基线章:asOf=0 是合法冻结线,计划与过滤器都接受。"""
plan = build_retrieval_plan(
run_id="run-ch1-baseline",
work_id=8,
target_chapter=1,
as_of=0,
fine_outline={
"entities": [],
"relations": [],
"locations": [],
"powerSystems": [],
"hardConstraints": ["开篇建立基调"],
},
card_index_version="cards-v1",
prose_index_version="prose-v1",
token_budget={"maxContextChars": 20000},
)
self.assertEqual(plan["asOf"], 0)
self.assertEqual(plan["filters"]["asOfChapter"], 0)
def test_plan_identity_excludes_run_id(self):
other = copy.deepcopy(self.plan)
other["runId"] = "run-b"

View File

@ -148,7 +148,57 @@ def valid_output(context: dict | None = None, *, body: str = "第一段正文。
return build_candidate_envelope(context or valid_context(), valid_draft(body=body))
def _resign(context: dict) -> None:
"""改动上下文字段后重算计划/清单/上下文三级身份哈希。"""
plan_payload = {key: value for key, value in context["retrievalPlan"].items() if key != "planId"}
context["retrievalPlan"]["planId"] = retrieval_identity(plan_payload)
context["retrievalManifest"]["planId"] = context["retrievalPlan"]["planId"]
manifest_payload = {key: value for key, value in context["retrievalManifest"].items() if key != "manifestId"}
context["retrievalManifest"]["manifestId"] = retrieval_identity(manifest_payload)
context["contextSnapshot"]["manifestId"] = context["retrievalManifest"]["manifestId"]
context["contextSnapshot"]["contextSha256"] = retrieval_identity(context)
def first_chapter_context() -> dict:
"""开篇基线章上下文:asOf=0(开篇前冻结线),无历史正文,证据只有设定/大纲/细纲。"""
context = valid_context()
context["targetChapter"] = 1
context["asOf"] = 0
context["retrievalPlan"]["asOf"] = 0
context["retrievalPlan"]["filters"]["asOfChapter"] = 0
context["proseEvidence"] = []
_resign(context)
return context
class WriterContractTest(unittest.TestCase):
def test_first_chapter_context_allows_asof_zero(self):
"""开篇基线章:asOf=0 是合法冻结线,正文基线为空。"""
normalized = validate_writer_context(first_chapter_context())
self.assertEqual(normalized["asOf"], 0)
self.assertEqual(normalized["proseEvidence"], [])
def test_asof_zero_rejects_any_historical_prose(self):
"""asOf=0 冻结线下,连第 1 章正文都属于未来正文,必须失败关闭。"""
context = first_chapter_context()
text = "第一章正文。"
context["proseEvidence"] = [{
"evidenceId": "prose:1", "chapter": 1,
"sourceRef": {"sourceId": "chapter:1", "sourceVersion": "chapter-1-v1",
"chapter": 1, "blockId": 1, "startCodePoint": 0,
"endCodePoint": len(text)},
"contentSha256": "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest(),
"purpose": "recent_full_chapter", "text": text, "isRecentBaseline": True,
}]
_resign(context)
with self.assertRaises(ContractError) as raised:
validate_writer_context(context)
self.assertIn("超出冻结线", str(raised.exception))
def test_index_hints_are_strict_diagnostic_only_and_frozen(self):
"""诊断卡索引提示只能进入不可接受的诊断上下文。"""

View File

@ -1,20 +1,20 @@
---
name: llm
description: New-API LLM 调用统一入口(默认 MiniMax-M3)。管线内所有内容生产型 LLM 调用(清洗探测、拆书抽取等)必须经此 skill 发起,不得裸调外部服务;主会话模型只负责固化提示词、发起调用与守卫验证。
name: call-content-model
description: 通过 New-API 的统一治理入口调用内容模型,执行额度窗口、模型降级、重试和 JSON 提取。清洗、拆书或知识审核需要 MiniMax 等内容模型时使用;不得裸调外部服务。
---
# llm —— New-API 统一调用入口
# 调用内容模型
创始人拍板(2026-07-13):清洗与拆书的内容生产 LLM **全部走 New-API 的 MiniMax-M3**;主会话(Fable5)只固化 agent/提示词/skill 与发起调用。本 skill 是唯一出口。
管线内容生产调用的**标准入口是 `chat_governed`**(受 5 小时额度窗 + 全局降级链治理);`chat`/`chat` CLI 是不受治理的直连,仅供调试。治理政策的机械事实源是 `.claude/skills/llm/scripts/llm.py` + `test_quota.py`(AGENTS.md §6 点名),模型链切换必须由该 skill 治理并留下日志。
管线内容生产调用的**标准入口是 `chat_governed`**(受 5 小时额度窗 + 全局降级链治理);`chat`/`chat` CLI 是不受治理的直连,仅供调试。治理政策的机械事实源是 `.claude/skills/call-content-model/scripts/llm.py` + `test_quota.py`(AGENTS.md §6 点名),模型链切换必须由该 skill 治理并留下日志。
## 用法
管线内所有内容生产型 LLM 调用(清洗探测、拆书抽取、知识卡审核等)**必须用 `chat_governed`**:
```python
# 其他 skill 内 import(clean_detect / parse-book / review-cards 的标准姿势)
# 其他 skill 内 import(clean_detect / deconstruct-book / review-knowledge-cards 的标准姿势)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
from llm import chat_governed, extract_json
@ -47,7 +47,7 @@ data = extract_json(content)
```bash
# 仅调试:读 prompt 文件直连调用,内容打到 stdout(token 用量与重试日志走 stderr)
.venv/bin/python .claude/skills/llm/scripts/llm.py chat --prompt-file /tmp/p.txt [--model MiniMax-M3] [--max-tokens 32000] [--out /tmp/resp.txt] [--extract-json]
.venv/bin/python .claude/skills/call-content-model/scripts/llm.py chat --prompt-file /tmp/p.txt [--model MiniMax-M3] [--max-tokens 32000] [--out /tmp/resp.txt] [--extract-json]
```
## 内建保障(chat 与 chat_governed 共用的单次调用机制,调用方不必重复实现)
@ -63,4 +63,4 @@ data = extract_json(content)
- token 为 New-API **普通令牌**(明文入仓是仓库政策);严禁改用管理令牌打 /v1。
- 可用模型以 `/v1/models` 为准(2026-07-13 在列:MiniMax-M3 / M2.x 系 / deepseek-v4-* / glm-5.2 / Qwen 嵌入与重排)。
- 嵌入调用不走本 skill(已有 embed skill,模型与维度钉死)。
- 嵌入调用不走本 Skill(已有 `embed-knowledge`,模型与维度钉死)。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""llm skill:New-API 统一调用入口(默认 MiniMax-M3)。
"""call-content-model Skill:New-API 统一调用入口(默认 MiniMax-M3)。
管线内所有内容生产型 LLM 调用(清洗探测/拆书抽取)必须经此入口:
- trust_env=False(本机代理环境变量会劫持内网直连,教训固化);
@ -59,10 +59,14 @@ class PlanQuotaExhausted(Exception):
def _default_persist_call(event):
"""按需加载 runtime 持久化器,避免 llm 单测和纯离线调用被迫连库。"""
runtime_scripts = pathlib.Path(__file__).resolve().parents[2] / "runtime" / "scripts"
if str(runtime_scripts) not in sys.path:
sys.path.insert(0, str(runtime_scripts))
"""按需加载运行证据持久化器,避免离线调用被迫连库。"""
evidence_scripts = (
pathlib.Path(__file__).resolve().parents[2]
/ "record-run-evidence"
/ "scripts"
)
if str(evidence_scripts) not in sys.path:
sys.path.insert(0, str(evidence_scripts))
from persist_llm_call import persist_call
return persist_call(event)

View File

@ -0,0 +1,98 @@
---
name: capture-ai-flavor-cases
description: 从已有作品或创作反馈中抽取可复核的 AI 味案例卡,保留来源哈希与位置并产出规则候选。需要反向积累人感样例、记录一次写作事故或把重复观察送入规则评审时使用;不直接修改正文、范式或生产规则。
disable-model-invocation: true
---
# 抽取 AI 味案例卡
## 目的与边界
本 Skill 只生产质量证据层的 `ai_flavor_case` 卡片。它不是作品实体卡,也不是公共范式卡;卡片默认处于 `shadow`,不能进入生成上下文,不能直接改变正文或规则。
输入有两条来源链:
- `backfill`:扫描已有作品,记录表面候选、全文哈希、章节/行位置和上下文。来源未明确授权时只保存哈希与位置,不把原文写入仓库。
- `live_feedback`:记录创作中发现的具体问题、候选正文哈希和人工判断,允许快速入卡,但同样先停在 `shadow`。
确定性脚本负责发现、哈希、状态、结构门禁和自动落库;“这是 AI 味还是有意写法”由人工/独立评审标注。不得把正例样本单独归纳成全局规则。
## 数据与副作用合同
- 读取:用户明确提供的文本文件,以及本 Skill 输出的案例卡 YAML/JSON。
- 写入:检测命令指定的回执文件,以及 `muse-example` 中的案例卡与重验证账本;不写正文、`knowledge/` 正式资产或生产规则目录。
- 自动落库:`scan`、`inventory`、`feedback` 在检测完成后自动以一个事务写入数据库。`--offline` 是显式例外,只用于离线合同测试或数据库恢复准备;不能把离线文件当正式内容。
- 恢复入口:`persist_cases.py` 只用于把已审计的 inventory/revalidation 文件恢复或迁移入库,正常检测不得依赖它单独执行。
- 模型:扫描、哈希、校验和候选归纳前置门不调用模型;语义标注可由独立评审完成,结果必须回写卡片的 review 字段。
- 失败:任何来源、哈希、状态或反例门失败都返回 `CASE_CARD_CONTRACT_FAILED`,不输出部分成功的规则。
## 运行
```bash
# 既有作品反向扫描;research_only 是默认安全值,输出 hash-only 卡
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py scan \
/path/to/work.txt --work-ref work-7 --output /tmp/ai-flavor-cases.yaml
# 命令结束时自动写入 muse-example;同时生成 .inventory.json/.revalidation.json 回执
# 批量回填并固化可复核清单;只写来源哈希、位置和观察,不复制第三方正文
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py inventory \
/path/to/works --source-root-ref 小说清单 --output /path/to/backfill-inventory.json \
--generated-on 2026-08-13
# 8 本作品的卡与逐卡重验证在同一检测运行中自动入库
# 创作反馈快速入卡(正文只在本次受控输入中读取)
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py feedback \
--text-file /tmp/candidate.txt --work-ref work-12 --run-ref run-abc \
--issue "段尾抽象升华没有功能" --output /tmp/feedback-card.yaml
# 创作反馈也会自动入库,保留 run_ref 绑定
# 明确只做离线构造(不会写库)
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py inventory \
/path/to/works --output /tmp/inventory.json --offline
# 对卡片做结构与来源门禁
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py validate \
/tmp/ai-flavor-cases.yaml
# 在确认/投影/规则使用前手工重验证来源;默认自动追加数据库回执。
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py revalidate \
/path/to/ai-flavor-cases.yaml --source-root /path/to/source-root \
--checked-on 2026-08-14 --output /tmp/ai-flavor-revalidation.json
# 仅需离线回执时显式加 --offline
# 只有已标注正反证据且来源重验证通过时才生成 candidate 规则;命令不会生成 active
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py propose-rule \
--cards /tmp/cards-a.yaml /tmp/cards-b.yaml \
--rule-id candidate-lexical-001 --name "空洞元话语" \
--verification /tmp/ai-flavor-revalidation.json \
--output /tmp/rule-candidate.yaml
```
`owned`、`licensed` 和 `public_domain` 才允许把片段写入卡;`research_only`、`unauthorized` 只能 hash-only,且永远不能确认。`source_sha256` 针对原始文件字节,`excerpt_sha256` 针对保存的片段,二者不可由模型自报。
重验证不会在看板打开时自动发生;它是确认、样例投影和规则消费前的显式 fail-closed 门。来源找不到不是“仍然有效”,而是 `unavailable`。
## 状态与升级
1. `scan`/`feedback` 产生 `shadow + unclassified`。
2. 人工标注为 `sf`(确实应修)、`snf`(表面相似但不应修)、`boundary` 或 `regression`;标注不等于确认。
3. 来源重验证结果为 `verified` 后,只有可审阅且获授权的卡才能 `canonical`,再投影为样例。
4. 规则候选至少需要两个不同来源作品,并同时包含一张 `sf` 与一张 `snf/boundary/regression` 卡;所有引用卡必须有 `verified` 回执。候选状态固定为 `candidate`。四类样例、跨任务回放和规则评审完成后,才由现有质量链决定是否激活。
详细字段和失败码见 [`references/case-card-contract.md`](references/case-card-contract.md)。
首版回填清单与候选规则种子见 [`references/fixtures/`](references/fixtures/);其中既有作品只保留 hash/位置,不能直接确认。
`backfill-inventory-*.json` 与 `revalidation-*.json` 是可复核的导出/恢复证据;正式内容在 `muse-example` 的
`example_ai_flavor_case`、`example_ai_flavor_revalidation_batch`、`example_ai_flavor_revalidation` 三张表。
`dashboard/server.py` 的 `/ai-flavor` 默认查这三张表,数据库不可用时才明确标注离线回退;页面不会因打开而重新读取原文。
状态语义:案例卡 `shadow` 只供复核,`canonical` 仅表示获授权且完成评审,`rejected`/`archived` 不进入生成上下文;重验证 `verified` 才能确认、投影样例或消费规则,`stale`(全文哈希变化)、`unavailable`(来源不可得)和 `card_mismatch`(锚点变化)都使当前卡在这些动作上失效,但历史回执保留。
## 机械验收
```bash
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py
.venv/bin/python .claude/skills/access-database/scripts/test_skill_catalog.py
git diff --check
```
测试必须覆盖:来源 hash、重验证 verified/stale/unavailable/card_mismatch、未授权 hash-only、重复 ID、live feedback 来源绑定、shadow 不能投影样例、缺重验证回执不能确认,以及跨作品/反例门。

View File

@ -0,0 +1,55 @@
# AI 味案例卡合同 v1
案例卡是质量与复利领域的证据载体。它记录“某段文本被怀疑有 AI 味,或被证明不应改”的可复核观察,不承担作品事实、公共范式或生产规则的职责。
## 必填字段
| 字段 | 合同 |
|---|---|
| `schema_version` | 固定 `ai-flavor-case-v1` |
| `id` | `case-` 加稳定哈希前缀;同一来源位置和模式只能有一个 ID |
| `card_type` | 固定 `ai_flavor_case` |
| `state` | `shadow` / `canonical` / `rejected` / `archived` |
| `label` | `unclassified` / `sf` / `snf` / `boundary` / `regression` |
| `layer` | `mechanical` / `lexical` / `structural` / `density` / `semantic` / `unknown` |
| `carrier` | `narration` / `dialogue` / `monologue` / `in_text_carrier` / `mixed` / `unknown` |
| `capture_mode` | `backfill` / `live_feedback` / `synthetic` / `review_import` |
| `source` | 来源种类、许可、`source_sha256`、位置和 `work_ref` |
| `observation` | 表面模式、问题判断、功能检查项、改动风险和建议动作 |
## 来源重验证
- `revalidate` 在使用卡片前重新读取受控来源的原始字节,计算当前 `source_sha256`,并与卡片保存的值比较。
- 全文哈希相同后还会检查 `location` 的 `excerpt_sha256` 与 `surface_location`;三者任一不一致,结果为 `card_mismatch`。
- 重验证结果是独立的不可变回执,不自动修改卡片的 `state`。结果状态固定为:`verified`(来源与锚点一致)、`stale`(全文哈希变化)、`unavailable`(来源不可读取)、`card_mismatch`(全文相同但卡片锚点不一致)。
- `canonical` 确认、样例投影和规则候选/规则使用必须携带对应卡片的 `verified` 回执;缺回执或回执哈希不匹配时 fail-closed。
- 检测命令完成时会把案例卡和重验证回执自动写入 `muse-example`;看板只读展示历史批次,打开页面不触发原文读取,也不写库。只有显式 `--offline` 才生成不入库的回执。
## 使用范围与失效
- `shadow`:只用于人工复核、聚类和候选提示;不能进入生成上下文、不能投影样例、不能成为 blocking 规则。
- `canonical`:必须有可保存授权、人工标签/评审和 `verified` 重验证;只表示这条证据可共享,不表示规则已经 active。
- `rejected` / `archived`:保留审计,不参与当前消费。
- `verified`:当前来源字节、片段锚点和表面锚点均一致,可用于确认、样例投影或规则候选评审。
- `stale` / `unavailable` / `card_mismatch`:当前使用立即失效;重新取得合法来源并重验证后才恢复。历史卡和历史回执不删除。
- 作用域默认是该卡的 `work_ref + source_ref + source_sha256 + capture_mode`;规则候选还必须满足跨作品和正反证据门。单卡、单作品或单次反馈不能扩大为全局规则。
## 来源规则
- `source_sha256` 是原始文件字节的 SHA-256 小写十六进制值。
- `excerpt_sha256` 是保存片段 UTF-8 字节的 SHA-256;没有片段时也必须保留它,便于在受控原文环境复核。
- `research_only` / `unauthorized` 来源必须 `excerpt: ""`、`context: ""`,状态只能是 `shadow`。
- `owned` / `licensed` / `public_domain` / `synthetic` 才能保存片段;`canonical` 必须有可审阅片段、非 `unclassified` 标签和 `review` 记录。
- 位置使用 `line_start`、`line_end`、`char_start`、`char_end`;哈希和位置不足以证明内容时,卡不能确认。
## 证据与规则边界
- `sf` 只表示“当前评审认为应修”,不是永远删除。
- `snf` 表示同一表面形式在上下文中有功能,提醒系统不要误修。
- `boundary` 表示需要任务合同或上下文裁决。
- `regression` 记录一次错误修复及其后果,优先用于回归门。
- 单张卡不能生成 active 规则。候选规则必须同时引用正反证据,且至少来自两个不同作品;规则状态由代码固定为 `candidate`。
## 与现有资产的关系
`canonical` 案例卡可以投影成四类样例,但样例必须保留 `case_card_id`、来源位置和许可。规则仍由 `review-knowledge-cards` 与质量/回放链审核;案例卡不替代 `meta/schemas` 的实体/范式卡。

View File

@ -0,0 +1,13 @@
# 首版 AI 味案例夹具
这些文件是检测运行的导出/恢复证据;正式内容自动写入 PostgreSQL `muse-example`,不是靠这些文件承载。
- `backfill-hash-only.yaml`:从本地既有作品扫描得到的第一批候选;只保留全文哈希、片段哈希、位置和待复核观察,不含第三方正文。
- `backfill-inventory-2026-08-13.json`:对 `小说清单/` 8 本作品的批量回填导出,共 788 张 hash-only Shadow 卡;同一 `inventory` 命令已经自动写入 `example_ai_flavor_case`。
- `revalidation-2026-08-14.json`:同一检测运行的来源重验证回执;当前 `verified=788`、`stale=0`、`unavailable=0`、`card_mismatch=0`,同时追加到 `example_ai_flavor_revalidation_batch` + `example_ai_flavor_revalidation`。
- `canonical-samples.yaml`:公版/合成短片段的已确认样例,用于验证卡→样例投影和反例门。
- `rule-candidates.yaml`:由两类以上来源、同时含正反证据的候选规则;状态固定为 `candidate`,不能直接加载为生产 `active`。
页面入口:启动 `dashboard/server.py` 后访问 `/ai-flavor`。页面默认只读展示 PostgreSQL 正式表;数据库不可用时才显示离线回退。确认、样例投影和规则消费必须携带 `verified` 回执。
样例标签含义:`sf` = 当前评审认为应修,`snf` = 表面相似但有功能不应修,`boundary` = 需上下文裁决,`regression` = 错误修复回归。

View File

@ -0,0 +1,80 @@
schema_version: ai-flavor-case-v1
cards:
- schema_version: ai-flavor-case-v1
id: case-backfill-jpxh-stock-expression-001
card_type: ai_flavor_case
state: shadow
label: unclassified
layer: lexical
carrier: unknown
capture_mode: backfill
excerpt: ''
context: ''
source:
kind: existing_work
license: research_only
source_sha256: b146aff2bcd8ab14472490fdbf5223d2752892aaab6830ca90740cf0707a0c1e
excerpt_sha256: 109835f69abc25b5c26ee1ed02a65957bd26407232125bad59dc4f0502a8eec1
source_ref: 小说清单/机破星河_当年离歌.txt
location: {line_start: 5955, line_end: 5955, char_start: 95420, char_end: 95436, column_start: 4, column_end: 20}
surface_location: {line_start: 5955, line_end: 5955, char_start: 95423, char_end: 95427, column_start: 7, column_end: 11}
work_ref: ref-work-机破星河
observation:
pattern_key: lexical.stock_micro_expression
surface: 嘴角勾起
diagnosis: 库存微表情候选;需检查是否是角色签名动作或场景独有反应。
function_check: [是否承担具体叙事功能, 是否是角色/场内载体的有意声线]
risk_if_changed: 未经上下文复核直接删除可能损失人物声音、伏笔或节奏。
suggested_action: 保留 shadow,回到受控原文复核。
- schema_version: ai-flavor-case-v1
id: case-backfill-jpxh-atmosphere-001
card_type: ai_flavor_case
state: shadow
label: unclassified
layer: lexical
carrier: unknown
capture_mode: backfill
excerpt: ''
context: ''
source:
kind: existing_work
license: research_only
source_sha256: b146aff2bcd8ab14472490fdbf5223d2752892aaab6830ca90740cf0707a0c1e
excerpt_sha256: 3adbb44fbe2382354bccd0421376393e63d7b861e7f9df8b78205ad142b876b5
source_ref: 小说清单/机破星河_当年离歌.txt
location: {line_start: 46431, line_end: 46431, char_start: 574508, char_end: 574547, column_start: 68, column_end: 107}
surface_location: {line_start: 46431, line_end: 46431, char_start: 574529, char_end: 574535, column_start: 89, column_end: 95}
work_ref: ref-work-机破星河
observation:
pattern_key: lexical.abstract_atmosphere
surface: 空气仿佛凝固
diagnosis: 抽象气氛候选;需检查是否有具体感官或场面功能支撑。
function_check: [是否承担具体叙事功能, 是否只是空泛气氛标签]
risk_if_changed: 直接删改可能抹掉高潮节奏或群体反应。
suggested_action: 保留 shadow,回到受控原文复核。
- schema_version: ai-flavor-case-v1
id: case-backfill-xhsm-stock-expression-001
card_type: ai_flavor_case
state: shadow
label: unclassified
layer: lexical
carrier: unknown
capture_mode: backfill
excerpt: ''
context: ''
source:
kind: existing_work
license: research_only
source_sha256: dd1a1e3aa20fd40bd147ffb8f7073ba9817f0a17587e71ba174de7a3955b5d59
excerpt_sha256: 02a168cf14ed2445845536b1131dfd70f67cd654e691278497af8d4e1a6523e1
source_ref: 小说清单/星环使命(虚伪王庭).txt
location: {line_start: 10694, line_end: 10694, char_start: 312102, char_end: 312152, column_start: 0, column_end: 50}
surface_location: {line_start: 10694, line_end: 10694, char_start: 312112, char_end: 312118, column_start: 10, column_end: 16}
work_ref: ref-work-星环使命
observation:
pattern_key: lexical.stock_micro_expression
surface: 嘴角微微上扬
diagnosis: 与其他作品出现同类表面形式;不能仅凭跨书频次认定应修。
function_check: [是否为角色签名动作, 是否在本段承担关系推进]
risk_if_changed: 误删可能抹平角色差异。
suggested_action: 等待正反证据和人工标注。

View File

@ -0,0 +1,56 @@
schema_version: ai-flavor-case-v1
cards:
- schema_version: ai-flavor-case-v1
id: case-synthetic-meta-001
card_type: ai_flavor_case
state: canonical
label: sf
layer: lexical
carrier: narration
capture_mode: synthetic
excerpt: 值得注意的是,门外下雨了。
context: 她抬头看了眼窗缝。值得注意的是,门外下雨了。她继续翻账本。
source:
kind: synthetic
license: synthetic
source_sha256: 6783305da4b565a52e97a2bbf99e7c1b2150eaf6d5598df14209ff009cb56338
excerpt_sha256: dd5a51981e22ca2c07be6b2f74bce7876a2bddcdafc22e173ac1138a92cfb1fc
source_ref: synthetic://ai-flavor-v1/meta-001
location: {line_start: 1, line_end: 1, char_start: 9, char_end: 22, column_start: 9, column_end: 22}
surface_location: {line_start: 1, line_end: 1, char_start: 9, char_end: 15, column_start: 9, column_end: 15}
work_ref: synthetic-work-a
observation:
pattern_key: lexical.meta_disclaimer
surface: 值得注意的是
diagnosis: 删除元话语后事实和动作不变,当前样例判定为应修。
function_check: [是否承担叙述者声线, 删除后信息是否保持]
risk_if_changed: 若属于固定评书腔,直接删除会损失声线。
suggested_action: 删除或改为具体动作,待任务声线合同复核。
review: {reviewer: fixture-review, note: 合成对照,确认本段没有额外功能}
- schema_version: ai-flavor-case-v1
id: case-synthetic-meta-002
card_type: ai_flavor_case
state: canonical
label: snf
layer: lexical
carrier: narration
capture_mode: synthetic
excerpt: 值得注意的是,这家茶馆只收旧账。
context: 说书人敲了敲桌面:值得注意的是,这家茶馆只收旧账。听众安静下来。
source:
kind: synthetic
license: synthetic
source_sha256: f9e0e88c10c1a199143431c5d7cb93e9159d117a6ad32763c32c243a751a654d
excerpt_sha256: 0468e1fc6513d05e6e809ded793ec951b76abc329dfd8c1f3796ec321652ad5c
source_ref: synthetic://ai-flavor-v1/meta-002
location: {line_start: 1, line_end: 1, char_start: 9, char_end: 25, column_start: 9, column_end: 25}
surface_location: {line_start: 1, line_end: 1, char_start: 9, char_end: 15, column_start: 9, column_end: 15}
work_ref: synthetic-work-b
observation:
pattern_key: lexical.meta_disclaimer
surface: 值得注意的是
diagnosis: 相同表面形式承担说书人节奏和悬念提示,当前样例判定为不应机械删除。
function_check: [是否为角色/叙述者固定声线, 是否改变信息节奏]
risk_if_changed: 删除会抹掉叙述者声线并削弱悬念落点。
suggested_action: 保留,或仅在声线合同允许时改写。
review: {reviewer: fixture-review, note: 合成反例,确认表面形式有叙事功能}

View File

@ -0,0 +1,13 @@
schema_version: ai-flavor-rule-candidate-v1
rules:
- id: candidate-lexical-meta-disclaimer-v1
name: 空洞元话语候选
status: candidate
default_disposition: candidate
layer: lexical
carrier_scope: narration
trigger: {type: phrase_family, pattern_key: lexical.meta_disclaimer}
fix_hint: 先检查信息、声线和节奏功能;无功能时删除或改成具体动作,不能按词表硬删。
case_card_ids: [case-synthetic-meta-001, case-synthetic-meta-002]
source_work_refs: [synthetic-work-a, synthetic-work-b]
evidence: 两个来源同时提供 sf 与 snf;仅作为候选,未进入生产规则。

View File

@ -0,0 +1,988 @@
#!/usr/bin/env python3
"""AI 味案例卡的确定性发现、自动落库与候选规则构造。
扫描/回填/创作反馈命令默认在检测完成后自动写入 agent-example 的
``muse-example``;``--offline`` 只用于明确的离线合同测试或恢复准备。
脚本不调用模型;语义判断由人工或独立评审补上。
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import re
import sys
from collections import Counter
from datetime import date
from pathlib import Path
from typing import Iterable
import yaml
# 作为 CLI 执行时也注册稳定模块名,自动落库模块复用同一份合同异常类型,
# 避免失败路径被重复 import 变成未捕获 traceback。
if __name__ == "__main__":
sys.modules.setdefault("capture_cases", sys.modules[__name__])
SCHEMA_VERSION = "ai-flavor-case-v1"
CARD_TYPE = "ai_flavor_case"
REVALIDATION_SCHEMA_VERSION = "ai-flavor-revalidation-v1"
STATES = {"shadow", "canonical", "rejected", "archived"}
LABELS = {"unclassified", "sf", "snf", "boundary", "regression"}
LAYERS = {"mechanical", "lexical", "structural", "density", "semantic", "unknown"}
CARRIERS = {"narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"}
CAPTURE_MODES = {"backfill", "live_feedback", "synthetic", "review_import"}
REVALIDATION_STATUSES = {"verified", "stale", "unavailable", "card_mismatch"}
LICENSES = {"owned", "licensed", "public_domain", "research_only", "unauthorized", "synthetic"}
NON_REPO_LICENSES = {"research_only", "unauthorized"}
STOREABLE_LICENSES = LICENSES - NON_REPO_LICENSES
class CaseCardError(ValueError):
"""案例卡合同或升级门禁失败。"""
PATTERNS = (
{
"key": "lexical.meta_disclaimer",
"layer": "lexical",
"pattern": r"(?:值得注意的是|值得一提的是|不言而喻|众所周知|换句话说)",
"diagnosis": "叙述者元话语可能没有新增信息;必须检查是否承担声线或节奏功能。",
},
{
"key": "lexical.stock_micro_expression",
"layer": "lexical",
"pattern": r"(?:嘴角(?:微微|轻轻|悄然)?(?:上扬|勾起)|眼中闪过(?:一丝|一抹)?(?:光|精光|异彩)|眸光(?:微闪|深邃))",
"diagnosis": "库存微表情候选;必须检查是否是角色签名动作或场景独有反应。",
},
{
"key": "lexical.abstract_atmosphere",
"layer": "lexical",
"pattern": r"(?:一股[^。!?\n]{0,24}气息[^。!?\n]{0,24}(?:弥漫|袭来|扑面而来)|空气仿佛凝固)",
"diagnosis": "抽象气氛候选;必须检查感官细节、因果和场面功能,不能看到词就删除。",
},
)
def _sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def text_sha256(text: str) -> str:
return _sha256_bytes(text.encode("utf-8"))
def _resolve_source_path(source_ref: str, source_root: Path | None = None) -> Path | None:
"""把脱敏来源引用解析为受控本地文件;URI 和无法落到文件的引用返回 None。"""
if not source_ref or "://" in source_ref:
return None
ref = Path(source_ref).expanduser()
if ref.is_absolute():
resolved = ref.resolve()
if source_root is not None:
try:
resolved.relative_to(source_root.expanduser().resolve())
except ValueError:
return None
return resolved
if source_root is None:
return None
root = source_root.expanduser().resolve()
candidates = [root / ref]
# inventory 默认把 source_root_ref(例如“小说清单”)写进 source_ref;
# 调用方也可以直接把 source_root 指到该目录,因此兼容两种入口。
if ref.parts and ref.parts[0] in {root.name, root.parent.name}:
candidates.append(root / Path(*ref.parts[1:]))
for candidate in candidates:
resolved = candidate.resolve()
try:
resolved.relative_to(root)
except ValueError:
continue
if resolved.is_file():
return resolved
return None
def _source_snapshot(*, source_ref: str, source_root: Path | None = None,
source_path: Path | None = None, source_text: str | None = None) -> dict:
"""读取一次来源,供单卡和批量重验证复用。"""
if source_text is not None and source_path is not None:
raise CaseCardError("source_text 与 source_path 不能同时提供")
if source_text is not None:
return {"status": "loaded", "actual_sha256": text_sha256(source_text), "text": source_text, "reason": ""}
path = source_path.expanduser().resolve() if source_path is not None else _resolve_source_path(source_ref, source_root)
if path is None:
return {"status": "unavailable", "actual_sha256": None, "text": None, "reason": "source_ref_not_file"}
try:
raw = path.read_bytes()
except OSError:
return {"status": "unavailable", "actual_sha256": None, "text": None, "reason": "source_not_found"}
actual_sha256 = _sha256_bytes(raw)
try:
text = raw.decode("utf-8")
except UnicodeError:
return {"status": "unavailable", "actual_sha256": actual_sha256, "text": None,
"reason": "source_not_utf8"}
return {"status": "loaded", "actual_sha256": actual_sha256, "text": text, "reason": ""}
def _card_anchor_reason(card: dict, text: str) -> str | None:
"""在全文哈希相同后,再检查卡片的位置和命中是否仍自洽。"""
source = card["source"]
location = source.get("location") or {}
start, end = location.get("char_start"), location.get("char_end")
if not isinstance(start, int) or not isinstance(end, int) or start < 0 or end < start or end > len(text):
return "card_anchor_out_of_range"
if text_sha256(text[start:end]) != source["excerpt_sha256"]:
return "card_excerpt_hash_mismatch"
surface_location = source.get("surface_location")
if surface_location:
surface_start, surface_end = surface_location.get("char_start"), surface_location.get("char_end")
expected_surface = card.get("observation", {}).get("surface", "")
if (not isinstance(surface_start, int) or not isinstance(surface_end, int)
or surface_start < 0 or surface_end < surface_start or surface_end > len(text)):
return "card_surface_anchor_out_of_range"
if text[surface_start:surface_end] != expected_surface:
return "card_surface_mismatch"
return None
def _revalidation_result(card: dict, snapshot: dict, *, checked_on: str) -> dict:
source = card["source"]
result = {
"card_id": card["id"],
"work_ref": source.get("work_ref", ""),
"source_ref": source.get("source_ref", ""),
"expected_source_sha256": source["source_sha256"],
"actual_source_sha256": snapshot.get("actual_sha256"),
"status": snapshot["status"],
"reason": snapshot.get("reason", ""),
"checked_on": checked_on,
}
if snapshot["status"] == "loaded":
if snapshot["actual_sha256"] != source["source_sha256"]:
result["status"] = "stale"
result["reason"] = "source_hash_mismatch"
else:
reason = _card_anchor_reason(card, snapshot["text"])
if reason:
result["status"] = "card_mismatch"
result["reason"] = reason
else:
result["status"] = "verified"
result["reason"] = "source_hash_and_anchor_match"
return result
def revalidate_card(card: dict, *, source_root: Path | None = None, source_path: Path | None = None,
source_text: str | None = None, checked_on: str | None = None) -> dict:
"""重新读取来源并返回不可变验证结果;不会自动改写案例卡状态。"""
validate_card(card)
snapshot = _source_snapshot(source_ref=card["source"].get("source_ref", ""),
source_root=source_root, source_path=source_path, source_text=source_text)
return _revalidation_result(card, snapshot, checked_on=checked_on or date.today().isoformat())
def revalidate_cards(cards: list[dict], *, source_root: Path | None = None,
checked_on: str | None = None) -> list[dict]:
"""批量重验证;同一来源只读取一次。"""
checked_on = checked_on or date.today().isoformat()
snapshots: dict[str, dict] = {}
results = []
for card in cards:
validate_card(card)
source_ref = card["source"].get("source_ref", "")
if source_ref not in snapshots:
snapshots[source_ref] = _source_snapshot(source_ref=source_ref, source_root=source_root)
results.append(_revalidation_result(card, snapshots[source_ref], checked_on=checked_on))
return results
def build_revalidation_report(cards: list[dict], *, source_root: Path | None = None,
checked_on: str | None = None,
source_texts: dict[str, str] | None = None) -> dict:
if source_texts is None:
results = revalidate_cards(cards, source_root=source_root, checked_on=checked_on)
else:
results = []
for card in cards:
if card["id"] not in source_texts:
raise CaseCardError(f"缺少案例卡 {card['id']} 的反馈正文")
results.append(revalidate_card(card, source_text=source_texts[card["id"]], checked_on=checked_on))
counts = Counter(result["status"] for result in results)
works: dict[tuple[str, str], dict] = {}
for result in results:
key = (result["work_ref"], result["source_ref"])
work = works.setdefault(key, {
"work_ref": result["work_ref"], "source_ref": result["source_ref"],
"expected_source_sha256": result["expected_source_sha256"],
"actual_source_sha256": result["actual_source_sha256"], "card_count": 0,
"status_counts": {},
})
work["card_count"] += 1
work["status_counts"][result["status"]] = work["status_counts"].get(result["status"], 0) + 1
if work["actual_source_sha256"] is None and result["actual_source_sha256"] is not None:
work["actual_source_sha256"] = result["actual_source_sha256"]
return {
"schema_version": REVALIDATION_SCHEMA_VERSION,
"checked_on": checked_on or date.today().isoformat(),
"source_root_ref": source_root.name if source_root else None,
"usable": bool(results) and all(result["status"] == "verified" for result in results),
"totals": {"cards": len(results), **{status: counts.get(status, 0) for status in sorted(REVALIDATION_STATUSES)}},
"works": list(sorted(works.values(), key=lambda item: (item["work_ref"], item["source_ref"]))),
"cards": results,
}
def _verification_results(verification) -> dict[str, dict]:
if not isinstance(verification, dict):
return {}
if verification.get("card_id"):
return {verification["card_id"]: verification}
items = verification.get("cards")
if not isinstance(items, list):
return {}
return {item.get("card_id"): item for item in items if isinstance(item, dict) and item.get("card_id")}
def require_verified(card: dict, verification) -> dict:
"""升级或消费前的 fail-closed 门:只接受本卡的 verified 回执。"""
validate_card(card)
result = _verification_results(verification).get(card["id"])
if (not result or result.get("status") != "verified"
or result.get("expected_source_sha256") != card["source"]["source_sha256"]
or result.get("actual_source_sha256") != card["source"]["source_sha256"]):
status = result.get("status") if result else "missing"
raise CaseCardError(f"案例卡 {card['id']} 来源未通过重验证: {status}")
return result
def require_verified_cards(cards: list[dict], verification) -> list[dict]:
return [require_verified(card, verification) for card in cards]
def _line_col(text: str, offset: int) -> tuple[int, int]:
line_start = text.count("\n", 0, offset) + 1
last_newline = text.rfind("\n", 0, offset)
return line_start, offset - (last_newline + 1)
def _location(text: str, start: int, end: int) -> dict:
line_start, col_start = _line_col(text, start)
line_end, col_end = _line_col(text, end)
return {
"line_start": line_start,
"line_end": line_end,
"char_start": start,
"char_end": end,
"column_start": col_start,
"column_end": col_end,
}
def _context(text: str, start: int, end: int) -> str:
"""取命中所在行及相邻一行,供有权保存的来源做人工复核。"""
line_start = text.rfind("\n", 0, start) + 1
line_end = text.find("\n", end)
if line_end < 0:
line_end = len(text)
previous_start = text.rfind("\n", 0, max(0, line_start - 1)) + 1
next_end = text.find("\n", line_end + 1)
if next_end < 0:
next_end = len(text)
return text[previous_start:next_end].strip("\n")
def _evidence_window(text: str, start: int, end: int, max_chars: int = 180) -> tuple[int, int]:
"""取命中词所在句的有限窗口,避免样例只有触发词或吞入整章。"""
left = max(text.rfind("。", 0, start), text.rfind("!", 0, start),
text.rfind("?", 0, start), text.rfind("\n", 0, start)) + 1
right_candidates = [p for p in (text.find("。", end), text.find("!", end),
text.find("?", end), text.find("\n", end)) if p >= 0]
right = min(right_candidates, default=len(text))
if right_candidates:
right += 1
if right - left > max_chars:
left = max(0, start - max_chars // 2)
right = min(len(text), max(end, start + max_chars // 2))
if right - left > max_chars:
right = left + max_chars
return left, right
def _require_string(value, field: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or (not allow_empty and not value.strip()):
raise CaseCardError(f"{field} 必须是{'可为空的' if allow_empty else ''}字符串")
return value
def validate_card(card: dict) -> dict:
"""验证单卡的字段和跨字段不变量。"""
if not isinstance(card, dict):
raise CaseCardError("卡片必须是对象")
for key in ("schema_version", "id", "card_type", "state", "label", "layer", "carrier", "capture_mode", "source", "observation"):
if key not in card:
raise CaseCardError(f"卡片缺少 {key}")
if card["schema_version"] != SCHEMA_VERSION:
raise CaseCardError(f"schema_version 必须为 {SCHEMA_VERSION}")
_require_string(card["id"], "id")
if not card["id"].startswith("case-"):
raise CaseCardError("id 必须以 case- 开头")
if card["card_type"] != CARD_TYPE:
raise CaseCardError("card_type 必须为 ai_flavor_case")
for field, allowed in (("state", STATES), ("label", LABELS), ("layer", LAYERS), ("carrier", CARRIERS), ("capture_mode", CAPTURE_MODES)):
if card[field] not in allowed:
raise CaseCardError(f"{field} 取值非法: {card[field]!r}")
source = card["source"]
if not isinstance(source, dict):
raise CaseCardError("source 必须是对象")
for key in ("kind", "license", "source_sha256", "excerpt_sha256", "location"):
if key not in source:
raise CaseCardError(f"source 缺少 {key}")
if source["kind"] not in {"existing_work", "creation_feedback", "synthetic", "public_domain"}:
raise CaseCardError(f"source.kind 非法: {source['kind']!r}")
if source["license"] not in LICENSES:
raise CaseCardError(f"source.license 非法: {source['license']!r}")
for key in ("source_sha256", "excerpt_sha256"):
if not re.fullmatch(r"[0-9a-f]{64}", source[key]):
raise CaseCardError(f"source.{key} 必须是 64 位小写 SHA-256")
if source["kind"] == "existing_work" and not source.get("work_ref"):
raise CaseCardError("existing_work 必须有 work_ref")
if card["capture_mode"] == "live_feedback" and source["kind"] != "creation_feedback":
raise CaseCardError("live_feedback 的 source.kind 必须为 creation_feedback")
location = source["location"]
if not isinstance(location, dict):
raise CaseCardError("source.location 必须是对象")
for key in ("line_start", "line_end", "char_start", "char_end"):
if not isinstance(location.get(key), int) or location[key] < 0:
raise CaseCardError(f"source.location.{key} 必须是非负整数")
if location["line_end"] < location["line_start"] or location["char_end"] < location["char_start"]:
raise CaseCardError("source.location 结束位置不能早于开始位置")
_require_string(card.get("excerpt", ""), "excerpt", allow_empty=True)
_require_string(card.get("context", ""), "context", allow_empty=True)
if source["license"] in NON_REPO_LICENSES:
if card.get("excerpt") or card.get("context"):
raise CaseCardError("未授权/研究限定来源必须 hash-only")
if card["state"] != "shadow":
raise CaseCardError("未授权/研究限定来源只能保持 shadow")
if card["state"] == "canonical":
if source["license"] not in STOREABLE_LICENSES:
raise CaseCardError("canonical 卡必须来自可保存的授权来源")
if not card.get("excerpt"):
raise CaseCardError("canonical 卡必须有可审阅片段")
if card["label"] == "unclassified":
raise CaseCardError("canonical 卡必须有评审标签")
review = card.get("review")
if not isinstance(review, dict) or not review.get("reviewer") or not review.get("note"):
raise CaseCardError("canonical 卡必须有 review.reviewer 和 review.note")
if card.get("excerpt") and source["excerpt_sha256"] != text_sha256(card["excerpt"]):
raise CaseCardError("excerpt_sha256 与 excerpt 不一致")
observation = card["observation"]
if not isinstance(observation, dict):
raise CaseCardError("observation 必须是对象")
for key in ("pattern_key", "surface", "diagnosis", "function_check", "risk_if_changed", "suggested_action"):
if key not in observation:
raise CaseCardError(f"observation 缺少 {key}")
for key in ("pattern_key", "surface", "diagnosis", "risk_if_changed", "suggested_action"):
_require_string(observation[key], f"observation.{key}")
if not isinstance(observation["function_check"], list) or not observation["function_check"]:
raise CaseCardError("observation.function_check 必须是非空数组")
return card
def _card_id(source_sha: str, start: int, end: int, pattern_key: str) -> str:
raw = f"{source_sha}:{start}:{end}:{pattern_key}".encode("utf-8")
return "case-" + _sha256_bytes(raw)[:20]
def build_case_card(*, text: str, source_sha256: str, source_kind: str, source_license: str,
source_ref: str, work_ref: str | None, start: int, end: int,
pattern: dict, capture_mode: str = "backfill", feedback: dict | None = None,
excerpt_start: int | None = None, excerpt_end: int | None = None) -> dict:
evidence_start = start if excerpt_start is None else excerpt_start
evidence_end = end if excerpt_end is None else excerpt_end
excerpt = text[evidence_start:evidence_end]
stored = source_license in STOREABLE_LICENSES
source = {
"kind": source_kind,
"license": source_license,
"source_sha256": source_sha256,
"excerpt_sha256": text_sha256(excerpt),
"source_ref": source_ref,
"location": _location(text, evidence_start, evidence_end),
"surface_location": _location(text, start, end),
}
if work_ref:
source["work_ref"] = work_ref
card = {
"schema_version": SCHEMA_VERSION,
"id": _card_id(source_sha256, start, end, pattern["key"]),
"card_type": CARD_TYPE,
"state": "shadow",
"label": "unclassified",
"layer": pattern["layer"],
"carrier": "unknown",
"capture_mode": capture_mode,
"excerpt": excerpt if stored else "",
"context": _context(text, start, end) if stored else "",
"source": source,
"observation": {
"pattern_key": pattern["key"],
"surface": text[start:end],
"diagnosis": pattern["diagnosis"],
"function_check": ["是否承担具体叙事功能", "是否是角色/场内载体的有意声线"],
"risk_if_changed": "未经上下文复核直接删除可能损失人物声音、伏笔或节奏。",
"suggested_action": "保留 shadow,补充上下文后再标注 sf/snf/boundary。",
},
}
if feedback is not None:
card["feedback"] = copy.deepcopy(feedback)
return validate_card(card)
def capture_file(path: Path, *, source_license: str = "research_only", source_kind: str = "existing_work",
work_ref: str | None = None, patterns: Iterable[dict] = PATTERNS,
max_cards: int = 500) -> list[dict]:
if source_license not in LICENSES:
raise CaseCardError(f"不支持的来源许可: {source_license}")
if source_kind not in {"existing_work", "public_domain", "synthetic"}:
raise CaseCardError("文件扫描 source_kind 必须是 existing_work/public_domain/synthetic")
raw = path.read_bytes()
text = raw.decode("utf-8")
source_sha = _sha256_bytes(raw)
cards = []
seen = set()
matches = []
for pattern in patterns:
matches.extend((match.start(), pattern["key"], pattern, match)
for match in re.finditer(pattern["pattern"], text, flags=re.MULTILINE))
for _start, _key, pattern, match in sorted(matches, key=lambda item: (item[0], item[1])):
evidence_start, evidence_end = _evidence_window(text, match.start(), match.end())
card = build_case_card(
text=text, source_sha256=source_sha, source_kind=source_kind,
source_license=source_license, source_ref=str(path), work_ref=work_ref,
start=match.start(), end=match.end(), pattern=pattern,
excerpt_start=evidence_start, excerpt_end=evidence_end,
)
if card["id"] not in seen:
cards.append(card)
seen.add(card["id"])
if len(cards) >= max_cards:
return cards
return cards
def capture_feedback(text: str, *, work_ref: str, run_ref: str, issue: str,
source_license: str = "owned", location: dict | None = None) -> dict:
if not text:
raise CaseCardError("反馈正文不能为空")
if not work_ref or not run_ref or not issue:
raise CaseCardError("反馈必须有 work_ref、run_ref 和 issue")
pattern = {
"key": "feedback.manual_observation",
"layer": "unknown",
"diagnosis": "创作反馈待人工归类,不把一次事故直接固化为规则。",
}
card = build_case_card(
text=text, source_sha256=text_sha256(text), source_kind="creation_feedback",
source_license=source_license, source_ref=run_ref, work_ref=work_ref,
start=0, end=len(text), pattern=pattern, capture_mode="live_feedback",
feedback={"run_ref": run_ref, "issue": issue, "candidate_sha256": text_sha256(text)},
)
card["source"]["location"] = location or {"line_start": 1, "line_end": text.count("\n") + 1,
"char_start": 0, "char_end": len(text),
"column_start": 0, "column_end": 0}
return validate_card(card)
def annotate_card(card: dict, *, label: str, carrier: str = "unknown", reviewer: str = "", note: str = "") -> dict:
validate_card(card)
if card["state"] != "shadow":
raise CaseCardError("只有 shadow 卡可以标注")
if label not in LABELS - {"unclassified"}:
raise CaseCardError(f"标注标签非法: {label}")
out = copy.deepcopy(card)
out["label"] = label
out["carrier"] = carrier
if reviewer or note:
out["review"] = {"reviewer": reviewer, "note": note}
return validate_card(out)
def confirm_card(card: dict, *, reviewer: str, note: str, verification=None) -> dict:
validate_card(card)
if card["source"]["license"] in NON_REPO_LICENSES:
raise CaseCardError("hash-only 卡不能在仓库内确认")
require_verified(card, verification)
out = copy.deepcopy(card)
out["state"] = "canonical"
out["review"] = {"reviewer": reviewer, "note": note}
return validate_card(out)
def project_sample(card: dict, *, verification=None) -> dict:
validate_card(card)
if card["state"] != "canonical":
raise CaseCardError("只有 canonical 卡可以投影样例")
require_verified(card, verification)
return {
"id": "sample-" + card["id"],
"type": card["label"],
"rules": list(card.get("rule_candidate_ids", [])),
"carrier": card["carrier"],
"source": card["source"]["license"],
"text": card["excerpt"],
"note": card["observation"]["diagnosis"],
"case_card_id": card["id"],
"source_ref": card["source"].get("source_ref", ""),
"source_license": card["source"]["license"],
}
def propose_rule(cards: list[dict], *, rule_id: str, name: str, fix_hint: str, verification=None) -> dict:
if not cards:
raise CaseCardError("规则候选至少需要一张案例卡")
for card in cards:
validate_card(card)
# 候选仍可保持 candidate,但其证据必须先证明对应来源没有漂移。
require_verified_cards(cards, verification)
works = {card["source"].get("work_ref") for card in cards if card["source"].get("work_ref")}
if len(works) < 2:
raise CaseCardError("规则候选至少需要两个不同来源作品")
labels = {card["label"] for card in cards}
if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}):
raise CaseCardError("规则候选必须同时有 sf 与 snf/boundary/regression 证据")
return {
"schema_version": "ai-flavor-rule-candidate-v1",
"id": rule_id,
"name": name,
"status": "candidate",
"default_disposition": "candidate",
"fix_hint": fix_hint,
"case_card_ids": [card["id"] for card in cards],
"source_work_refs": sorted(works),
"evidence": "由多来源案例卡归纳;待四类样例、回放和独立评审。",
}
def load_bundle(paths: Iterable[Path]) -> list[dict]:
cards = []
seen = set()
for path in paths:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
items = data.get("cards", []) if isinstance(data, dict) else data
if not isinstance(items, list):
raise CaseCardError(f"{path}: cards 必须是数组")
for card in items:
validate_card(card)
if card["id"] in seen:
raise CaseCardError(f"案例卡 id 重复: {card['id']}")
seen.add(card["id"])
cards.append(card)
return cards
def load_verification(path: Path) -> dict:
"""读取 revalidate 产出的 JSON/YAML 回执,并做最小结构门禁。"""
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict) or data.get("schema_version") != REVALIDATION_SCHEMA_VERSION:
raise CaseCardError(f"{path}: 不是 {REVALIDATION_SCHEMA_VERSION} 回执")
if not isinstance(data.get("cards"), list):
raise CaseCardError(f"{path}: 回执缺少 cards 数组")
for result in data["cards"]:
if not isinstance(result, dict) or result.get("status") not in REVALIDATION_STATUSES:
raise CaseCardError(f"{path}: 回执包含非法结果")
return data
def build_inventory(root: Path, *, source_license: str = "research_only",
source_kind: str = "existing_work", glob: str = "*.txt",
work_ref_prefix: str = "ref:", source_root_ref: str | None = None,
max_cards: int = 5000, generated_on: str | None = None) -> dict:
"""扫描目录并生成可持久化的汇总报告及完整 hash-only 卡清单。
报告保留每张卡的稳定 ID、模式、位置和来源哈希;当来源不是可保存许可时,
``capture_file`` 已将片段与上下文清空。``source_root_ref`` 只作为脱敏后的
相对引用写入报告,绝不把本机绝对路径带入共享资产。
"""
if max_cards <= 0:
raise CaseCardError("max_cards 必须为正整数")
if source_license not in LICENSES:
raise CaseCardError(f"不支持的来源许可: {source_license}")
if source_kind not in {"existing_work", "public_domain", "synthetic"}:
raise CaseCardError("目录扫描 source_kind 必须是 existing_work/public_domain/synthetic")
root = root.expanduser().resolve()
if not root.is_dir():
raise CaseCardError(f"扫描根目录不存在或不是目录: {root}")
paths = sorted(path for path in root.rglob(glob) if path.is_file())
if not paths:
raise CaseCardError(f"扫描根目录没有匹配 {glob!r} 的文件: {root}")
cards: list[dict] = []
works: list[dict] = []
total_patterns: Counter[str] = Counter()
seen_ids: set[str] = set()
prefix = source_root_ref.rstrip("/") if source_root_ref else ""
for path in paths:
relative = path.relative_to(root).as_posix()
work_name = str(Path(relative).with_suffix("")).replace("\\", "/")
work_ref = f"{work_ref_prefix}{work_name}"
raw = path.read_bytes()
source_ref = f"{prefix}/{relative}" if prefix else relative
file_cards = capture_file(
path,
source_license=source_license,
source_kind=source_kind,
work_ref=work_ref,
max_cards=max_cards,
)
pattern_counts = Counter(card["observation"]["pattern_key"] for card in file_cards)
total_patterns.update(pattern_counts)
for card in file_cards:
# 不改变卡片 ID;只将机器路径替换为报告中的相对来源引用。
card = copy.deepcopy(card)
card["source"]["source_ref"] = source_ref
validate_card(card)
if card["id"] in seen_ids:
raise CaseCardError(f"目录扫描产生重复案例卡 id: {card['id']}")
seen_ids.add(card["id"])
cards.append(card)
works.append({
"work_ref": work_ref,
"source_ref": source_ref,
"source_sha256": _sha256_bytes(raw),
"card_count": len(file_cards),
"pattern_counts": dict(sorted(pattern_counts.items())),
})
return {
"schema_version": "ai-flavor-inventory-v1",
"capture_schema_version": SCHEMA_VERSION,
"generated_on": generated_on or date.today().isoformat(),
"source_root_ref": source_root_ref or root.name,
"source_kind": source_kind,
"source_license": source_license,
"max_cards_per_work": max_cards,
"totals": {
"books": len(works),
"cards": len(cards),
"pattern_counts": dict(sorted(total_patterns.items())),
},
"works": works,
"cards": cards,
}
def write_yaml(path: Path, payload) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8")
def _file_sha256(path: Path) -> str:
return _sha256_bytes(path.read_bytes())
def _inventory_from_cards(cards: list[dict], *, generated_on: str | None = None,
source_root_ref: str | None = None,
source_kind: str = "existing_work",
source_license: str = "research_only") -> dict:
"""把单文件/反馈检测结果包装成统一 inventory 载荷,供自动落库。"""
works: dict[tuple[str, str], dict] = {}
patterns = Counter()
for card in cards:
source = card["source"]
key = (source.get("work_ref", ""), source.get("source_ref", ""))
work = works.setdefault(key, {
"work_ref": key[0], "source_ref": key[1],
"source_sha256": source["source_sha256"], "card_count": 0,
"pattern_counts": {},
})
pattern_key = card["observation"]["pattern_key"]
work["card_count"] += 1
work["pattern_counts"][pattern_key] = work["pattern_counts"].get(pattern_key, 0) + 1
patterns.update([pattern_key])
return {
"schema_version": "ai-flavor-inventory-v1",
"capture_schema_version": SCHEMA_VERSION,
"generated_on": generated_on or date.today().isoformat(),
"source_root_ref": source_root_ref,
"source_kind": source_kind,
"source_license": source_license,
"max_cards_per_work": len(cards),
"totals": {"books": len(works), "cards": len(cards),
"pattern_counts": dict(sorted(patterns.items()))},
"works": [dict(item, pattern_counts=dict(sorted(item["pattern_counts"].items())))
for item in sorted(works.values(), key=lambda value: (value["work_ref"], value["source_ref"]))],
"cards": cards,
}
def _persist_detection(*, inventory: dict, verification: dict, inventory_path: Path,
verification_path: Path, offline: bool = False) -> dict:
"""检测命令统一落库边界;失败即让 CLI 失败,不报假绿。"""
if offline:
return {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
# 延迟导入,保持纯函数测试不建立数据库连接,同时避免循环导入。
from persist_cases import persist_generated
try:
return persist_generated(
inventory=inventory,
verification=verification,
inventory_ref=f"capture://ai-flavor/{inventory_path.name}",
report_ref=f"capture://ai-flavor/{verification_path.name}",
inventory_sha256=_file_sha256(inventory_path),
report_sha256=_file_sha256(verification_path),
)
except CaseCardError:
raise
except Exception as exc:
raise CaseCardError(f"自动落库失败(事务已回滚): {type(exc).__name__}: {exc}") from exc
def _write_revalidation(path: Path, report: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _load_inventory_file(path: Path) -> dict:
"""读取 inventory 载荷,供显式重验证落库使用。"""
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise CaseCardError(f"{path}: inventory 读取失败: {exc}") from exc
if not isinstance(data, dict) or data.get("schema_version") != "ai-flavor-inventory-v1":
raise CaseCardError(f"{path}: 不是 ai-flavor-inventory-v1 清单")
cards = data.get("cards")
if not isinstance(cards, list):
raise CaseCardError(f"{path}: inventory.cards 必须是数组")
for card in cards:
validate_card(card)
return data
def _sidecar(path: Path, suffix: str) -> Path:
return path.with_name(f"{path.stem}{suffix}")
def _detection_paths(output: Path, *, inventory_command: bool = False) -> tuple[Path, Path]:
"""为每次检测生成不可能互相覆盖的 inventory/revalidation 文件名。"""
if inventory_command:
if "inventory" in output.stem:
verification = output.with_name(
f"{output.stem.replace('inventory', 'revalidation', 1)}{output.suffix}"
)
else:
verification = output.with_name(f"{output.stem}.revalidation{output.suffix}")
if verification == output:
verification = output.with_name(f"{output.stem}.revalidation{output.suffix}")
return output, verification
return output.with_suffix(".inventory.json"), output.with_suffix(".revalidation.json")
def _run_detection_persistence(*, cards: list[dict], inventory: dict,
inventory_path: Path, verification_path: Path,
source_root: Path | None = None,
source_texts: dict[str, str] | None = None,
checked_on: str | None = None, offline: bool = False) -> dict:
"""所有检测命令共用:生成回执文件后立即写入数据库。"""
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
verification = build_revalidation_report(
cards, source_root=source_root, source_texts=source_texts, checked_on=checked_on,
)
# 把逻辑回执引用纳入报告哈希:同一输出路径重跑幂等,换一次检测产物则留下新批次;
# 不把本机绝对路径写进正式库。
verification["report_ref"] = f"capture://ai-flavor/{verification_path.name}"
_write_revalidation(verification_path, verification)
if offline:
return {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
return _persist_detection(
inventory=inventory, verification=verification,
inventory_path=inventory_path, verification_path=verification_path,
)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="发现并验证 AI 味案例卡")
sub = parser.add_subparsers(dest="command", required=True)
scan = sub.add_parser("scan")
scan.add_argument("path", type=Path)
scan.add_argument("--work-ref")
scan.add_argument("--license", dest="source_license", default="research_only", choices=sorted(LICENSES))
scan.add_argument("--source-kind", default="existing_work", choices=["existing_work", "public_domain", "synthetic"])
scan.add_argument("--output", type=Path, required=True)
scan.add_argument("--max-cards", type=int, default=500)
scan.add_argument("--offline", action="store_true", help="只生成文件,不自动写 muse-example")
feedback = sub.add_parser("feedback")
feedback.add_argument("--text-file", type=Path, required=True)
feedback.add_argument("--work-ref", required=True)
feedback.add_argument("--run-ref", required=True)
feedback.add_argument("--issue", required=True)
feedback.add_argument("--license", dest="source_license", default="owned", choices=sorted(LICENSES))
feedback.add_argument("--output", type=Path, required=True)
feedback.add_argument("--offline", action="store_true", help="只生成文件,不自动写 muse-example")
validate = sub.add_parser("validate")
validate.add_argument("path", type=Path)
revalidate = sub.add_parser("revalidate")
revalidate.add_argument("cards", type=Path, help="案例卡 YAML/JSON 或 inventory 报告")
revalidate.add_argument("--source-root", type=Path,
help="受控来源根目录;不传则只能验证卡片中的绝对 source_ref")
revalidate.add_argument("--output", type=Path, required=True)
revalidate.add_argument("--checked-on")
revalidate.add_argument("--offline", action="store_true", help="只生成回执,不自动写 muse-example")
propose = sub.add_parser("propose-rule")
propose.add_argument("--cards", type=Path, nargs="+", required=True)
propose.add_argument("--rule-id", required=True)
propose.add_argument("--name", required=True)
propose.add_argument("--fix-hint", default="待独立评审决定")
propose.add_argument("--verification", type=Path, required=True,
help="revalidate 命令生成的来源重验证回执")
propose.add_argument("--output", type=Path, required=True)
inventory = sub.add_parser("inventory")
inventory.add_argument("root", type=Path)
inventory.add_argument("--glob", default="*.txt")
inventory.add_argument("--source-root-ref")
inventory.add_argument("--work-ref-prefix", default="ref:")
inventory.add_argument("--license", dest="source_license", default="research_only", choices=sorted(LICENSES))
inventory.add_argument("--source-kind", default="existing_work", choices=["existing_work", "public_domain", "synthetic"])
inventory.add_argument("--output", type=Path, required=True)
inventory.add_argument("--max-cards", type=int, default=5000)
inventory.add_argument("--generated-on")
inventory.add_argument("--offline", action="store_true", help="只生成文件,不自动写 muse-example")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if args.command == "scan":
cards = capture_file(args.path, source_license=args.source_license, source_kind=args.source_kind,
work_ref=args.work_ref, max_cards=args.max_cards)
# 单文件扫描也遵守脱敏来源合同;重验证仍通过受控 source_root 读取真实文件。
cards = [copy.deepcopy(card) for card in cards]
for card in cards:
card["source"]["source_ref"] = args.path.name
inventory = _inventory_from_cards(cards, source_root_ref=args.path.parent.name,
source_kind=args.source_kind, source_license=args.source_license)
inventory_path, verification_path = _detection_paths(args.output)
result = _run_detection_persistence(
cards=cards, inventory=inventory, inventory_path=inventory_path,
verification_path=verification_path, source_root=args.path.parent,
checked_on=date.today().isoformat(), offline=args.offline,
)
# 保留原有 YAML 作为人工复核输入,但它不是正式权威。
write_yaml(args.output, {"schema_version": SCHEMA_VERSION, "cards": cards})
print(json.dumps({"cards": len(cards), "output": str(args.output),
"inventory": str(inventory_path), "revalidation": str(verification_path),
"persistence": result}, ensure_ascii=False))
elif args.command == "feedback":
text = args.text_file.read_text(encoding="utf-8")
card = capture_feedback(text, work_ref=args.work_ref,
run_ref=args.run_ref, issue=args.issue, source_license=args.source_license)
inventory = _inventory_from_cards([card], source_root_ref="live_feedback",
source_kind="creation_feedback", source_license=args.source_license)
inventory_path, verification_path = _detection_paths(args.output)
result = _run_detection_persistence(
cards=[card], inventory=inventory, inventory_path=inventory_path,
verification_path=verification_path,
source_texts={card["id"]: text}, checked_on=date.today().isoformat(), offline=args.offline,
)
write_yaml(args.output, {"schema_version": SCHEMA_VERSION, "cards": [card]})
print(json.dumps({"cards": 1, "output": str(args.output),
"inventory": str(inventory_path), "revalidation": str(verification_path),
"persistence": result}, ensure_ascii=False))
elif args.command == "validate":
cards = load_bundle([args.path])
print(json.dumps({"valid": True, "cards": len(cards)}, ensure_ascii=False))
elif args.command == "revalidate":
cards = load_bundle([args.cards])
report = build_revalidation_report(
cards,
source_root=args.source_root,
checked_on=args.checked_on,
)
report["report_ref"] = f"capture://ai-flavor/{args.output.name}"
_write_revalidation(args.output, report)
try:
inventory = _load_inventory_file(args.cards)
except CaseCardError:
inventory = _inventory_from_cards(
cards,
source_root_ref="manual-revalidate",
source_kind=cards[0]["source"].get("kind", "existing_work") if cards else "existing_work",
source_license=cards[0]["source"].get("license", "research_only") if cards else "research_only",
)
if not args.offline:
# 手工重验证也是检测运行;默认自动追加批次/逐卡回执,--offline 才不写库。
inventory_path = args.cards.with_name(f"{args.cards.stem}.inventory.json")
if args.cards.name.endswith(".inventory.json"):
inventory_path = args.cards
# 单卡 YAML 不是正式 inventory;先写 sidecar,保证自动落库有稳定的
# inventory hash 和可恢复引用,不要求用户再调用导入脚本。
if inventory_path != args.cards:
inventory_path.parent.mkdir(parents=True, exist_ok=True)
inventory_path.write_text(
json.dumps(inventory, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
verification_path = args.output
persistence = _persist_detection(
inventory=inventory, verification=report,
inventory_path=inventory_path, verification_path=verification_path,
)
else:
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
print(json.dumps({
"cards": report["totals"]["cards"],
"verified": report["totals"]["verified"],
"stale": report["totals"]["stale"],
"unavailable": report["totals"]["unavailable"],
"card_mismatch": report["totals"]["card_mismatch"],
"usable": report["usable"],
"output": str(args.output),
"persistence": persistence,
}, ensure_ascii=False))
elif args.command == "propose-rule":
cards = load_bundle(args.cards)
verification = load_verification(args.verification)
rule = propose_rule(cards, rule_id=args.rule_id, name=args.name, fix_hint=args.fix_hint,
verification=verification)
write_yaml(args.output, rule)
print(json.dumps({"status": rule["status"], "cards": len(cards), "output": str(args.output)}, ensure_ascii=False))
else:
report = build_inventory(
args.root,
source_license=args.source_license,
source_kind=args.source_kind,
glob=args.glob,
work_ref_prefix=args.work_ref_prefix,
source_root_ref=args.source_root_ref,
max_cards=args.max_cards,
generated_on=args.generated_on,
)
inventory_path, verification_path = _detection_paths(args.output, inventory_command=True)
persistence = _run_detection_persistence(
cards=report["cards"], inventory=report,
inventory_path=args.output, verification_path=verification_path,
source_root=args.root, checked_on=report["generated_on"], offline=args.offline,
)
print(json.dumps({"books": report["totals"]["books"], "cards": report["totals"]["cards"],
"output": str(args.output), "revalidation": str(verification_path),
"persistence": persistence}, ensure_ascii=False))
return 0
except (CaseCardError, OSError, UnicodeError) as exc:
print(f"CASE_CARD_CONTRACT_FAILED: {exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""把 AI 味案例卡与来源重验证回执写入 agent-example 的 muse-example。
采集脚本保持确定性、可离线回放;本脚本是唯一的持久化边界,复用
``access-database`` 的连接入口。案例卡做幂等当前投影,重验证批次/回执做
append-only 账本。研究限定来源只写 hash、位置和观察,不写第三方正文。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
import yaml
SCRIPT_DIR = Path(__file__).resolve().parent
DB_SCRIPTS = SCRIPT_DIR.parents[1] / "access-database" / "scripts"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
if str(DB_SCRIPTS) not in sys.path:
sys.path.insert(0, str(DB_SCRIPTS))
from capture_cases import ( # noqa: E402
CaseCardError,
REVALIDATION_SCHEMA_VERSION,
SCHEMA_VERSION,
load_verification,
validate_card,
)
from db import connect # noqa: E402
TENANT_ID = 1
CREATOR = "1"
def _logical_ref(path: Path) -> str:
"""落库只保留可迁移的证据名,不把本机绝对路径当跨环境引用。"""
return f"capture://ai-flavor/{path.name}"
def _sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _load_object(path: Path) -> dict:
try:
value = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise CaseCardError(f"{path}: 读取失败: {exc}") from exc
if not isinstance(value, dict):
raise CaseCardError(f"{path}: 顶层必须是对象")
return value
def load_inventory(path: Path) -> dict:
"""加载并逐卡验证 inventory;返回可安全写入的报告对象。"""
report = _load_object(path)
if report.get("schema_version") != "ai-flavor-inventory-v1":
raise CaseCardError(f"{path}: 不是 ai-flavor-inventory-v1 清单")
cards = report.get("cards")
if not isinstance(cards, list):
raise CaseCardError(f"{path}: cards 必须是数组")
seen: set[str] = set()
for card in cards:
validate_card(card)
if card["id"] in seen:
raise CaseCardError(f"{path}: 案例卡 id 重复: {card['id']}")
seen.add(card["id"])
totals = report.get("totals") or {}
if totals.get("cards") != len(cards):
raise CaseCardError(f"{path}: totals.cards 与 cards 数量不一致")
return report
def _validate_pair(inventory: dict, verification: dict, *, inventory_ref: str,
report_ref: str, inventory_sha256: str, report_sha256: str) -> dict:
"""校验一次检测产出的卡片集合和同运行重验证回执。"""
if inventory.get("schema_version") != "ai-flavor-inventory-v1":
raise CaseCardError("inventory 不是 ai-flavor-inventory-v1 清单")
cards = inventory.get("cards")
if not isinstance(cards, list):
raise CaseCardError("inventory.cards 必须是数组")
for card in cards:
validate_card(card)
verification = verification if isinstance(verification, dict) else {}
if verification.get("schema_version") != REVALIDATION_SCHEMA_VERSION:
raise CaseCardError("重验证版本不支持")
if verification.get("report_ref") and verification["report_ref"] != report_ref:
raise CaseCardError("重验证 report_ref 与导入引用不一致")
results = verification["cards"]
if not isinstance(results, list):
raise CaseCardError("重验证 cards 必须是数组")
card_ids = {card["id"] for card in cards}
result_ids = {item.get("card_id") for item in results}
if card_ids != result_ids:
missing = sorted(card_ids - result_ids)
extra = sorted(result_ids - card_ids)
raise CaseCardError(f"清单/重验证卡片集合不一致: missing={missing[:3]} extra={extra[:3]}")
by_id = {card["id"]: card for card in cards}
for result in results:
card = by_id[result["card_id"]]
source_sha = card["source"]["source_sha256"]
if result.get("expected_source_sha256") != source_sha:
raise CaseCardError(f"{result['card_id']}: expected_source_sha256 与卡片不一致")
if result.get("checked_on") != verification.get("checked_on"):
raise CaseCardError(f"{result['card_id']}: checked_on 与批次不一致")
return {
"inventory": inventory,
"verification": verification,
"inventory_sha256": inventory_sha256,
"report_sha256": report_sha256,
"inventory_ref": inventory_ref,
"report_ref": report_ref,
}
def prepare_import(inventory_path: Path, verification_path: Path) -> dict:
"""准备恢复/迁移导入;正常检测不需要调用此函数。"""
return _validate_pair(
load_inventory(inventory_path), load_verification(verification_path),
inventory_ref=_logical_ref(inventory_path), report_ref=_logical_ref(verification_path),
inventory_sha256=_sha256_file(inventory_path), report_sha256=_sha256_file(verification_path),
)
def persist_generated(*, inventory: dict, verification: dict, inventory_ref: str,
report_ref: str, inventory_sha256: str, report_sha256: str,
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""检测命令的自动写入口;不要求先生成可导入文件。"""
prepared = _validate_pair(
inventory, verification, inventory_ref=inventory_ref, report_ref=report_ref,
inventory_sha256=inventory_sha256, report_sha256=report_sha256,
)
return persist(prepared, creator=creator, tenant_id=tenant_id)
def _card_params(card: dict, prepared: dict, *, creator: str, tenant_id: int) -> tuple:
source = card["source"]
return (
card["id"], card["schema_version"], card["card_type"], card["state"],
card["label"], card["layer"], card["carrier"], card["capture_mode"],
source["kind"], source["license"], source["source_sha256"], source["excerpt_sha256"],
source.get("source_ref", ""), source.get("work_ref"), _json(source["location"]),
_json(source.get("surface_location")) if source.get("surface_location") else None,
card.get("excerpt", ""), card.get("context", ""), _json(card["observation"]),
_json(card["feedback"]) if card.get("feedback") is not None else None,
_json(card["review"]) if card.get("review") is not None else None,
_json(card.get("rule_candidate_ids", [])), prepared["inventory_ref"],
prepared["inventory_sha256"], creator, creator, tenant_id,
)
def persist(prepared: dict, *, creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""事务性导入;重复运行不重复插入批次/逐卡回执。"""
inventory = prepared["inventory"]
verification = prepared["verification"]
cards = inventory["cards"]
results = verification["cards"]
batch_sql = (
"INSERT INTO example_ai_flavor_revalidation_batch "
"(batch_ref,report_sha256,schema_version,checked_on,source_root_ref,usable,totals,works,report_path,creator,tenant_id) "
"VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s,%s) "
"ON CONFLICT (tenant_id,report_sha256) DO NOTHING RETURNING id"
)
card_sql = (
"INSERT INTO example_ai_flavor_case "
"(card_id,schema_version,card_type,state,label,layer,carrier,capture_mode,source_kind,source_license,"
"source_sha256,excerpt_sha256,source_ref,work_ref,source_location,surface_location,excerpt,context,observation,"
"feedback,review,rule_candidate_ids,inventory_ref,inventory_sha256,creator,updater,tenant_id) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s,%s,%s,%s) "
"ON CONFLICT (tenant_id,card_id) DO UPDATE SET "
"schema_version=EXCLUDED.schema_version,card_type=EXCLUDED.card_type,source_kind=EXCLUDED.source_kind,"
"source_license=EXCLUDED.source_license,source_sha256=EXCLUDED.source_sha256,excerpt_sha256=EXCLUDED.excerpt_sha256,"
"source_ref=EXCLUDED.source_ref,work_ref=EXCLUDED.work_ref,source_location=EXCLUDED.source_location,"
"surface_location=EXCLUDED.surface_location,observation=EXCLUDED.observation,feedback=EXCLUDED.feedback,"
"rule_candidate_ids=EXCLUDED.rule_candidate_ids,inventory_ref=EXCLUDED.inventory_ref,"
"inventory_sha256=EXCLUDED.inventory_sha256,updater=EXCLUDED.updater,update_time=CURRENT_TIMESTAMP "
"WHERE example_ai_flavor_case.state NOT IN ('canonical','rejected','archived')"
)
result_sql = (
"INSERT INTO example_ai_flavor_revalidation "
"(batch_id,card_id,work_ref,source_ref,expected_source_sha256,actual_source_sha256,status,reason,checked_on,creator,tenant_id) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
"ON CONFLICT (tenant_id,batch_id,card_id) DO NOTHING"
)
with connect() as conn:
try:
batch_row = conn.execute(
batch_sql,
(prepared["report_ref"], prepared["report_sha256"], verification["schema_version"],
verification["checked_on"], verification.get("source_root_ref"), verification["usable"],
_json(verification.get("totals", {})), _json(verification.get("works", [])),
prepared["report_ref"], creator, tenant_id),
).fetchone()
if batch_row:
batch_id = batch_row[0]
else:
batch_id = conn.execute(
"SELECT id FROM example_ai_flavor_revalidation_batch WHERE tenant_id=%s AND report_sha256=%s",
(tenant_id, prepared["report_sha256"]),
).fetchone()[0]
for card in cards:
conn.execute(card_sql, _card_params(card, prepared, creator=creator, tenant_id=tenant_id))
for result in results:
conn.execute(result_sql, (
batch_id, result["card_id"], result.get("work_ref"), result.get("source_ref", ""),
result["expected_source_sha256"], result.get("actual_source_sha256"), result["status"],
result.get("reason", ""), result["checked_on"], creator, tenant_id,
))
counts = conn.execute(
"SELECT count(*) FILTER (WHERE deleted=false), "
"count(*) FILTER (WHERE deleted=false AND state='shadow') "
"FROM example_ai_flavor_case WHERE tenant_id=%s", (tenant_id,),
).fetchone()
receipt_count = conn.execute(
"SELECT count(*) FROM example_ai_flavor_revalidation WHERE tenant_id=%s AND batch_id=%s",
(tenant_id, batch_id),
).fetchone()[0]
conn.commit()
except Exception:
conn.rollback()
raise
return {
"status": "written",
"batch_id": batch_id,
"inventory_cards": len(cards),
"case_rows": int(counts[0]),
"shadow_rows": int(counts[1]),
"revalidation_rows": int(receipt_count),
"report_sha256": prepared["report_sha256"],
}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AI 味案例卡与重验证回执落库")
parser.add_argument("inventory", type=Path, help="ai-flavor-inventory-v1 JSON/YAML")
parser.add_argument("verification", type=Path, help="ai-flavor-revalidation-v1 JSON/YAML")
parser.add_argument("--creator", default=CREATOR)
parser.add_argument("--tenant-id", type=int, default=TENANT_ID)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
prepared = prepare_import(args.inventory, args.verification)
print(json.dumps(persist(prepared, creator=args.creator, tenant_id=args.tenant_id),
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (CaseCardError, OSError, KeyError, TypeError, IndexError) as exc:
print(f"CASE_CARD_PERSIST_FAILED: {exc}", file=sys.stderr)
raise SystemExit(2)

View File

@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""AI 味案例卡离线合同测试;不连接数据库、不调用模型。"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import yaml
from capture_cases import (
CaseCardError,
PATTERNS,
annotate_card,
capture_feedback,
capture_file,
build_inventory,
confirm_card,
project_sample,
propose_rule,
build_revalidation_report,
revalidate_card,
text_sha256,
validate_card,
main,
)
class CaptureCasesTest(unittest.TestCase):
def test_inventory_is_deterministic_and_keeps_only_hash_for_research_sources(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "b.txt").write_text("值得注意的是。\n", encoding="utf-8")
(root / "a.txt").write_text("嘴角微微上扬。\n", encoding="utf-8")
report = build_inventory(root, source_root_ref="fixture", generated_on="2026-08-13")
self.assertEqual("ai-flavor-inventory-v1", report["schema_version"])
self.assertEqual({"books": 2, "cards": 2},
{key: report["totals"][key] for key in ("books", "cards")})
self.assertEqual(2, len({card["id"] for card in report["cards"]}))
self.assertTrue(all(card["state"] == "shadow" and not card["excerpt"] for card in report["cards"]))
self.assertTrue(all(not card["source"]["source_ref"].startswith("/") for card in report["cards"]))
def test_backfill_research_only_is_hash_only_and_replayable(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("第一行。值得注意的是,门外下雨了。\n", encoding="utf-8")
cards = capture_file(path, work_ref="work-a")
self.assertEqual(1, len(cards))
card = cards[0]
self.assertEqual("shadow", card["state"])
self.assertEqual("", card["excerpt"])
self.assertEqual("", card["context"])
self.assertEqual(text_sha256("值得注意的是,门外下雨了。"), card["source"]["excerpt_sha256"])
self.assertEqual(1, card["source"]["location"]["line_start"])
validate_card(card)
def test_owned_backfill_keeps_context_and_location(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("她抬头。嘴角微微上扬。\n", encoding="utf-8")
cards = capture_file(path, work_ref="work-owned", source_license="owned")
self.assertEqual(1, len(cards))
self.assertEqual("嘴角微微上扬。", cards[0]["excerpt"])
self.assertIn("她抬头", cards[0]["context"])
self.assertEqual(1, cards[0]["source"]["location"]["line_start"])
def test_duplicate_scan_pattern_is_deduplicated(self):
pattern = PATTERNS[0]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "same.txt"
path.write_text("值得注意的是。", encoding="utf-8")
cards = capture_file(path, patterns=[pattern, pattern], source_license="owned", work_ref="work-same")
self.assertEqual(1, len(cards))
def test_feedback_is_bound_to_creation_run(self):
card = capture_feedback("候选正文", work_ref="work-12", run_ref="run-7", issue="段尾空泛")
self.assertEqual("live_feedback", card["capture_mode"])
self.assertEqual("creation_feedback", card["source"]["kind"])
self.assertEqual("run-7", card["feedback"]["run_ref"])
self.assertEqual("shadow", card["state"])
def test_unlicensed_card_cannot_be_confirmed_or_store_text(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "third-party.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-third")[0]
with self.assertRaises(CaseCardError):
confirm_card(card, reviewer="u", note="不能确认")
broken = dict(card)
broken["excerpt"] = "偷偷保存的原文"
with self.assertRaises(CaseCardError):
validate_card(broken)
def test_revalidate_unchanged_source_is_verified(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("第一行。值得注意的是,门外下雨了。\n", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
result = revalidate_card(card, source_path=path, checked_on="2026-08-14")
self.assertEqual("verified", result["status"])
self.assertEqual(card["source"]["source_sha256"], result["actual_source_sha256"])
self.assertEqual("2026-08-14", result["checked_on"])
def test_revalidate_modified_source_is_stale_and_does_not_mutate_card(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。\n", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
original_state = card["state"]
path.write_text("已经改成另一版正文。\n", encoding="utf-8")
result = revalidate_card(card, source_path=path)
self.assertEqual("stale", result["status"])
self.assertEqual("source_hash_mismatch", result["reason"])
self.assertEqual("shadow", original_state)
self.assertEqual("shadow", card["state"])
def test_revalidate_same_source_but_tampered_anchor_is_card_mismatch(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-a", source_license="owned")[0]
tampered = dict(card)
tampered["source"] = dict(card["source"])
tampered["source"]["surface_location"] = dict(card["source"]["surface_location"])
tampered["source"]["surface_location"]["char_start"] = 1
result = revalidate_card(tampered, source_path=path)
self.assertEqual("card_mismatch", result["status"])
self.assertEqual("card_surface_mismatch", result["reason"])
def test_revalidate_unavailable_source_is_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(path, work_ref="work-a")[0]
result = revalidate_card(card, source_path=Path(tmp) / "missing" / "work.txt")
self.assertEqual("unavailable", result["status"])
self.assertEqual("source_not_found", result["reason"])
def test_revalidate_report_deduplicates_source_reads_and_exposes_counts(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "work.txt"
path.write_text("值得注意的是。嘴角微微上扬。", encoding="utf-8")
cards = capture_file(path, work_ref="work-a", source_license="owned")
report = build_revalidation_report(cards, source_root=Path(tmp), checked_on="2026-08-14")
self.assertEqual(2, report["totals"]["cards"])
self.assertEqual(2, report["totals"]["verified"])
self.assertTrue(report["usable"])
def test_confirmation_requires_verified_receipt(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="narration")
with self.assertRaises(CaseCardError):
confirm_card(annotated, reviewer="human", note="无功能")
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="无功能", verification=verification)
self.assertEqual("canonical", canonical["state"])
def test_shadow_needs_explicit_annotation_before_rule_candidate(self):
with tempfile.TemporaryDirectory() as tmp:
a = Path(tmp) / "a.txt"
b = Path(tmp) / "b.txt"
a.write_text("值得注意的是。", encoding="utf-8")
b.write_text("值得注意的是。", encoding="utf-8")
sf = annotate_card(capture_file(a, work_ref="work-a", source_license="owned")[0], label="sf")
snf = annotate_card(capture_file(b, work_ref="work-b", source_license="owned")[0], label="snf")
verification = {"cards": [
revalidate_card(sf, source_path=a), revalidate_card(snf, source_path=b)
]}
rule = propose_rule([sf, snf], rule_id="candidate-1", name="元话语", fix_hint="先核功能",
verification=verification)
self.assertEqual("candidate", rule["status"])
self.assertEqual({"work-a", "work-b"}, set(rule["source_work_refs"]))
def test_rule_candidate_rejects_single_work_or_one_sided_evidence(self):
with tempfile.TemporaryDirectory() as tmp:
a = Path(tmp) / "a.txt"
a.write_text("值得注意的是。\n值得注意的是。", encoding="utf-8")
cards = capture_file(a, work_ref="work-a", source_license="owned")
sf = annotate_card(cards[0], label="sf")
sf2 = annotate_card(cards[1], label="sf")
with self.assertRaises(CaseCardError):
propose_rule([sf, sf2], rule_id="candidate-one-sided", name="单样本禁令", fix_hint="删除")
def test_canonical_card_projects_to_sample_only_after_review(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="narration")
with self.assertRaises(CaseCardError):
project_sample(annotated, verification={})
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="上下文无功能", verification=verification)
sample = project_sample(canonical, verification=verification)
self.assertEqual("sample-" + canonical["id"], sample["id"])
self.assertEqual(canonical["id"], sample["case_card_id"])
def test_shipped_fixtures_pass_the_same_validator(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
for name in ("backfill-hash-only.yaml", "canonical-samples.yaml"):
data = yaml.safe_load((root / name).read_text(encoding="utf-8"))
for card in data["cards"]:
validate_card(card)
def test_shipped_rule_seed_is_candidate_only(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
data = yaml.safe_load((root / "rule-candidates.yaml").read_text(encoding="utf-8"))
self.assertTrue(data["rules"])
self.assertTrue(all(rule["status"] == "candidate" for rule in data["rules"]))
def test_shipped_revalidation_report_is_structurally_usable(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures"
data = yaml.safe_load((root / "revalidation-2026-08-14.json").read_text(encoding="utf-8"))
self.assertEqual("ai-flavor-revalidation-v1", data["schema_version"])
self.assertTrue(data["usable"])
self.assertEqual({"cards": 788, "verified": 788, "stale": 0, "unavailable": 0, "card_mismatch": 0}, data["totals"])
def test_revalidate_cli_is_persist_by_default_contract(self):
import capture_cases
parser = capture_cases._parser()
args = parser.parse_args(["revalidate", "cards.yaml", "--output", "receipt.json"])
self.assertFalse(args.offline)
def test_inventory_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "works"
root.mkdir()
(root / "work.txt").write_text("值得注意的是。", encoding="utf-8")
output = Path(tmp) / "inventory.json"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main(["inventory", str(root), "--output", str(output)])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_inventory_cli_offline_is_explicit_escape_hatch(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "works"
root.mkdir()
(root / "work.txt").write_text("值得注意的是。", encoding="utf-8")
output = Path(tmp) / "inventory.json"
with patch("capture_cases._persist_detection") as persist:
rc = main(["inventory", str(root), "--output", str(output), "--offline"])
self.assertEqual(0, rc)
persist.assert_not_called()
def test_scan_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
output = root / "cards.yaml"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main(["scan", str(source), "--work-ref", "work-a", "--output", str(output)])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_feedback_cli_automatically_calls_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "candidate.txt"
source.write_text("候选正文。", encoding="utf-8")
output = root / "feedback.yaml"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main([
"feedback", "--text-file", str(source), "--work-ref", "work-a",
"--run-ref", "run-a", "--issue", "段尾空泛", "--output", str(output),
])
self.assertEqual(0, rc)
persist.assert_called_once()
def test_revalidate_single_card_writes_inventory_sidecar_before_persistence(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(source, work_ref="work-a", source_license="owned")[0]
cards_path = root / "card.yaml"
cards_path.write_text(yaml.safe_dump({"schema_version": "ai-flavor-case-v1", "cards": [card]}, allow_unicode=True), encoding="utf-8")
receipt_path = root / "receipt.json"
with patch("capture_cases._persist_detection", return_value={"status": "written"}) as persist:
rc = main([
"revalidate", str(cards_path), "--source-root", str(root),
"--output", str(receipt_path), "--checked-on", "2026-08-14",
])
self.assertEqual(0, rc)
persist.assert_called_once()
self.assertTrue((root / "card.inventory.json").is_file())
def test_revalidate_single_card_offline_does_not_write_database(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "work.txt"
source.write_text("值得注意的是。", encoding="utf-8")
card = capture_file(source, work_ref="work-a", source_license="owned")[0]
cards_path = root / "card.yaml"
cards_path.write_text(yaml.safe_dump({"schema_version": "ai-flavor-case-v1", "cards": [card]}, allow_unicode=True), encoding="utf-8")
receipt_path = root / "receipt.json"
with patch("capture_cases._persist_detection") as persist:
rc = main([
"revalidate", str(cards_path), "--source-root", str(root),
"--output", str(receipt_path), "--offline",
])
self.assertEqual(0, rc)
persist.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@ -1,6 +1,6 @@
---
name: detect
description: 检测的功能合同(scenario: validation/consistency_check/fine_outline_replay,检测槽位)。检查清单由 schema 字段自动生成——凡 aiContext 含 detection 的字段即检查项。
name: check-content-consistency
description: 检查正文或细纲候选的结构、事实、角色状态、能力代价、伏笔和证据缺口。候选进入用户决策或独立评分前使用;只产检测报告,不修改候选。
disable-model-invocation: true
---
@ -12,7 +12,7 @@ disable-model-invocation: true
1. 先运行 `scripts/check_writer_candidate.py`;它只做确定性机械硬门,不调用模型。
2. 机械硬门阻塞时直接返回结构化失败码,保留审查轨迹,不调用语义 detector。
3. 机械硬门通过后,编排层构造 `semantic-detector-input-v3`,再以 fresh 无会话调用执行一个候选的语义核查。
3. 机械硬门通过后,编排层构造 `semantic-detector-input-v3`(生产构造器:`scripts/run_writer_semantic_detector.py` 的 `build_semantic_input_v3`,从 WriterContext + 候选投影并闭集校验,sourceRef 清洗成合同形状),再以 fresh 无会话调用执行一个候选的语义核查。
4. 模型只返回 `semantic-detection-draft-v3`;adapter 负责绑定输入、候选、上下文、模型回执、字符 offset、状态和报告 hash,形成 `SemanticDetection v3`。异常、非法结构或绑定不一致全部失败关闭。
当前机械硬门覆盖:WriterContext v1、CandidateEnvelope v2、上下文与候选 hash、篇幅、细纲事件/角色/伏笔/章末钩子锚点。事实断言、角色知情范围、能力代价、语义冲突、新设定识别和证据缺口属于 semantic detector,不得塞回 writer 输出或机械门。
@ -44,11 +44,13 @@ schema 给字段加上 detection 用途,检查项自动+1,本 skill 与 detector
`claims` 明确候选中的事实主张与覆盖状态;`findings` 记录可定位问题;verdict 完整覆盖输入登记的 assertion/constraint ID。只有 `evidenceGaps` 或 `unknown` 可以触发编排器补证;纯机械失败或明确语义失败直接拒绝,不以同一输入重试。补证后必须冻结新的上下文快照和新的 WriterCreativeInput,再启动 fresh writer 调用。
正文回放检测每臂最多调用三次。模型已返回但结构/引文不合约时,可用剩余额度回喂上一稿纠错;带可信成本回执的瞬时 `SEMANTIC_DETECTOR_API_ERROR` 最多重发同一输入一次,并与结构纠错共用三次总额度。认证、预算、本地合同、成本未知或连续第二次 API 错误均失败关闭,不得重试绕过。
最终 SemanticDetection v3 由 adapter 增加运行身份、候选/上下文 hash、字符 offset、模型回执、状态和报告 hash。模型不得输出这些可信字段。
## 细纲回放分支(`fine_outline_replay`)
何时用:`fine-outline` 候选进入独立评分前。检测器只看冻结到 `as_of` 的规划上下文和候选,不看目标章标准事实,不把评测答案倒灌回规划侧。
何时用:`plan-chapter` 产出的 `fine_outline` 候选进入独立评分前。检测器只看冻结到 `as_of` 的规划上下文和候选,不看目标章标准事实,不把评测答案倒灌回规划侧。
检查对象和证据格式:

View File

@ -9,7 +9,7 @@ import sys
from typing import Any, Mapping, Sequence
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "read-context" / "scripts"
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
if str(READ_CONTEXT_DIR) not in sys.path:
sys.path.insert(0, str(READ_CONTEXT_DIR))

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import copy
import hashlib
import json
import pathlib
@ -11,9 +12,9 @@ import sys
from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
RUNTIME_DIR = SCRIPT_DIR.parents[1] / "runtime" / "scripts"
if str(RUNTIME_DIR) not in sys.path:
sys.path.insert(0, str(RUNTIME_DIR))
EXECUTION_DIR = SCRIPT_DIR.parents[1] / "execute-claude-task" / "scripts"
if str(EXECUTION_DIR) not in sys.path:
sys.path.insert(0, str(EXECUTION_DIR))
try:
from claude_runtime import ExecutionProfile, contains_path_traversal, run_claude, sha256_json # type: ignore[import-not-found] # noqa: E402
@ -54,6 +55,27 @@ REPORT_FIELDS = frozenset({
"hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps", "status",
"reportSha256",
})
SAFE_DIAGNOSTIC_VERSION = "semantic-diagnostic-v1"
SAFE_DIAGNOSTIC_MAX_COUNT = 10_000
SAFE_DIAGNOSTIC_SECTIONS = frozenset({
"input", "model_output", "claims", "findings", "assertion_verdicts",
"hard_constraint_verdicts", "new_setting_candidates", "evidence_gaps",
"report", "runtime",
})
SAFE_DIAGNOSTIC_REASON_CODES = frozenset({
"ARRAY_REQUIRED", "CONTRACT_INVALID", "DUPLICATE_ID", "ENUM_INVALID",
"EVIDENCE_ID_INVALID", "EVIDENCE_REQUIRED", "FIELD_SET_INVALID",
"GAP_REASON_FORBIDDEN", "GAP_REASON_REQUIRED", "HASH_INVALID",
"ID_ORDER_MISMATCH", "INTEGER_RANGE_INVALID", "MODEL_VERSION_INVALID",
"NON_EMPTY_STRING_REQUIRED", "OBJECT_REQUIRED", "QUOTE_NOT_FOUND",
"REFERENCE_LEAKAGE", "RUNTIME_FAILED", "SEMANTIC_BLOCKED",
})
SAFE_PRIMARY_CODE_PATTERN = re.compile(r"^SEMANTIC_[A-Z0-9_]{1,95}$")
SAFE_BLOCKING_COUNT_FIELDS = (
"highFindings", "failedAssertions", "failedHardConstraints",
"conflictingClaims", "evidenceGaps", "unknownAssertions",
"unknownHardConstraints", "unknownClaims",
)
@runtime_checkable
@ -61,12 +83,67 @@ class ModelRunner(Protocol):
def run(self, *, adapter_role: str, model_input: Mapping[str, Any], output_schema: Mapping[str, Any]) -> Mapping[str, Any]: ...
def _diagnostic_section(path: str) -> str:
"""把 adapter 自己生成的字段路径投影成固定区段,不保留索引或原始路径。"""
prefixes = (
("$.claims", "claims"),
("$.findings", "findings"),
("$.assertionVerdicts", "assertion_verdicts"),
("$.hardConstraintVerdicts", "hard_constraint_verdicts"),
("$.newSettingCandidates", "new_setting_candidates"),
("$.evidenceGaps", "evidence_gaps"),
)
for prefix, section in prefixes:
if path.startswith(prefix):
return section
return "model_output"
def _diagnostic_section_for_error(path: str, code: str) -> str:
_reason, default_section = _default_diagnostic(code)
if default_section in {"input", "report", "runtime"}:
return default_section
return _diagnostic_section(path)
def _default_diagnostic(code: str) -> tuple[str, str]:
if code in {"SEMANTIC_DETECTOR_RUNTIME_FAILED", "SEMANTIC_DETECTOR_RUNNER_INVALID"}:
return "RUNTIME_FAILED", "runtime"
if code == "SEMANTIC_DETECTOR_LEAKAGE_DETECTED":
return "REFERENCE_LEAKAGE", "input"
if code == "SEMANTIC_DETECTOR_QUOTE_NOT_FOUND":
return "QUOTE_NOT_FOUND", "model_output"
if "INPUT" in code or "CANDIDATE_HASH" in code:
return "CONTRACT_INVALID", "input"
if "REPORT" in code or "STATUS" in code or "OFFSET" in code:
return "CONTRACT_INVALID", "report"
return "CONTRACT_INVALID", "model_output"
def _safe_primary_code(value: Any) -> str:
if isinstance(value, str) and SAFE_PRIMARY_CODE_PATTERN.fullmatch(value):
return value
return "SEMANTIC_DETECTOR_INVALID"
class SemanticDetectorContractError(ValueError):
def __init__(self, code: str, message: str, *, causes: Sequence[str] = ()) -> None:
def __init__(
self,
code: str,
message: str,
*,
causes: Sequence[str] = (),
reason_code: str | None = None,
section: str | None = None,
) -> None:
super().__init__(message)
self.code = code
self.causes = tuple(cause for cause in causes if cause != code)
self.acceptance_eligible = False
default_reason, default_section = _default_diagnostic(code)
self.reason_code = reason_code if reason_code in SAFE_DIAGNOSTIC_REASON_CODES else default_reason
self.section = section if section in SAFE_DIAGNOSTIC_SECTIONS else default_section
def _canonical_json(value: Any) -> str:
@ -79,36 +156,54 @@ def canonical_sha256(value: Any) -> str:
def _object(value: Any, path: str, required: frozenset[str], optional: frozenset[str] = frozenset(), *, code: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise SemanticDetectorContractError(code, f"{path} 必须是对象")
raise SemanticDetectorContractError(
code, f"{path} 必须是对象", reason_code="OBJECT_REQUIRED",
section=_diagnostic_section_for_error(path, code),
)
missing = sorted(required - set(value))
extra = sorted(set(value) - required - optional)
if missing or extra:
raise SemanticDetectorContractError(code, f"{path} 字段非法,missing={missing}, extra={extra}")
raise SemanticDetectorContractError(
code, f"{path} 字段非法,missing={missing}, extra={extra}",
reason_code="FIELD_SET_INVALID", section=_diagnostic_section_for_error(path, code),
)
return value
def _array(value: Any, path: str, *, code: str) -> list[Any]:
if not isinstance(value, list):
raise SemanticDetectorContractError(code, f"{path} 必须是数组")
raise SemanticDetectorContractError(
code, f"{path} 必须是数组", reason_code="ARRAY_REQUIRED",
section=_diagnostic_section_for_error(path, code),
)
return value
def _string(value: Any, path: str, *, code: str) -> str:
if not isinstance(value, str) or not value.strip():
raise SemanticDetectorContractError(code, f"{path} 必须是非空字符串")
raise SemanticDetectorContractError(
code, f"{path} 必须是非空字符串", reason_code="NON_EMPTY_STRING_REQUIRED",
section=_diagnostic_section_for_error(path, code),
)
return value
def _integer(value: Any, path: str, *, minimum: int, code: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise SemanticDetectorContractError(code, f"{path} 必须是大于等于 {minimum} 的整数")
raise SemanticDetectorContractError(
code, f"{path} 必须是大于等于 {minimum} 的整数",
reason_code="INTEGER_RANGE_INVALID", section=_diagnostic_section_for_error(path, code),
)
return value
def _hash(value: Any, path: str, *, code: str) -> str:
text = _string(value, path, code=code)
if not HASH_PATTERN.fullmatch(text):
raise SemanticDetectorContractError(code, f"{path} 必须是规范 SHA-256")
raise SemanticDetectorContractError(
code, f"{path} 必须是规范 SHA-256", reason_code="HASH_INVALID",
section=_diagnostic_section_for_error(path, code),
)
return text
@ -118,7 +213,12 @@ def _safe_reference(value: Any, path: str, *, code: str) -> str:
# 子串匹配会把省略号 `...`(含子串 `..`)误判为路径穿越,冤杀含省略号的合法引用;
# 精确判定只拦 a/../b 这类真实穿越,臂名与 raw 路径仍由其余条件保留拦截。
if text in REAL_ARM_NAMES or text.startswith(("/", "file:")) or contains_path_traversal(text) or RAW_PATH_PATTERN.search(text):
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_LEAKAGE_DETECTED", f"{path} 含真实臂名或 raw 路径")
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_LEAKAGE_DETECTED",
f"{path} 含真实臂名或 raw 路径",
reason_code="REFERENCE_LEAKAGE",
section="input",
)
return text
@ -241,9 +341,13 @@ def validate_semantic_detector_input(value: Any) -> dict[str, Any]:
return normalized
def build_semantic_model_input(value: Any) -> dict[str, Any]:
def build_semantic_model_input(value: Any, correction: Mapping[str, Any] | None = None) -> dict[str, Any]:
# WHY: correction 只在「模型输入层」可选透传,绝不进入 detector_input——
# validate_semantic_detector_input 对 detector_input 执行固定 INPUT_FIELDS 闭集校验,
# 多塞 correction 会被当成非法 extra 字段拒掉。把 correction 放在这里拼进模型输入,
# 既不破坏正常输入的闭集校验,又能把「上一轮不合格产出 + 出错原因」喂回模型做自我纠错。
payload = validate_semantic_detector_input(value)
return {
model_input: dict[str, Any] = {
"candidateBody": payload["candidateBody"],
"fineOutline": payload["fineOutline"],
"hardConstraints": payload["hardConstraints"],
@ -263,6 +367,9 @@ def build_semantic_model_input(value: Any) -> dict[str, Any]:
],
"asOf": payload["asOf"],
}
if correction is not None:
model_input["correction"] = dict(correction)
return model_input
def _quote_location(body: str, quote: Any, path: str) -> tuple[str, int, int]:
@ -270,7 +377,10 @@ def _quote_location(body: str, quote: Any, path: str) -> tuple[str, int, int]:
# 0 次 = 引文根本不在候选里 = 编造证据,必须失败关闭(防编造核心不动)。
if text not in body:
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_QUOTE_NOT_FOUND", f"{path} 引文未在候选正文中出现(不得编造证据)"
"SEMANTIC_DETECTOR_QUOTE_NOT_FOUND",
f"{path} 引文未在候选正文中出现(不得编造证据)",
reason_code="QUOTE_NOT_FOUND",
section=_diagnostic_section(path),
)
# ≥1 次:由确定性代码绑定到首次出现,不再因多处出现而失败关闭——
# 大模型无法可靠数出一句话在长文里出现几次,精确唯一计数不是可信门槛。
@ -281,7 +391,12 @@ def _quote_location(body: str, quote: Any, path: str) -> tuple[str, int, int]:
def _evidence_ids(value: Any, path: str, allowed: set[str]) -> list[str]:
ids = [_string(item, f"{path}[{index}]", code="SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID") for index, item in enumerate(_array(value, path, code="SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID"))]
if len(ids) != len(set(ids)) or any(item not in allowed for item in ids):
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID", f"{path} 含重复或越界证据 ID")
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID",
f"{path} 含重复或越界证据 ID",
reason_code="EVIDENCE_ID_INVALID",
section=_diagnostic_section(path),
)
return ids
@ -289,7 +404,10 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
code = "SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID"
draft = _object(value, "$", frozenset({"schemaVersion", "claims", "findings", "assertionVerdicts", "hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps"}), code=code)
if draft["schemaVersion"] != MODEL_OUTPUT_VERSION:
raise SemanticDetectorContractError(code, "模型输出版本非法")
raise SemanticDetectorContractError(
code, "模型输出版本非法", reason_code="MODEL_VERSION_INVALID",
section="model_output",
)
body = payload["candidateBody"]
allowed_evidence = set(payload["_evidenceIds"])
@ -307,11 +425,15 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
claim_id = _string(item["claimId"], f"$.claims[{index}].claimId", code=code)
claim_ids.append(claim_id)
if item["coverageState"] not in coverage_states:
raise SemanticDetectorContractError(code, f"$.claims[{index}].coverageState 非法")
raise SemanticDetectorContractError(
code, f"$.claims[{index}].coverageState 非法",
reason_code="ENUM_INVALID", section="claims",
)
if item["coverageState"] == "unknown" and "gapReason" not in item:
raise SemanticDetectorContractError(code, f"$.claims[{index}] unknown 必须携带 gapReason")
if item["coverageState"] != "unknown" and "gapReason" in item:
raise SemanticDetectorContractError(code, f"$.claims[{index}] 非 unknown 不得携带 gapReason")
raise SemanticDetectorContractError(
code, f"$.claims[{index}] unknown 必须携带 gapReason",
reason_code="GAP_REASON_REQUIRED", section="claims",
)
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.claims[{index}].candidateQuote")
bound = {
"claimId": claim_id,
@ -324,11 +446,15 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
"coverageState": item["coverageState"],
"evidenceIds": _evidence_ids(item["evidenceIds"], f"$.claims[{index}].evidenceIds", allowed_evidence),
}
if "gapReason" in item:
# WHY: 模型可能在已判定的 claim 上残留解释性 gapReason;它不改变闭集状态,
# 绑定时确定性丢弃,避免把无害冗余升级为整份报告失败。unknown 仍须在上方校验非空原因。
if item["coverageState"] == "unknown":
bound["gapReason"] = _string(item["gapReason"], f"$.claims[{index}].gapReason", code=code)
claims.append(bound)
if len(claim_ids) != len(set(claim_ids)):
raise SemanticDetectorContractError(code, "claimId 不得重复")
raise SemanticDetectorContractError(
code, "claimId 不得重复", reason_code="DUPLICATE_ID", section="claims"
)
findings: list[dict[str, Any]] = []
finding_ids: list[str] = []
@ -337,7 +463,10 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
finding_id = _string(item["findingId"], f"$.findings[{index}].findingId", code=code)
finding_ids.append(finding_id)
if item["severity"] not in SEVERITIES or item["category"] not in FINDING_CATEGORIES:
raise SemanticDetectorContractError(code, f"$.findings[{index}] 枚举非法")
raise SemanticDetectorContractError(
code, f"$.findings[{index}] 枚举非法",
reason_code="ENUM_INVALID", section="findings",
)
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.findings[{index}].candidateQuote")
findings.append({
"findingId": finding_id, "severity": item["severity"], "category": item["category"],
@ -347,7 +476,9 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
"message": _string(item["message"], f"$.findings[{index}].message", code=code),
})
if len(finding_ids) != len(set(finding_ids)):
raise SemanticDetectorContractError(code, "findingId 不得重复")
raise SemanticDetectorContractError(
code, "findingId 不得重复", reason_code="DUPLICATE_ID", section="findings"
)
def verdicts(field: str, id_field: str, expected_ids: list[str]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
@ -357,23 +488,39 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
stable_id = _string(item[id_field], f"$.{field}[{index}].{id_field}", code=code)
actual_ids.append(stable_id)
if item["verdict"] not in VERDICTS:
raise SemanticDetectorContractError(code, f"$.{field}[{index}].verdict 非法")
raise SemanticDetectorContractError(
code, f"$.{field}[{index}].verdict 非法",
reason_code="ENUM_INVALID", section=_diagnostic_section(f"$.{field}"),
)
if item["verdict"] == "unknown" and "gapReason" not in item:
raise SemanticDetectorContractError(code, f"$.{field}[{index}] unknown 必须携带 gapReason")
if item["verdict"] != "unknown" and "gapReason" in item:
raise SemanticDetectorContractError(code, f"$.{field}[{index}] 非 unknown 不得携带 gapReason")
raise SemanticDetectorContractError(
code, f"$.{field}[{index}] unknown 必须携带 gapReason",
reason_code="GAP_REASON_REQUIRED", section=_diagnostic_section(f"$.{field}"),
)
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.{field}[{index}].candidateQuote")
bound = {
id_field: stable_id, "candidateSha256": payload["candidateSha256"], "verdict": item["verdict"],
"candidateQuote": quote, "startCodePoint": start, "endCodePoint": end,
"evidenceIds": _evidence_ids(item["evidenceIds"], f"$.{field}[{index}].evidenceIds", allowed_evidence),
}
if "gapReason" in item:
# WHY: pass/fail 已是终态,额外 gapReason 不参与可信绑定;统一丢弃可消除模型格式噪声,
# 但 unknown 的非空原因仍由上方强制校验,其他字段和证据约束保持失败关闭。
if item["verdict"] == "unknown":
bound["gapReason"] = _string(item["gapReason"], f"$.{field}[{index}].gapReason", code=code)
result.append(bound)
if actual_ids != expected_ids:
raise SemanticDetectorContractError(code, f"$.{field} ID 集或顺序不一致")
return result
# WHY: ID 集完整且无重复时,条目顺序不影响语义;由适配器按冻结输入顺序重排,
# 避免模型把同一组裁决按字典序返回而被误判为缺失。集合不完整、重复或越界仍失败关闭。
if (
len(actual_ids) != len(expected_ids)
or len(actual_ids) != len(set(actual_ids))
or set(actual_ids) != set(expected_ids)
):
raise SemanticDetectorContractError(
code, f"$.{field} ID 集或顺序不一致",
reason_code="ID_ORDER_MISMATCH", section=_diagnostic_section(f"$.{field}"),
)
by_id = {item[id_field]: item for item in result}
return [by_id[item] for item in expected_ids]
assertion_verdicts = verdicts("assertionVerdicts", "assertionId", list(payload["_expectedAssertionIds"]))
constraint_verdicts = verdicts("hardConstraintVerdicts", "constraintId", list(payload["_expectedConstraintIds"]))
@ -392,7 +539,10 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
"startCodePoint": start, "endCodePoint": end,
})
if len(setting_ids) != len(set(setting_ids)):
raise SemanticDetectorContractError(code, "settingId 不得重复")
raise SemanticDetectorContractError(
code, "settingId 不得重复", reason_code="DUPLICATE_ID",
section="new_setting_candidates",
)
gaps: list[dict[str, Any]] = []
gap_ids: list[str] = []
@ -401,7 +551,10 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
gap_id = _string(item["gapId"], f"$.evidenceGaps[{index}].gapId", code=code)
gap_ids.append(gap_id)
if item["priority"] not in PRIORITIES:
raise SemanticDetectorContractError(code, f"$.evidenceGaps[{index}].priority 非法")
raise SemanticDetectorContractError(
code, f"$.evidenceGaps[{index}].priority 非法",
reason_code="ENUM_INVALID", section="evidence_gaps",
)
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.evidenceGaps[{index}].candidateQuote")
gaps.append({
"gapId": gap_id, "query": _string(item["query"], f"$.evidenceGaps[{index}].query", code=code),
@ -410,7 +563,9 @@ def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str,
"candidateQuote": quote, "startCodePoint": start, "endCodePoint": end,
})
if len(gap_ids) != len(set(gap_ids)):
raise SemanticDetectorContractError(code, "gapId 不得重复")
raise SemanticDetectorContractError(
code, "gapId 不得重复", reason_code="DUPLICATE_ID", section="evidence_gaps"
)
return {
"claims": claims, "findings": findings, "assertionVerdicts": assertion_verdicts,
"hardConstraintVerdicts": constraint_verdicts, "newSettingCandidates": settings,
@ -424,7 +579,8 @@ def build_semantic_detection(value: Any, detector_input: Mapping[str, Any], *, m
content = _validate_model_output(value, payload)
has_unknown = any(item["verdict"] == "unknown" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in content[field]) or any(item["coverageState"] == "unknown" for item in content["claims"])
has_failure = any(item["severity"] == "high" for item in content["findings"]) or any(item["verdict"] == "fail" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in content[field]) or any(item["coverageState"] == "conflict" for item in content["claims"])
status = "needs_evidence" if content["evidenceGaps"] or has_unknown else ("failed" if has_failure else "passed")
# 高危优先:有高危发现/失败裁决/冲突即 failed(不被证据缺口掩盖);无高危仅有证据缺口/未知才是 needs_evidence。
status = "failed" if has_failure else ("needs_evidence" if content["evidenceGaps"] or has_unknown else "passed")
report = {
"schemaVersion": REPORT_VERSION,
"runId": payload["runId"], "sampleId": payload["sampleId"], "opaqueArmId": payload["opaqueArmId"],
@ -484,7 +640,7 @@ def validate_semantic_detector_report(value: Any, detector_input: Mapping[str, A
values = [item[id_field] for item in report[field]]
if len(values) != len(set(values)):
raise SemanticDetectorContractError(code, f"$.{field}.{id_field} 不得重复")
expected_status = "needs_evidence" if report["evidenceGaps"] or any(item.get("verdict") == "unknown" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "unknown" for item in report["claims"]) else ("failed" if any(item.get("severity") == "high" for item in report["findings"]) or any(item.get("verdict") == "fail" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "conflict" for item in report["claims"]) else "passed")
expected_status = "failed" if any(item.get("severity") == "high" for item in report["findings"]) or any(item.get("verdict") == "fail" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "conflict" for item in report["claims"]) else ("needs_evidence" if report["evidenceGaps"] or any(item.get("verdict") == "unknown" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "unknown" for item in report["claims"]) else "passed")
if report["status"] != expected_status:
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_STATUS_MISMATCH", "报告状态与语义内容不一致")
supplied_hash = _hash(report["reportSha256"], "$.reportSha256", code=code)
@ -503,6 +659,106 @@ def calculate_semantic_metrics(report: Mapping[str, Any], detector_input: Mappin
}
def _bounded_count(value: int) -> int:
return min(max(int(value), 0), SAFE_DIAGNOSTIC_MAX_COUNT)
def _empty_blocking_counts() -> dict[str, int]:
return {field: 0 for field in SAFE_BLOCKING_COUNT_FIELDS}
def _contract_safe_diagnostic(
error: SemanticDetectorContractError, *, attempt_count: int, correction_count: int | None = None
) -> dict[str, Any]:
attempts = _bounded_count(attempt_count)
corrections = _bounded_count(
max(attempts - 1, 0) if correction_count is None else correction_count
)
return {
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
"outcome": "invalid",
"primaryCode": _safe_primary_code(error.code),
"reasonCode": error.reason_code,
"section": error.section,
"attemptCount": attempts,
"correctionCount": min(corrections, max(attempts - 1, 0)),
"blockingCounts": _empty_blocking_counts(),
}
def build_safe_semantic_diagnostic(value: Mapping[str, Any]) -> dict[str, Any]:
"""从完整检测结果蒸馏固定闭集摘要;不复制任何模型文本、ID、引用或字段路径。"""
if value.get("ok") is not True or not isinstance(value.get("report"), Mapping):
candidate = value.get("safeDiagnostic")
if not isinstance(candidate, Mapping):
candidate = {}
reason = candidate.get("reasonCode")
section = candidate.get("section")
attempts = candidate.get("attemptCount")
corrections = candidate.get("correctionCount")
safe_attempts = _bounded_count(
attempts if isinstance(attempts, int) and not isinstance(attempts, bool) else 0
)
safe_corrections = _bounded_count(
corrections if isinstance(corrections, int) and not isinstance(corrections, bool) else 0
)
return {
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
"outcome": "invalid",
"primaryCode": _safe_primary_code(candidate.get("primaryCode") or value.get("primaryCode")),
"reasonCode": reason if reason in SAFE_DIAGNOSTIC_REASON_CODES else "CONTRACT_INVALID",
"section": section if section in SAFE_DIAGNOSTIC_SECTIONS else "model_output",
"attemptCount": safe_attempts,
"correctionCount": min(safe_corrections, max(safe_attempts - 1, 0)),
"blockingCounts": _empty_blocking_counts(),
}
report = value["report"]
status = str(report.get("status") or "")
if status not in {"failed", "needs_evidence"}:
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_STATUS_MISMATCH",
"安全诊断只接受阻断态报告",
section="report",
)
findings = report.get("findings") if isinstance(report.get("findings"), list) else []
assertions = report.get("assertionVerdicts") if isinstance(report.get("assertionVerdicts"), list) else []
constraints = report.get("hardConstraintVerdicts") if isinstance(report.get("hardConstraintVerdicts"), list) else []
claims = report.get("claims") if isinstance(report.get("claims"), list) else []
gaps = report.get("evidenceGaps") if isinstance(report.get("evidenceGaps"), list) else []
counts = {
"highFindings": _bounded_count(sum(isinstance(item, Mapping) and item.get("severity") == "high" for item in findings)),
"failedAssertions": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "fail" for item in assertions)),
"failedHardConstraints": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "fail" for item in constraints)),
"conflictingClaims": _bounded_count(sum(isinstance(item, Mapping) and item.get("coverageState") == "conflict" for item in claims)),
"evidenceGaps": _bounded_count(len(gaps)),
"unknownAssertions": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "unknown" for item in assertions)),
"unknownHardConstraints": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "unknown" for item in constraints)),
"unknownClaims": _bounded_count(sum(isinstance(item, Mapping) and item.get("coverageState") == "unknown" for item in claims)),
}
attempts = value.get("attemptCount")
corrections = value.get("correctionCount")
safe_attempts = _bounded_count(
attempts if isinstance(attempts, int) and not isinstance(attempts, bool) else 1
)
safe_corrections = _bounded_count(
corrections
if isinstance(corrections, int) and not isinstance(corrections, bool)
else max(safe_attempts - 1, 0)
)
return {
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
"outcome": status,
"primaryCode": "SEMANTIC_EVIDENCE_REQUIRED" if status == "needs_evidence" else "SEMANTIC_CHECK_FAILED",
"reasonCode": "EVIDENCE_REQUIRED" if status == "needs_evidence" else "SEMANTIC_BLOCKED",
"section": "report",
"attemptCount": safe_attempts,
"correctionCount": min(safe_corrections, max(safe_attempts - 1, 0)),
"blockingCounts": counts,
}
class ClaudeRuntimeModelRunner:
def __init__(self, profile: ExecutionProfile, *, runtime_callable: Any = None) -> None:
self.profile = profile
@ -532,29 +788,114 @@ def _invoke_model_runner(model_runner: ModelRunner, model_input: Mapping[str, An
return result["structuredOutput"], result["modelReceiptSha256"]
def _failure(error: SemanticDetectorContractError) -> dict[str, Any]:
return {"ok": False, "acceptanceEligible": False, "status": "failed", "primaryCode": error.code, "causes": list(error.causes), "message": str(error)}
def _failure(
error: SemanticDetectorContractError, *, attempt_count: int, correction_count: int
) -> dict[str, Any]:
return {
"ok": False,
"acceptanceEligible": False,
"status": "failed",
"primaryCode": error.code,
"causes": list(error.causes),
"message": str(error),
"safeDiagnostic": _contract_safe_diagnostic(
error,
attempt_count=attempt_count,
correction_count=correction_count,
),
}
def run_writer_semantic_detector(detector_input: Mapping[str, Any], *, model_runner: ModelRunner) -> dict[str, Any]:
def run_writer_semantic_detector(
detector_input: Mapping[str, Any],
*,
model_runner: ModelRunner,
max_corrections: int = 2,
max_runtime_retries: int = 1,
) -> dict[str, Any]:
# WHY: 检测模型(opus high)最常见的不合格是引文校验挂——它引了一句正文里没有的话。
# 这类错误自我纠错最对症:把上一轮原始产出和出错原因回喂给模型,让它换一句正文里真实存在的原话。
# 盲重试(不带上一轮产出)不一定收敛,所以纠错反馈里携带 previousDraft + error。
# max_corrections 给出硬上界(默认 2 轮纠错 = 总共最多 3 次调用),防止无限循环;
# 每次调用都走同一个 model_runner(生产中是 _BudgetedModelRunner),各自过预算账本。
attempt_count = 0
correction_count = 0
runtime_retry_count = 0
try:
if (
isinstance(max_runtime_retries, bool)
or not isinstance(max_runtime_retries, int)
or not 0 <= max_runtime_retries <= 1
):
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_RUNNER_INVALID",
"max_runtime_retries 必须是 0 或 1",
)
normalized = validate_semantic_detector_input(detector_input)
public_input = {key: value for key, value in normalized.items() if not key.startswith("_")}
model_input = build_semantic_model_input(public_input)
try:
draft, receipt_hash = _invoke_model_runner(model_runner, model_input)
except SemanticDetectorContractError:
raise
except Exception as exc:
runtime_code = getattr(exc, "primary_code", None) or getattr(exc, "code", None)
if isinstance(runtime_code, str) and runtime_code.startswith("SEMANTIC_DETECTOR_"):
raise SemanticDetectorContractError(runtime_code, "模型运行底座失败", causes=getattr(exc, "causes", ())) from exc
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", f"模型运行失败: {type(exc).__name__}") from exc
report = build_semantic_detection(draft, public_input, model_receipt_sha256=receipt_hash)
report = validate_semantic_detector_report(report, public_input, model_receipt_sha256=receipt_hash)
return {"ok": True, "acceptanceEligible": False, "status": report["status"], "report": report, "metrics": calculate_semantic_metrics(report, public_input)}
base_model_input = build_semantic_model_input(public_input)
correction: dict[str, Any] | None = None
for attempt in range(max_corrections + 1):
attempt_count = attempt + 1
# 首轮不带 correction;纠错轮在原始模型输入基础上追加 correction 字段透传给模型。
model_input = base_model_input if correction is None else {**base_model_input, "correction": correction}
draft: Any = None
try:
try:
draft, receipt_hash = _invoke_model_runner(model_runner, model_input)
except SemanticDetectorContractError:
raise
except Exception as exc:
runtime_code = getattr(exc, "primary_code", None) or getattr(exc, "code", None)
if isinstance(runtime_code, str) and runtime_code.startswith("SEMANTIC_DETECTOR_"):
raise SemanticDetectorContractError(runtime_code, "模型运行底座失败", causes=getattr(exc, "causes", ())) from exc
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", f"模型运行失败: {type(exc).__name__}") from exc
report = build_semantic_detection(draft, public_input, model_receipt_sha256=receipt_hash)
report = validate_semantic_detector_report(report, public_input, model_receipt_sha256=receipt_hash)
return {
"ok": True,
"acceptanceEligible": False,
"status": report["status"],
"report": report,
"metrics": calculate_semantic_metrics(report, public_input),
"attemptCount": attempt_count,
"correctionCount": correction_count,
}
except SemanticDetectorContractError as error:
# 纠错只对有「模型原始产出」的不合格有意义(build/validate 抛错时 draft 已存在)。
# runner 底座失败没有 draft(draft 仍为 None),纠错帮不上忙,直接失败关闭;
# 已用尽纠错轮次时也直接抛出最后一轮错误,返回 ok=False。
if draft is None:
# WHY: 带可信失败回执的瞬时 API 错误可以重新发送同一输入一次;它占用
# 现有三次总调用额度,不携带伪造 correction,也不重试本地合同/认证错误。
if (
error.code == "SEMANTIC_DETECTOR_API_ERROR"
and runtime_retry_count < max_runtime_retries
and attempt < max_corrections
):
runtime_retry_count += 1
correction = None
continue
raise
if attempt >= max_corrections:
raise
correction = {"previousDraft": draft, "error": str(error)}
if error.reason_code == "ID_ORDER_MISMATCH":
# WHY: 纠错轮明确给出冻结输入要求的完整 ID 集;适配器仍严格校验
# 缺失、重复、越界和绑定,提示只帮助模型修正格式,不放宽覆盖门禁。
correction["expectedVerdictIds"] = {
"assertionVerdicts": list(normalized["_expectedAssertionIds"]),
"hardConstraintVerdicts": list(normalized["_expectedConstraintIds"]),
}
correction_count += 1
# 循环必然在 return 或 raise 处退出,此处不可达。
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", "纠错环意外退出")
except SemanticDetectorContractError as error:
return _failure(error)
return _failure(
error,
attempt_count=attempt_count,
correction_count=correction_count,
)
def _closed(properties: Mapping[str, Any], required: Sequence[str], optional: Sequence[str] = ()) -> dict[str, Any]:
@ -603,11 +944,126 @@ SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA = _closed(
)
# 检测输入 sourceRef 闭集:与 _source_ref 合同逐字段对齐。writer 上下文的证据 sourceRef
# 允许携带 sourceType 等多余字段,投影给 detector 时必须清洗成闭集形状(上下文本身不动)。
_INPUT_SOURCE_REF_ALLOWED = frozenset(
{"sourceId", "sourceVersion", "chapter", "blockId", "startCodePoint", "endCodePoint", "contentSha256"}
)
def _clean_input_source_ref(ref: Any) -> Any:
"""把单个 sourceRef 深拷贝并清洗成检测输入闭集形状。"""
if not isinstance(ref, Mapping):
return copy.deepcopy(ref)
return {key: copy.deepcopy(ref[key]) for key in ref if key in _INPUT_SOURCE_REF_ALLOWED}
def _clean_input_evidence(evidence: Any) -> Any:
"""深拷贝证据列表,仅清洗每条证据的 sourceRef 子对象。"""
if not isinstance(evidence, list):
return copy.deepcopy(evidence)
cleaned: list[Any] = []
for item in evidence:
if not isinstance(item, Mapping):
cleaned.append(copy.deepcopy(item))
continue
new_item = {key: copy.deepcopy(value) for key, value in item.items() if key != "sourceRef"}
if "sourceRef" in item:
new_item["sourceRef"] = _clean_input_source_ref(item["sourceRef"])
cleaned.append(new_item)
return cleaned
def _project_outline_for_input(writer_context: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""把写手细纲投影成 detector 输入的稳定 ID 合同(constraint-N / declared-fact-N)。"""
outline = writer_context.get("fineOutline")
if not isinstance(outline, Mapping):
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "writerContext.fineOutline 必须是对象"
)
raw_constraints = outline.get("hardConstraints") or []
if not isinstance(raw_constraints, list):
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "fineOutline.hardConstraints 必须是数组"
)
constraints = [
{"constraintId": f"constraint-{index + 1}", "text": str(text)}
for index, text in enumerate(raw_constraints)
]
declared: list[dict[str, Any]] = []
raw_declared = outline.get("declaredNewFacts") or []
if not isinstance(raw_declared, list):
raise SemanticDetectorContractError(
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "fineOutline.declaredNewFacts 必须是数组"
)
for index, value in enumerate(raw_declared):
if isinstance(value, Mapping):
declared.append({
"factId": str(value.get("factId") or f"declared-fact-{index + 1}"),
"text": str(value.get("text") or ""),
"sourceRef": copy.deepcopy(value.get("sourceRef") or outline["sourceRef"]),
})
else:
declared.append({
"factId": f"declared-fact-{index + 1}",
"text": str(value),
"sourceRef": copy.deepcopy(outline["sourceRef"]),
})
projected = {
"sourceRef": copy.deepcopy(outline["sourceRef"]),
"hardConstraints": constraints,
"adjustableBeats": [str(item) for item in (outline.get("adjustableBeats") or [])],
"declaredNewFacts": declared,
}
return projected, constraints
def build_semantic_input_v3(
*,
run_id: str,
sample_id: str,
opaque_arm_id: str,
writer_context: Mapping[str, Any],
candidate: Mapping[str, Any],
) -> dict[str, Any]:
"""把 WriterContext + 候选投影成严格 semantic-detector-input-v3(生产/回放共用)。
只依赖 writer_context 与 candidate 两个输入,不读库不读文件;投影后立即由
validate_semantic_detector_input 做闭集校验,合同漂移在此失败关闭。
"""
fine_outline, constraints = _project_outline_for_input(writer_context)
payload = {
"schemaVersion": INPUT_VERSION,
"runId": run_id,
"sampleId": sample_id,
"opaqueArmId": opaque_arm_id,
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
"candidateBody": candidate["candidateBody"],
"contextSnapshotSha256": writer_context["contextSnapshot"]["contextSha256"],
"fineOutline": fine_outline,
"hardConstraints": constraints,
"factEvidence": _clean_input_evidence(writer_context.get("factEvidence", [])),
"proseEvidence": _clean_input_evidence(writer_context.get("proseEvidence", [])),
"asOf": writer_context["asOf"],
"authorizationSnapshotId": writer_context["authorizationSnapshot"]["snapshotId"],
}
payload["inputSha256"] = canonical_sha256(payload)
# 先做闭集校验失败关闭,再返回不含内部 "_" 派生键的干净输入(供 run/detector 复用)。
validate_semantic_detector_input(payload)
return json.loads(_canonical_json(payload))
__all__ = [
"ModelRunner", "ClaudeRuntimeModelRunner", "SemanticDetectorContractError",
"INPUT_VERSION", "MODEL_OUTPUT_VERSION", "REPORT_VERSION", "SEVERITIES",
"FINDING_CATEGORIES", "SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA", "canonical_sha256",
"validate_semantic_detector_input", "build_semantic_model_input",
"build_semantic_detection", "validate_semantic_detector_report",
"calculate_semantic_metrics", "run_writer_semantic_detector",
"calculate_semantic_metrics", "build_safe_semantic_diagnostic",
"run_writer_semantic_detector", "build_semantic_input_v3",
]

View File

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""build_semantic_input_v3 生产投影的离线测试。
验证:WriterContext + 候选能被投影成通过闭集校验的 semantic-detector-input-v3;
sourceRef 多余字段被清洗;身份字段严格绑定;哈希自洽。
跑法:.venv/bin/python .claude/skills/check-content-consistency/scripts/test_build_semantic_input.py
"""
from __future__ import annotations
import copy
import pathlib
import sys
import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR,
SKILLS_DIR / "check-content-consistency" / "scripts",
SKILLS_DIR / "write-next-chapter" / "scripts",
SKILLS_DIR / "assemble-context" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from run_writer_semantic_detector import ( # noqa: E402
build_semantic_input_v3, validate_semantic_detector_input,
)
from test_check_writer_candidate import _valid_pair # noqa: E402
class BuildSemanticInputV3Test(unittest.TestCase):
def test_projects_valid_input_that_passes_closed_validation(self) -> None:
context, candidate = _valid_pair()
payload = build_semantic_input_v3(
run_id=context["runId"], sample_id="writer-ch2", opaque_arm_id="production",
writer_context=context, candidate=candidate)
# 返回值本身必须能再过一遍闭集校验(幂等自洽)
validated = validate_semantic_detector_input(payload)
self.assertEqual(validated["runId"], context["runId"])
self.assertEqual(validated["candidateSha256"], candidate["candidateSha256"])
self.assertEqual(validated["candidateVersion"], candidate["candidateVersion"])
self.assertEqual(
validated["contextSnapshotSha256"], context["contextSnapshot"]["contextSha256"])
self.assertEqual(validated["asOf"], context["asOf"])
self.assertNotIn("_expectedAssertionIds", payload)
def test_cleans_source_ref_extra_fields(self) -> None:
context, candidate = _valid_pair()
# _bound_context 的 factEvidence.sourceRef 带 sourceType 多余字段,必须被清洗掉
payload = build_semantic_input_v3(
run_id=context["runId"], sample_id="writer-ch2", opaque_arm_id="production",
writer_context=context, candidate=candidate)
for item in payload["factEvidence"]:
self.assertNotIn("sourceType", item["sourceRef"])
# 原上下文不被改动
self.assertIn("sourceType", context["factEvidence"][0]["sourceRef"])
def test_outline_constraints_get_stable_ids(self) -> None:
context, candidate = _valid_pair()
payload = build_semantic_input_v3(
run_id=context["runId"], sample_id="writer-ch2", opaque_arm_id="production",
writer_context=context, candidate=candidate)
ids = [item["constraintId"] for item in payload["hardConstraints"]]
self.assertEqual(ids, [f"constraint-{i + 1}" for i in range(len(ids))])
self.assertEqual(
[item["constraintId"] for item in payload["fineOutline"]["hardConstraints"]], ids)
def test_candidate_body_hash_mismatch_fails_closed(self) -> None:
context, candidate = _valid_pair()
bad = copy.deepcopy(candidate)
bad["candidateSha256"] = "sha256:" + "f" * 64
with self.assertRaises(Exception):
build_semantic_input_v3(
run_id=context["runId"], sample_id="writer-ch2", opaque_arm_id="production",
writer_context=context, candidate=bad)
if __name__ == "__main__":
unittest.main()

View File

@ -11,8 +11,8 @@ import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
CONTINUATION_DIR = SKILLS_DIR / "continuation" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "read-context" / "scripts"
CONTINUATION_DIR = SKILLS_DIR / "write-next-chapter" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR):
sys.path.insert(0, str(path))

View File

@ -8,7 +8,7 @@ import hashlib
import pathlib
import sys
import unittest
from typing import Any, Mapping
from typing import Any, Mapping, Sequence
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
@ -16,6 +16,7 @@ from run_writer_semantic_detector import ( # noqa: E402
SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA,
SemanticDetectorContractError,
_quote_location,
build_safe_semantic_diagnostic,
canonical_sha256,
calculate_semantic_metrics,
run_writer_semantic_detector,
@ -143,6 +144,198 @@ class FakeRunner:
return {"structuredOutput": copy.deepcopy(self.output), "modelReceiptSha256": self.receipt_hash}
class SequenceFakeRunner:
"""按调用次序依次返回预设产出,用于驱动自我纠错环。"""
def __init__(self, outputs: Sequence[Any], receipt_hash: str = "sha256:" + "3" * 64) -> None:
self.outputs = [
output if isinstance(output, BaseException) else copy.deepcopy(dict(output))
for output in outputs
]
self.receipt_hash = receipt_hash
self.calls: list[dict[str, Any]] = []
def run(self, *, adapter_role: str, model_input: Mapping[str, Any], output_schema: Mapping[str, Any]) -> Mapping[str, Any]:
self.calls.append({"role": adapter_role, "input": copy.deepcopy(dict(model_input)), "schema": output_schema})
# 超出预设数量后一直复用最后一份产出,便于断言纠错轮次上界。
output = self.outputs[min(len(self.calls) - 1, len(self.outputs) - 1)]
if isinstance(output, BaseException):
raise output
return {"structuredOutput": copy.deepcopy(output), "modelReceiptSha256": self.receipt_hash}
class SemanticDetectorCorrectionTest(unittest.TestCase):
"""检测自我纠错环:首轮引文不合格 → 回喂上一轮产出+原因 → 纠错后合格。"""
def test_quote_not_found_then_corrected_returns_ok_after_two_calls(self) -> None:
# 首轮引了一句正文里没有的话(QUOTE_NOT_FOUND),纠错后换成正文里真实存在的原话。
bad = semantic_draft()
bad["assertionVerdicts"][0]["candidateQuote"] = "正文里根本没有的引文"
good = semantic_draft()
runner = SequenceFakeRunner([bad, good])
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "passed")
# 模型被调了 2 次:首轮不合格 + 一轮纠错。
self.assertEqual(len(runner.calls), 2)
# 首轮输入不带 correction。
self.assertNotIn("correction", runner.calls[0]["input"])
# 第二轮输入携带 correction:previousDraft 是首轮原始产出,error 说明引文不在正文里。
correction = runner.calls[1]["input"]["correction"]
self.assertEqual(correction["previousDraft"], bad)
self.assertIn("引文未在候选正文中出现", correction["error"])
# 纠错不放宽校验:第二轮合格产出仍被完整绑定(offset 由 adapter 计算)。
verdict = result["report"]["assertionVerdicts"][0]
self.assertEqual(verdict["candidateQuote"], "林澈守住城门")
self.assertEqual(verdict["startCodePoint"], 0)
def test_id_set_mismatch_correction_receives_expected_verdict_ids(self) -> None:
"""ID 集错误时把冻结顺序显式回喂,但仍由适配器执行完整覆盖校验。"""
bad = semantic_draft()
bad["assertionVerdicts"][0]["assertionId"] = "wrong-id"
runner = SequenceFakeRunner([bad, semantic_draft()])
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertTrue(result["ok"], result)
self.assertEqual(
runner.calls[1]["input"]["correction"]["expectedVerdictIds"],
{"assertionVerdicts": ["evidence-1"], "hardConstraintVerdicts": ["constraint-1"]},
)
def test_transient_api_error_retries_same_input_without_fake_correction(self) -> None:
"""可信 API 瞬时错误占用现有槽位,重发原输入后可恢复。"""
api_error = SemanticDetectorContractError(
"SEMANTIC_DETECTOR_API_ERROR", "瞬时 API 错误"
)
runner = SequenceFakeRunner([api_error, semantic_draft()])
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertTrue(result["ok"], result)
self.assertEqual(result["attemptCount"], 2)
self.assertEqual(result["correctionCount"], 0)
self.assertEqual(len(runner.calls), 2)
self.assertNotIn("correction", runner.calls[0]["input"])
self.assertNotIn("correction", runner.calls[1]["input"])
def test_second_transient_api_error_fails_without_third_call(self) -> None:
"""API 瞬时错误最多重试一次,不能吃掉所有格式纠错槽位后继续盲重试。"""
first = SemanticDetectorContractError(
"SEMANTIC_DETECTOR_API_ERROR", "第一次瞬时 API 错误"
)
second = SemanticDetectorContractError(
"SEMANTIC_DETECTOR_API_ERROR", "第二次瞬时 API 错误"
)
runner = SequenceFakeRunner([first, second, semantic_draft()])
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertFalse(result["ok"])
self.assertEqual(result["primaryCode"], "SEMANTIC_DETECTOR_API_ERROR")
self.assertEqual(result["safeDiagnostic"]["attemptCount"], 2)
self.assertEqual(result["safeDiagnostic"]["correctionCount"], 0)
self.assertEqual(len(runner.calls), 2)
def test_all_rounds_invalid_exhausts_corrections_and_fails_closed(self) -> None:
# 三轮都引错(max_corrections=2 → 总共最多 3 次调用),最终返回最后一轮的失败。
bad = semantic_draft()
bad["assertionVerdicts"][0]["candidateQuote"] = "始终不在正文里的引文"
runner = SequenceFakeRunner([bad])
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertFalse(result["ok"])
self.assertEqual(result["primaryCode"], "SEMANTIC_DETECTOR_QUOTE_NOT_FOUND")
self.assertEqual(len(runner.calls), 3)
# 第二、三轮都带纠错反馈,首轮不带。
self.assertNotIn("correction", runner.calls[0]["input"])
self.assertIn("correction", runner.calls[1]["input"])
self.assertIn("correction", runner.calls[2]["input"])
self.assertEqual(
result["safeDiagnostic"],
{
"schemaVersion": "semantic-diagnostic-v1",
"outcome": "invalid",
"primaryCode": "SEMANTIC_DETECTOR_QUOTE_NOT_FOUND",
"reasonCode": "QUOTE_NOT_FOUND",
"section": "assertion_verdicts",
"attemptCount": 3,
"correctionCount": 2,
"blockingCounts": {
"highFindings": 0,
"failedAssertions": 0,
"failedHardConstraints": 0,
"conflictingClaims": 0,
"evidenceGaps": 0,
"unknownAssertions": 0,
"unknownHardConstraints": 0,
"unknownClaims": 0,
},
},
)
def test_hostile_extra_key_never_enters_safe_diagnostic(self) -> None:
hostile = semantic_draft()
hostile["raw-/private/tmp-候选正文"] = "不应出现在安全摘要"
result = run_writer_semantic_detector(
semantic_input(), model_runner=FakeRunner(hostile), max_corrections=0
)
self.assertFalse(result["ok"])
diagnostic = result["safeDiagnostic"]
self.assertEqual(diagnostic["reasonCode"], "FIELD_SET_INVALID")
self.assertEqual(diagnostic["section"], "model_output")
serialized = str(diagnostic)
for forbidden in ("raw-/private/tmp-候选正文", "不应出现在安全摘要", "missing", "extra"):
self.assertNotIn(forbidden, serialized)
def test_safe_diagnostic_rejects_untrusted_code_and_unbounded_counts(self) -> None:
diagnostic = build_safe_semantic_diagnostic(
{
"ok": False,
"primaryCode": "SEMANTIC_BAD\n/private/tmp/raw",
"safeDiagnostic": {
"primaryCode": "SEMANTIC_BAD\n/private/tmp/raw",
"reasonCode": "不可信原因",
"section": "$.candidateBody",
"attemptCount": 100_000_000,
"correctionCount": 100_000_000,
},
}
)
self.assertEqual(diagnostic["primaryCode"], "SEMANTIC_DETECTOR_INVALID")
self.assertEqual(diagnostic["reasonCode"], "CONTRACT_INVALID")
self.assertEqual(diagnostic["section"], "model_output")
self.assertEqual(diagnostic["attemptCount"], 10_000)
self.assertEqual(diagnostic["correctionCount"], 9_999)
self.assertNotIn("/private/tmp", str(diagnostic))
def test_runner_failure_without_draft_is_not_corrected(self) -> None:
# runner 底座失败没有模型原始产出,纠错帮不上忙:只调一次即失败关闭。
class BrokenRunner:
def __init__(self) -> None:
self.calls = 0
def run(self, *, adapter_role: str, model_input: Mapping[str, Any], output_schema: Mapping[str, Any]) -> Mapping[str, Any]:
self.calls += 1
return {"structuredOutput": None} # 缺 modelReceiptSha256 → _invoke_model_runner 抛错
runner = BrokenRunner()
result = run_writer_semantic_detector(semantic_input(), model_runner=runner)
self.assertFalse(result["ok"])
self.assertEqual(result["primaryCode"], "SEMANTIC_DETECTOR_RECEIPT_BINDING_MISMATCH")
self.assertEqual(runner.calls, 1)
class SemanticDetectorV3Test(unittest.TestCase):
def test_model_schema_rejects_hash_offset_and_runtime_identity(self) -> None:
schema = SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA
@ -180,6 +373,38 @@ class SemanticDetectorV3Test(unittest.TestCase):
for forbidden in ("runId", "sampleId", "opaqueArmId", "candidateSha256", "contextSnapshotSha256", "authorizationSnapshotId", "inputSha256"):
self.assertNotIn(forbidden, serialized)
def test_verdict_id_set_is_normalized_to_input_order(self) -> None:
"""同一完整 ID 集乱序时确定性重排,缺失/重复仍由合同拒绝。"""
payload = semantic_input()
payload["factEvidence"].append({
"evidenceId": "evidence-2",
"fact": "旧徽章在城门",
"sourceType": "historical_prose",
"sourceRef": {"sourceId": "prose:487", "sourceVersion": "v1", "chapter": 487},
"contentSha256": "sha256:" + "3" * 64,
"riskLevel": "low",
})
payload["inputSha256"] = canonical_sha256({
key: value for key, value in payload.items() if key != "inputSha256"
})
draft = semantic_draft()
draft["assertionVerdicts"].append({
"assertionId": "evidence-2",
"verdict": "pass",
"candidateQuote": "林澈守住城门",
"evidenceIds": ["evidence-2"],
})
draft["assertionVerdicts"].reverse()
result = run_writer_semantic_detector(payload, model_runner=FakeRunner(draft))
self.assertTrue(result["ok"], result)
self.assertEqual(
[item["assertionId"] for item in result["report"]["assertionVerdicts"]],
["evidence-1", "evidence-2"],
)
def test_duplicate_quote_now_binds_first_occurrence(self) -> None:
# 引文在候选中出现多次不再失败关闭:绑定到首次出现("。" 在正文里出现两次)。
duplicate = semantic_draft()
@ -214,6 +439,46 @@ class SemanticDetectorV3Test(unittest.TestCase):
self.assertEqual(result["report"]["status"], "failed")
self.assertEqual(result["report"]["findings"][0]["severity"], "high")
self.assertEqual(result["metrics"]["highSeverityCount"], 1)
diagnostic = build_safe_semantic_diagnostic(result)
self.assertEqual(diagnostic["outcome"], "failed")
self.assertEqual(diagnostic["reasonCode"], "SEMANTIC_BLOCKED")
self.assertEqual(diagnostic["blockingCounts"]["highFindings"], 1)
self.assertEqual(diagnostic["blockingCounts"]["failedHardConstraints"], 0)
serialized = str(diagnostic)
for forbidden in ("candidateQuote", "message", "旧徽章", "constraint-1"):
self.assertNotIn(forbidden, serialized)
def test_corrected_blocking_report_preserves_attempt_count(self) -> None:
invalid = semantic_draft()
invalid["assertionVerdicts"][0]["candidateQuote"] = "正文里不存在的引文"
result = run_writer_semantic_detector(
semantic_input(),
model_runner=SequenceFakeRunner([invalid, semantic_draft(failed=True)]),
)
diagnostic = build_safe_semantic_diagnostic(result)
self.assertEqual(result["attemptCount"], 2)
self.assertEqual(diagnostic["outcome"], "failed")
self.assertEqual(diagnostic["attemptCount"], 2)
self.assertEqual(diagnostic["correctionCount"], 1)
def test_needs_evidence_safe_diagnostic_contains_counts_only(self) -> None:
result = run_writer_semantic_detector(
semantic_input(), model_runner=FakeRunner(semantic_draft(gap=True))
)
diagnostic = build_safe_semantic_diagnostic(result)
self.assertEqual(diagnostic["outcome"], "needs_evidence")
self.assertEqual(diagnostic["primaryCode"], "SEMANTIC_EVIDENCE_REQUIRED")
self.assertEqual(diagnostic["blockingCounts"]["evidenceGaps"], 1)
self.assertEqual(diagnostic["blockingCounts"]["unknownAssertions"], 1)
serialized = str(diagnostic)
for forbidden in (
"candidateQuote", "旧徽章来源", "候选出现未覆盖物品", "gap-1"
):
self.assertNotIn(forbidden, serialized)
def test_ellipsis_reference_not_flagged_but_real_traversal_blocked(self) -> None:
# 省略号 `...` 含子串 `..`,旧的 `".." in text` 会误判为路径穿越;精确判定必须放行。
@ -238,6 +503,66 @@ class SemanticDetectorV3Test(unittest.TestCase):
self.assertFalse(result["ok"])
self.assertEqual(result["primaryCode"], "SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID")
def test_non_unknown_claim_gap_reason_is_deterministically_discarded(self) -> None:
# WHY: supported/conflict/declared_new 已有闭集结论,模型残留的解释不应阻断整份报告,
# 也不得进入可信报告参与状态或哈希计算。
for coverage_state in ("supported", "conflict", "declared_new"):
with self.subTest(coverage_state=coverage_state):
draft = semantic_draft()
draft["claims"][0]["coverageState"] = coverage_state
draft["claims"][0]["gapReason"] = "模型残留的冗余解释"
result = run_writer_semantic_detector(
semantic_input(), model_runner=FakeRunner(draft), max_corrections=0
)
self.assertTrue(result["ok"])
bound_claim = result["report"]["claims"][0]
self.assertEqual(bound_claim["coverageState"], coverage_state)
self.assertNotIn("gapReason", bound_claim)
def test_non_unknown_verdict_gap_reason_is_deterministically_discarded(self) -> None:
# assertion 与 hard constraint 共用同一绑定器;分别覆盖 pass/fail,确保两类列表都收敛。
cases = (
("assertionVerdicts", "pass"),
("assertionVerdicts", "fail"),
("hardConstraintVerdicts", "pass"),
("hardConstraintVerdicts", "fail"),
)
for field, verdict in cases:
with self.subTest(field=field, verdict=verdict):
draft = semantic_draft()
draft[field][0]["verdict"] = verdict
draft[field][0]["gapReason"] = "模型残留的冗余解释"
result = run_writer_semantic_detector(
semantic_input(), model_runner=FakeRunner(draft), max_corrections=0
)
self.assertTrue(result["ok"])
bound_verdict = result["report"][field][0]
self.assertEqual(bound_verdict["verdict"], verdict)
self.assertNotIn("gapReason", bound_verdict)
def test_unknown_claim_and_hard_constraint_without_gap_reason_fail_closed(self) -> None:
# unknown 的解释不是冗余字段:缺失时仍须失败关闭,防止“未知”成为无理由逃生口。
cases = (
("claims", "coverageState"),
("hardConstraintVerdicts", "verdict"),
)
for field, state_field in cases:
with self.subTest(field=field):
draft = semantic_draft()
draft[field][0][state_field] = "unknown"
result = run_writer_semantic_detector(
semantic_input(), model_runner=FakeRunner(draft), max_corrections=0
)
self.assertFalse(result["ok"])
self.assertEqual(result["primaryCode"], "SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID")
self.assertEqual(result["safeDiagnostic"]["reasonCode"], "GAP_REASON_REQUIRED")
def test_old_v2_and_v1_reports_fail_closed(self) -> None:
payload = semantic_input()
for version in ("semantic-detector-report-v2", "semantic-detector-report-v1"):

View File

@ -1,29 +1,29 @@
---
name: clean
description: LLM 辅助正文清洗——MiniMax-M3 按窗口(约10万字)只输出待删垃圾段原文与理由,代码做精确匹配删除并全程留审计(example_clean_log)。LLM 不改写正文,删不删由守卫规则最终裁决。
name: clean-book-text
description: 识别并删除参考书或旧稿中的广告、水印、作者拉票和乱码噪声,同时保留逐段审计。静态导入后仍有语义垃圾时使用;模型只提候选,确定性守卫决定是否删除,绝不改写正文。
---
# clean —— LLM 检测 + 代码执行的正文清洗
# 清洗书稿正文
分工(创始人方案 2026-07-13):**规则层**已在 import 解决结构性垃圾(水印正则/重贴章题/目录页/分页尾巴/残破实体);本 skill 处理**语义垃圾**——变体书站广告、作者拉票/PS 段、微信导流、乱码水印等正则打不全的散落噪声。LLM 只当探测器(输出待删片段逐字原文),删除由脚本执行:零改写、可审计、可回放。探测模型=New-API `MiniMax-M3`(经 llm skill 的受治理入口 `chat_governed`;模型降级与额度治理走全局 `BUDGET_CHAIN` 与 5h 额度窗,本 skill 不自写降级链。全链耗尽时该窗记 skipped、不清洗、不返回假成功——网文正文会触发上游敏感词拦截,由治理链自动换模型救回)。
分工(创始人方案 2026-07-13):**规则层**已在 `import-book` 解决结构性垃圾(水印正则/重贴章题/目录页/分页尾巴/残破实体);本 Skill 处理**语义垃圾**——变体书站广告、作者拉票/PS 段、微信导流、乱码水印等正则打不全的散落噪声。LLM 只当探测器(输出待删片段逐字原文),删除由脚本执行:零改写、可审计、可回放。探测模型=New-API `MiniMax-M3`(经 `call-content-model` 的受治理入口 `chat_governed`;模型降级与额度治理走全局 `BUDGET_CHAIN` 与 5h 额度窗,本 Skill 不自写降级链。全链耗尽时该窗记 skipped、不清洗、不返回假成功——网文正文会触发上游敏感词拦截,由治理链自动换模型救回)。
## 流程
```bash
# 放量驱动(每书 prep→detect→apply 全链;断点续跑/幂等防重删;可多进程分书并行)
.venv/bin/python .claude/skills/clean/scripts/clean_batch.py --work-id 7 --work-id 10 --batch <批次号>
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_batch.py --work-id 7 --work-id 10 --batch <批次号>
# 单步(调试/演示用)
.venv/bin/python .claude/skills/clean/scripts/clean_prep.py --work-id 7 [--from 1 --to 50] # 切窗
.venv/bin/python .claude/skills/clean/scripts/clean_detect.py --work-id 7 [--win 1] # M3 探测
.venv/bin/python .claude/skills/clean/scripts/clean_apply.py --work-id 7 --batch X --file … [--dry-run] [--report-md docs/清洗-N-书名.md]
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_prep.py --work-id 7 [--from 1 --to 50] # 切窗
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_detect.py --work-id 7 [--win 1] # M3 探测
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_apply.py --work-id 7 --batch X --file … [--dry-run] [--report-md docs/清洗-N-书名.md]
# 收尾收割:高频水印全书规则扫净(LLM 每窗只报样例,重复水印靠种子收割)
.venv/bin/python .claude/skills/clean/scripts/clean_sweep.py --work-id 7 --batch X # 审计自动种子(重复≥3次且≥20字)
.venv/bin/python .claude/skills/clean/scripts/clean_sweep.py --work-id 4 --batch X --seed "http://m." # 人工确认的碎水印
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_sweep.py --work-id 7 --batch X # 审计自动种子(重复≥3次且≥20字)
.venv/bin/python .claude/skills/clean-book-text/scripts/clean_sweep.py --work-id 4 --batch X --seed "http://m." # 人工确认的碎水印
# 审查:审计对账
.venv/bin/python .claude/skills/db/scripts/db.py query "SELECT batch, count(*), sum(length(removed_text)) FROM example_clean_log WHERE work_id=7 GROUP BY batch"
.venv/bin/python .claude/skills/access-database/scripts/db.py query "SELECT batch, count(*), sum(length(removed_text)) FROM example_clean_log WHERE work_id=7 GROUP BY batch"
```
⚠️ prep 会覆盖 /tmp/muse-clean/<work>/ 的窗与 manifest——换范围重切前先归档旧产物目录。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""clean skill:精确匹配删除执行器(LLM 建议 ≠ 必删,守卫规则最终裁决)+ 审计入库。
"""clean-book-text Skill:精确匹配删除执行器(LLM 建议 ≠ 必删,守卫规则最终裁决)+ 审计入库。
输入 deletions JSON:[{chapter_order|chapter, exact, reason}](键名 ASCII,兼容中文键)。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""clean skill:放量驱动——每书串行跑 prep→detect→apply 全链,可多进程分书并行。
"""clean-book-text Skill:放量驱动——每书串行跑 prep→detect→apply 全链,可多进程分书并行。
断点续跑设计:
- prep 仅在该书 manifest 缺失时执行(防覆盖已切窗);

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""clean skill:LLM 探测执行器——每窗一次受治理调用(chat_governed),产出 deletions JSON。
"""clean-book-text Skill:LLM 探测执行器——每窗一次受治理调用,产出 deletions JSON。
读 clean_prep 产出的 manifest.json,逐窗经 llm.chat_governed 探测垃圾段(只报逐字原文,不改写),
写 /tmp/muse-clean/<work>/deletions-NNN.json。断点续跑:已有产物的窗自动跳过。
@ -13,8 +13,8 @@ import time
import click
# 统一走 llm skill 受治理入口(额度窗/全局降级链/熔断 + trust_env/重试/<think>剥离/JSON 容错都在那边)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
# 统一走 call-content-model 受治理入口(额度窗/全局降级链/熔断 + trust_env/重试/<think>剥离/JSON 容错都在那边)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
from llm import chat_governed, extract_json # noqa: E402
OUT = pathlib.Path("/tmp/muse-clean")
@ -65,7 +65,7 @@ def main(work_id, wins, model, force):
# 降级与额度治理已上收 llm.chat_governed(全局 BUDGET_CHAIN + 5h 额度窗):撞内容安全/
# 模型不可用由它沿全局链自动换模型并按窗预算/调用数治理;本脚本不自写降级链。
# 单窗失败不中断整书——全链耗尽或输出无法解析时失败关闭:该窗不清洗、不返回假成功。
content, usage, used_model = chat_governed(prompt, model=model)
content, usage, used_model = chat_governed(prompt, model=model, caller="clean")
if used_model is None:
# 治理链全部耗尽(多为上游敏感词拦截):写空产物占位(含跳过原因),
# apply 端窗产物齐备可继续,审计可追——绝不拿空内容当成功

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""clean skill:按章对齐切窗(LLM 探测输入面)。窗文件含章题标记行,便于 LLM 报 chapter_order。"""
"""clean-book-text Skill:按章对齐切窗。窗文件含章题标记行,便于 LLM 报 chapter_order。"""
import json
import pathlib
import re

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""clean skill:高频水印全书规则收割(LLM 探测样例 → 代码全书扫净)。
"""clean-book-text Skill:高频水印全书规则收割(LLM 探测样例 → 代码全书扫净)。
M3 按窗探测对「每章重复的固定水印」只会报样例章(窗内看到≠逐章报全),残留由本脚本收割:
- 种子=example_clean_log 中该书重复删除段(相同 removed_text 出现 ≥min-occur 次、长度 ≥min-len)——

View File

@ -1,6 +1,6 @@
---
name: confirm
description: 确认=把创作产出从待审(Shadow)转为正式事实(Canonical)。正文候选经 scripts/write_canonical.py 单事务写库(正文块+来源归因+决策归档+翻候选态);知识卡走 draft→entity 双轨。全仓唯一的确认通道,仅由用户指令触发。
name: decide-candidate
description: 根据用户明确指令接受、合并或丢弃 Shadow 候选,并以受控事务更新 Canonical、归因和决策记录。用户已经对正文、知识卡或规划作出决定时使用;不得由 Agent 自行触发。
---
# 确认 / 丢弃(Shadow→Canonical 的唯一入口)
@ -14,24 +14,26 @@ description: 确认=把创作产出从待审(Shadow)转为正式事实(Canonical
两步,顺序不可颠倒:
1. **先过接受前置检查(纯函数,不写库)**:`scripts/check_writer_acceptance.py`
- `check_shadow_ready` 校验生产模式、`acceptanceEligible=true`、候选正文 hash、冻结上下文 ID/hash、`writer-production-v1` 策略、来源状态、候选有效期,以及 detector 最终报告对当前 `attempt/candidateVersion/candidateSha256` 的绑定。
- `check_shadow_ready` 校验生产模式、`acceptanceEligible=true`、候选正文 hash、冻结上下文 ID/hash、`writer-production-v1` 策略、来源状态、候选有效期,以及 detector 最终报告(`writer-pipeline-result-v1`,要求最终轨迹同时通过机械门与语义审查)对当前 `attempt/candidateVersion/candidateSha256` 的绑定。
- 实时状态由 `scripts/acceptance_state.py` 从库重读(冻结行、授权快照、已确认细纲、Canonical revision、接受窗口),编排层不得用内存旧快照冒充。
- `accept/merge` 必须实时匹配 `expectedRevision`;冲突返回 `REVISION_CONFLICT`,不得覆盖 Canonical。
- `acceptanceEligible=false` 的诊断/评测候选在第一道门硬拒绝,不能靠改参数、重跑 fake 或用户确认混进接受链。
- detector 真实模型尚未实现;fake 通过只用于接口测试,不能作为真实正文接受依据。
- 生产链的 detector 终态来自 `run_writer_pipeline` 的真实机械门 + 语义 detector 双报告;LLM 自然语言 PASS 不是裁决依据,只有结构化报告过检才可接受。
2. **前置通过后,经写入层落库(单事务)**:`scripts/write_canonical.py`(复用 db skill 的 DSN)
2. **前置通过后,经写入层落库(单事务)**:`scripts/write_canonical.py`(复用 `access-database` 的 DSN)
- 接受:
```bash
.venv/bin/python .claude/skills/confirm/scripts/write_canonical.py accept <candidate_id> \
--expected-revision <N> --rationale "为什么接受" --basis-ref "大纲@日期" --command-id <幂等ID>
.venv/bin/python .claude/skills/decide-candidate/scripts/write_canonical.py accept <candidate_id> \
--expected-revision <N> --rationale "为什么接受" --basis-ref "大纲@日期" --command-id <幂等ID> \
[--approved-deltas <已批准增量JSON数组>]
# 先试跑(完整走一遍事务再回滚,校验不落库):加 --dry-run
```
- 丢弃:
```bash
.venv/bin/python .claude/skills/confirm/scripts/write_canonical.py discard <candidate_id> --rationale "为什么丢弃"
.venv/bin/python .claude/skills/decide-candidate/scripts/write_canonical.py discard <candidate_id> --rationale "为什么丢弃"
```
- 写入层按落库设计 §2.9 单事务执行:写正文块(`content_text`,revision+1,CAS 乐观锁)→ 写来源归因(`muse_content_block_source_attribution`,来源权威落块,架构-02 §3)→ 写命令幂等审计 → 写决策归档(`example_user_decision`)→ 翻候选 `state='accepted'`。**任一失败整体回滚,绝不留无来源指针的正式正文。**
- DB 级兜底硬校验(不靠调用方自觉):`run_type` 非 production 拒绝接受(05 §8.4)、`state` 非 passed 拒绝、revision 冲突拒绝。
- 写入层按落库设计 §2.9 单事务执行:写正文块(`content_text`,revision+1,CAS 乐观锁)→ 写来源归因(`muse_content_block_source_attribution`,来源权威落块,架构-02 §3)→ 合并已批准事实增量(`fact_delta.py`,进 `example_fact_ledger` 正典账本)→ 登记投影(`projection_registry.py`,旧 revision 投影翻 stale、新 revision 登记 pending)→ 写命令幂等审计 → 写决策归档(`example_user_decision`)→ 翻候选 `state='accepted'`。**任一失败整体回滚,绝不留无来源指针的正式正文,也绝不产生正文已提交而事实半合并。**
- DB 级兜底硬校验(不靠调用方自觉):`run_type` 非 production 拒绝接受(05 §8.4)、`state` 非 passed 拒绝、`semantic_status` 非 passed 拒绝(先审后入)、revision 冲突拒绝。
## merge(修改后合并)
@ -40,7 +42,7 @@ description: 确认=把创作产出从待审(Shadow)转为正式事实(Canonical
## 知识卡 / 规划:各自的确认轨
- **知识卡**:确认 = `muse_knowledge_draft` 翻 `confirmed` + 落 `muse_knowledge_entity(active)`(关系卡落 `muse_knowledge_relation`),并在同一事务内确保作品↔知识库绑定、迁移实体向量 owner。使用 `.venv/bin/python .claude/skills/confirm/scripts/confirm_knowledge.py --draft-id <id> --dry-run` 试跑;实际确认只能在用户明确确认后执行。批量实体/关系必须显式给 `--all-entities <work_id>` 或 `--all-relations <work_id>`。**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。
- **知识卡**:确认 = `muse_knowledge_draft` 翻 `confirmed` + 落 `muse_knowledge_entity(active)`(关系卡落 `muse_knowledge_relation`),并在同一事务内确保作品↔知识库绑定、迁移实体向量 owner。使用 `.venv/bin/python .claude/skills/decide-candidate/scripts/confirm_knowledge.py --draft-id <id> --dry-run` 试跑;实际确认只能在用户明确确认后执行。批量实体/关系必须显式给 `--all-entities <work_id>` 或 `--all-relations <work_id>`。**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。
- **规划**(大纲/细纲/设定):规划表(100)落库前,暂以 git 提交确认——只 `git add` 用户点名的创作文件,**严禁混入框架文件(agents/skills/meta)**;提交信息 `作品(书名): 确认 设定包/大纲vN | 来源: planner`。规划表建成后改为库内 shadow→confirmed。
## 红线

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""接受前置检查的实时状态重读(生产模式)。
check_writer_acceptance 是无副作用纯函数,不碰库;本模块负责在**接受时刻**从 muse-example
重读冻结行、Canonical 正文 revision、已确认细纲状态与授权快照,组装严格 live_state。
编排层不得用内存里的旧快照冒充实时状态——这里读到什么,preflight 就拿什么比对。
"""
from __future__ import annotations
import json
import pathlib
import sys
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
DB_DIR = pathlib.Path(__file__).resolve().parents[2] / "access-database" / "scripts"
if str(DB_DIR) not in sys.path:
sys.path.insert(0, str(DB_DIR))
from db import connect # noqa: E402
PRODUCTION_POLICY = "writer-production-v1"
# 候选接受窗口:生成后 24 小时内必须完成接受,超期 preflight 报 CANDIDATE_EXPIRED。
ACCEPTANCE_WINDOW = timedelta(hours=24)
class LiveStateError(RuntimeError):
"""实时状态不可读或形状非法——失败关闭,不得带病进入 preflight。"""
def _bare_sha(value: str) -> str:
value = str(value or "")
return value[len("sha256:"):] if value.startswith("sha256:") else value
def _parse_tz(value: Any) -> datetime | None:
"""解析带时区 ISO 时间;非法返回 None(由调用方决定是否失败关闭)。"""
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _iso_z(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def build_live_acceptance_state(
context: Mapping[str, Any], *, now: datetime | None = None
) -> dict[str, Any]:
"""按 check_writer_acceptance 的 live_state 闭集字段重读实时状态。"""
if not isinstance(context, Mapping):
raise LiveStateError("context 必须是对象")
context_sha = _bare_sha(context.get("contextSnapshot", {}).get("contextSha256"))
if len(context_sha) != 64:
raise LiveStateError("context 缺 contextSnapshot.contextSha256")
work_id = context.get("workId")
target_chapter = context.get("targetChapter")
if not isinstance(work_id, int) or not isinstance(target_chapter, int):
raise LiveStateError("context 缺 workId/targetChapter")
checked_at = now or datetime.now(timezone.utc)
with connect(readonly=True) as conn:
freeze = conn.execute(
"SELECT manifest_sha256, context_sha256, authorization_snapshot, create_time "
"FROM example_context_freeze WHERE context_sha256=%s ORDER BY id DESC LIMIT 1",
(context_sha,),
).fetchone()
if not freeze:
raise LiveStateError("CONTEXT_NOT_FROZEN:冻结上下文未落库,接受前置检查拒绝继续")
manifest_sha, freeze_context_sha, auth_payload, freeze_time = freeze
revision_row = conn.execute(
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND c.order_no=%s AND b.deleted=false",
(work_id, target_chapter),
).fetchone()
canonical_revision = int(revision_row[0])
outline_row = conn.execute(
"SELECT state FROM example_planning_section WHERE work_id=%s "
"AND section_type='fine_outline' AND target_chapter=%s AND deleted=false "
"ORDER BY version DESC LIMIT 1",
(work_id, target_chapter),
).fetchone()
source_status = "active" if outline_row and outline_row[0] == "confirmed" else "stale"
# 授权快照以冻结行为准重读:快照 ID 必须与候选绑定一致,且核验时间不晚于本次检查。
auth = auth_payload if isinstance(auth_payload, Mapping) else {}
if isinstance(auth_payload, str):
try:
auth = json.loads(auth_payload)
except ValueError:
auth = {}
context_snapshot = context.get("authorizationSnapshot", {})
context_snapshot_id = context_snapshot.get("snapshotId") if isinstance(context_snapshot, Mapping) else None
authorization_valid = bool(
auth.get("snapshotId")
and context_snapshot_id
and auth.get("snapshotId") == context_snapshot_id
)
if authorization_valid:
verified_at = _parse_tz(auth.get("verifiedAt"))
authorization_valid = verified_at is not None and verified_at <= checked_at
# WriterContext 合同里 generatedAt 住在 contextSnapshot 内,顶层没有该字段
generated_at = _parse_tz(context.get("contextSnapshot", {}).get("generatedAt"))
if generated_at is None and freeze_time is not None:
generated_at = freeze_time.replace(tzinfo=timezone.utc)
if generated_at is None:
raise LiveStateError("无法确定候选生成时间,接受窗口不可计算")
expires_at = generated_at + ACCEPTANCE_WINDOW
return {
"qualityPolicyVersion": PRODUCTION_POLICY,
"contextSnapshotId": "sha256:" + str(manifest_sha),
"contextSnapshotSha256": "sha256:" + str(freeze_context_sha),
"authorizationSnapshotId": str(auth.get("snapshotId") or ""),
"authorizationValid": authorization_valid,
"sourceStatus": source_status,
"candidateExpiresAt": _iso_z(expires_at),
"checkedAt": _iso_z(checked_at),
"canonicalRevision": canonical_revision,
}
__all__ = ["ACCEPTANCE_WINDOW", "LiveStateError", "PRODUCTION_POLICY", "build_live_acceptance_state"]

View File

@ -9,7 +9,7 @@ from datetime import datetime
from typing import Any, Mapping
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "read-context" / "scripts"
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
if str(READ_CONTEXT_DIR) not in sys.path:
sys.path.insert(0, str(READ_CONTEXT_DIR))

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""知识草稿确认:把待审实体/关系写入作品正式知识面。
正文候选和知识卡共用 confirm skill 的主权边界,但知识表不是正文表:
正文候选和知识卡共用 decide-candidate 的主权边界,但知识表不是正文表:
实体写入 ``muse_knowledge_entity``,关系写入 ``muse_knowledge_relation``,
草稿状态、知识库绑定和向量 owner 在同一事务内完成。默认命令只确认点名
草稿;批量确认必须显式给出 ``--all-entities`` 或 ``--all-relations``。
@ -13,7 +13,7 @@ import sys
from copy import deepcopy
DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts"
DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "access-database" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402

View File

@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""结构化事实增量(DeltaProposal + reducer)—— 模型只提变更,事实由代码合并。
合同要点(先审后入的实体面):
- 模型/抽取只能提出**类型化增量提案**(六种闭集类型),不能重写整份状态;
- 每条提案必须带正文证据引文,reducer 确定性校验引文出现在候选正文中(不得编造证据);
- 提案默认 `proposed`,**抽取结果绝不自动升格**;只有显式批准的增量才随正文在同一事务
进 example_fact_ledger(ChapterCommit 的一部分),并绑正文块 revision 与 command_id;
- 账本 append-only:正文被替换后按 source_block_revision 判 stale,旧事实不冒充当前状态。
本模块的 validate/apply 是纯逻辑(apply 使用调用方传入的连接与事务,不自行提交);
propose_fact_deltas 是抽取侧登记提案的独立入口(自持短事务)。
"""
from __future__ import annotations
import json
import pathlib
import sys
from typing import Any, Mapping, Sequence
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
DB_DIR = SKILLS_DIR / "access-database" / "scripts"
for path in (READ_CONTEXT_DIR, DB_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from writer_contract import normalize_text # noqa: E402
DELTA_TYPES = frozenset({
"character_location_changed",
"character_knowledge_added",
"relationship_changed",
"hook_advanced",
"timeline_event_added",
"setting_added",
})
HOOK_ACTIONS = frozenset({"planted", "advanced", "resolved", "deferred"})
_PROPOSAL_FIELDS = frozenset({"deltaId", "deltaType", "payload", "evidenceQuote"})
CREATOR = "extractor"
class FactDeltaError(RuntimeError):
"""增量提案非法或无法合并——失败关闭,不静默丢弃也不带病入库。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _require_str(value: Any, path: str) -> str:
if not isinstance(value, str) or not value.strip():
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", f"{path} 必须是非空字符串")
return value
def _validate_payload(delta_type: str, payload: Any) -> dict[str, Any]:
"""逐型校验 payload 闭集并返回归一化副本。"""
if not isinstance(payload, Mapping):
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "payload 必须是对象")
payload = dict(payload)
if delta_type == "character_location_changed":
if set(payload) - {"characterName", "fromLocation", "toLocation"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "位置增量含未知字段")
_require_str(payload.get("characterName"), "payload.characterName")
_require_str(payload.get("toLocation"), "payload.toLocation")
if payload.get("fromLocation") is not None:
_require_str(payload["fromLocation"], "payload.fromLocation")
elif delta_type == "character_knowledge_added":
if set(payload) != {"characterName", "knowledge"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "知情增量字段非法")
_require_str(payload.get("characterName"), "payload.characterName")
_require_str(payload.get("knowledge"), "payload.knowledge")
elif delta_type == "relationship_changed":
if set(payload) != {"fromName", "toName", "relation"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "关系增量字段非法")
_require_str(payload.get("fromName"), "payload.fromName")
_require_str(payload.get("toName"), "payload.toName")
_require_str(payload.get("relation"), "payload.relation")
elif delta_type == "hook_advanced":
if set(payload) - {"hookId", "action", "note", "dueWindow"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "伏笔增量含未知字段")
_require_str(payload.get("hookId"), "payload.hookId")
if payload.get("action") not in HOOK_ACTIONS:
raise FactDeltaError(
"FACT_DELTA_PAYLOAD_INVALID",
f"payload.action 必须属于 {sorted(HOOK_ACTIONS)}")
if payload.get("note") is not None:
_require_str(payload["note"], "payload.note")
due = payload.get("dueWindow")
if due is not None:
if (not isinstance(due, Mapping) or set(due) != {"fromChapter", "toChapter"}
or isinstance(due.get("fromChapter"), bool)
or not isinstance(due.get("fromChapter"), int)
or isinstance(due.get("toChapter"), bool)
or not isinstance(due.get("toChapter"), int)
or due["fromChapter"] < 1 or due["toChapter"] < due["fromChapter"]):
raise FactDeltaError(
"FACT_DELTA_PAYLOAD_INVALID", "payload.dueWindow 必须是合法章区间")
payload["dueWindow"] = dict(due)
elif delta_type == "timeline_event_added":
if set(payload) != {"event"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "时间线增量字段非法")
_require_str(payload.get("event"), "payload.event")
else: # setting_added(DELTA_TYPES 闭集已在外层校验)
if set(payload) != {"factType", "text"}:
raise FactDeltaError("FACT_DELTA_PAYLOAD_INVALID", "设定增量字段非法")
_require_str(payload.get("factType"), "payload.factType")
_require_str(payload.get("text"), "payload.text")
return payload
def _derive_subject_key(delta_type: str, payload: Mapping[str, Any], target_chapter: int) -> str:
"""派生观察索引键:人物类=人名,关系=双向对,伏笔=hookId,其余按章定位。"""
if delta_type in ("character_location_changed", "character_knowledge_added"):
return str(payload["characterName"])
if delta_type == "relationship_changed":
return f"{payload['fromName']}→{payload['toName']}"
if delta_type == "hook_advanced":
return str(payload["hookId"])
if delta_type == "setting_added":
return str(payload["factType"])
return f"event@ch{target_chapter}"
def validate_delta_proposal(
proposal: Any, *, candidate_body: str, target_chapter: int
) -> dict[str, Any]:
"""校验单条增量提案:字段闭集、类型闭集、payload 合同、证据引文真实存在。"""
if not isinstance(proposal, Mapping):
raise FactDeltaError("FACT_DELTA_INVALID", "增量提案必须是对象")
if set(proposal) != _PROPOSAL_FIELDS:
missing = sorted(_PROPOSAL_FIELDS - set(proposal))
extra = sorted(set(proposal) - _PROPOSAL_FIELDS)
raise FactDeltaError(
"FACT_DELTA_INVALID", f"增量提案字段非法 missing={missing} extra={extra}")
delta_id = _require_str(proposal.get("deltaId"), "deltaId")
if len(delta_id) > 64:
raise FactDeltaError("FACT_DELTA_INVALID", "deltaId 超长")
delta_type = proposal.get("deltaType")
if delta_type not in DELTA_TYPES:
raise FactDeltaError("FACT_DELTA_TYPE_INVALID", f"deltaType 非法: {delta_type!r}")
if not isinstance(target_chapter, int) or isinstance(target_chapter, bool) or target_chapter < 1:
raise FactDeltaError("FACT_DELTA_INVALID", "target_chapter 非法")
payload = _validate_payload(delta_type, proposal.get("payload"))
quote = proposal.get("evidenceQuote")
if not isinstance(quote, str) or not quote.strip():
raise FactDeltaError("FACT_DELTA_EVIDENCE_REQUIRED", "增量提案必须携带正文证据引文")
body = normalize_text(candidate_body)
normalized_quote = normalize_text(quote)
if normalized_quote not in body:
# 与语义 detector 同规则:0 次命中 = 编造证据,失败关闭
raise FactDeltaError(
"FACT_DELTA_QUOTE_NOT_FOUND", "证据引文未在候选正文中出现(不得编造证据)")
return {
"deltaId": delta_id,
"deltaType": delta_type,
"payload": payload,
"evidenceQuote": normalized_quote,
"subjectKey": _derive_subject_key(delta_type, payload, target_chapter),
}
def validate_delta_batch(
proposals: Sequence[Any], *, candidate_body: str, target_chapter: int
) -> list[dict[str, Any]]:
"""批量校验并拒绝重复 deltaId。"""
if not isinstance(proposals, Sequence) or isinstance(proposals, (str, bytes)):
raise FactDeltaError("FACT_DELTA_INVALID", "增量提案必须是数组")
normalized = [
validate_delta_proposal(item, candidate_body=candidate_body,
target_chapter=target_chapter)
for item in proposals
]
ids = [item["deltaId"] for item in normalized]
if len(ids) != len(set(ids)):
raise FactDeltaError("FACT_DELTA_DUPLICATE_ID", "同批 deltaId 不得重复")
return normalized
def apply_accepted_deltas(
conn,
*,
work_id: int,
target_chapter: int,
run_id: str | None,
candidate_sha256_bare: str,
candidate_body: str,
deltas: Sequence[Mapping[str, Any]],
block_revision: int,
command_id: str | None,
decided_by: str,
rationale: str | None = None,
) -> list[int]:
"""把**已批准**的增量随正文同一事务落提案表(accepted)与正典账本。
使用调用方的连接与事务,不自行 commit:任一增量非法则抛错,由外层整体回滚,
绝不会出现"正文已提交但事实半合并"。
两条入口都合到这里:抽取侧已用 propose_fact_deltas 登记过同 deltaId 的 proposed 行时,
走条件 UPDATE 翻态(propose→approve 正道,不撞唯一键);未登记过的直接 INSERT 为 accepted。
已被裁决过的行(accepted/rejected/superseded)不得再次接受,失败关闭给稳定错误码。
"""
normalized = validate_delta_batch(
deltas, candidate_body=candidate_body, target_chapter=target_chapter)
delta_ids: list[int] = []
for item in normalized:
payload_json = json.dumps(item["payload"], ensure_ascii=False)
existing = conn.execute(
"SELECT id, status FROM example_fact_delta WHERE tenant_id=0 AND candidate_sha256=%s "
"AND delta_id=%s AND deleted=false",
(candidate_sha256_bare, item["deltaId"]),
).fetchone()
if existing is not None:
if existing[1] != "proposed":
raise FactDeltaError(
"FACT_DELTA_ALREADY_DECIDED",
f"增量 {item['deltaId']} 已是 {existing[1]},不得再次接受")
delta_row_id = conn.execute(
"UPDATE example_fact_delta SET status='accepted', decided_by=%s, "
"decision_rationale=%s, source_revision=%s, updater=%s "
"WHERE id=%s AND status='proposed' RETURNING id",
(decided_by, rationale, block_revision, CREATOR, existing[0]),
).fetchone()
if delta_row_id is None:
raise FactDeltaError(
"FACT_DELTA_ALREADY_DECIDED",
f"增量 {item['deltaId']} 翻态竞争失败(状态已被并发裁决)")
delta_row_id = delta_row_id[0]
else:
delta_row_id = conn.execute(
"INSERT INTO example_fact_delta(work_id, target_chapter, run_id, candidate_sha256, "
"delta_id, delta_type, subject_key, payload, evidence_quote, status, decided_by, "
"decision_rationale, source_revision, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,'accepted',%s,%s,%s,%s) RETURNING id",
(work_id, target_chapter, run_id, candidate_sha256_bare, item["deltaId"],
item["deltaType"], item["subjectKey"], payload_json,
item["evidenceQuote"], decided_by, rationale, block_revision, CREATOR),
).fetchone()[0]
conn.execute(
"INSERT INTO example_fact_ledger(work_id, target_chapter, delta_ref, delta_type, "
"subject_key, payload, source_candidate_sha256, source_block_revision, command_id, creator) "
"VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)",
(work_id, target_chapter, delta_row_id, item["deltaType"], item["subjectKey"],
payload_json, candidate_sha256_bare,
block_revision, command_id, CREATOR),
)
delta_ids.append(delta_row_id)
return delta_ids
def propose_fact_deltas(
*,
work_id: int,
target_chapter: int,
run_id: str | None,
candidate_sha256_bare: str,
candidate_body: str,
deltas: Sequence[Mapping[str, Any]],
creator: str = CREATOR,
dry_run: bool = False,
) -> dict[str, Any]:
"""抽取侧登记增量**提案**(status=proposed);升格必须另行显式批准。"""
from db import connect # 延迟导入:纯校验路径(测试)不需要库
normalized = validate_delta_batch(
deltas, candidate_body=candidate_body, target_chapter=target_chapter)
inserted: list[int] = []
with connect() as conn:
try:
for item in normalized:
row = conn.execute(
"INSERT INTO example_fact_delta(work_id, target_chapter, run_id, "
"candidate_sha256, delta_id, delta_type, subject_key, payload, evidence_quote, "
"status, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,'proposed',%s) "
"ON CONFLICT (tenant_id, candidate_sha256, delta_id) DO NOTHING RETURNING id",
(work_id, target_chapter, run_id, candidate_sha256_bare, item["deltaId"],
item["deltaType"], item["subjectKey"],
json.dumps(item["payload"], ensure_ascii=False),
item["evidenceQuote"], creator),
).fetchone()
if row is None:
raise FactDeltaError(
"FACT_DELTA_DUPLICATE_ID",
f"该候选已登记过 deltaId={item['deltaId']}(幂等拒绝,不得覆盖)")
inserted.append(row[0])
if dry_run:
conn.rollback()
return {"status": "dry_run_ok", "proposed_ids": inserted,
"note": "试跑已回滚,未落库"}
conn.commit()
except Exception:
conn.rollback()
raise
return {"status": "proposed", "proposed_ids": inserted}
__all__ = [
"DELTA_TYPES", "HOOK_ACTIONS", "FactDeltaError",
"validate_delta_proposal", "validate_delta_batch",
"apply_accepted_deltas", "propose_fact_deltas",
]
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="事实增量提案登记(抽取侧入口)")
ap.add_argument("payload_json", nargs="?", default="-",
help="提案 JSON 文件路径(默认 stdin),含 workId/targetChapter/runId/"
"candidateSha256/candidateBody/deltas")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
raw = sys.stdin.read() if args.payload_json == "-" else pathlib.Path(
args.payload_json).read_text(encoding="utf-8")
spec = json.loads(raw)
try:
out = propose_fact_deltas(
work_id=int(spec["workId"]), target_chapter=int(spec["targetChapter"]),
run_id=spec.get("runId"),
candidate_sha256_bare=str(spec["candidateSha256"]).removeprefix("sha256:"),
candidate_body=str(spec["candidateBody"]), deltas=list(spec["deltas"]),
dry_run=args.dry_run)
print(json.dumps(out, ensure_ascii=False))
except FactDeltaError as exc:
print(f"[拒绝] {exc.code}: {exc}", file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Canonical 投影的登记、失效与恢复(08 数据权威)。
摘要、handoff、embedding、章后抽取、看板缓存都是正文的幂等投影:派生数据坏了可重建,
绝不能反向成为事实源。本模块把每个投影绑定到 source_revision + source_text_hash:
- register_pending_projections:正文提交同事务登记 pending 投影(幂等键防重放重复登记);
- mark_stale_before_revision:正文换新 revision 时,同事务把旧 revision 的投影翻 stale;
- finish_projection / retry_projection:投影 worker 报告完成/失败与显式重试(attempt+1);
- refresh_staleness:恢复巡检——按当前正文 revision/哈希对账,漂移的投影标 stale。
投影失败记 failed 并留原因,绝不显示 completed;stale/failed 不得直接洗白成 completed(DB 触发器兜底)。
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
from typing import Any, Iterable
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
DB_DIR = SCRIPT_DIR.parents[1] / "access-database" / "scripts"
if str(DB_DIR) not in sys.path:
sys.path.insert(0, str(DB_DIR))
from db import connect # noqa: E402
PROJECTION_KINDS = frozenset({"summary", "handoff", "embedding", "extraction", "dashboard"})
CREATOR = "confirm"
class ProjectionError(RuntimeError):
"""投影登记/状态流转非法——失败关闭。"""
def _bare_sha(value: str) -> str:
value = str(value or "")
return value[len("sha256:"):] if value.startswith("sha256:") else value
def idempotency_key(work_id: int, target_chapter: int | None, kind: str, source_revision: int) -> str:
"""同一提交重放不重复登记的幂等键:work:chapter:kind:revN。"""
chapter_part = f"ch{target_chapter}" if target_chapter is not None else "book"
return f"{work_id}:{chapter_part}:{kind}:rev{source_revision}"
def register_pending_projections(
conn,
*,
work_id: int,
target_chapter: int | None,
kinds: Iterable[str],
source_revision: int,
source_text_hash_bare: str,
candidate_sha256_bare: str | None = None,
creator: str = CREATOR,
) -> list[int]:
"""在调用方事务内登记 pending 投影;重放同一幂等键回读已有行,不重复登记。"""
kinds = list(dict.fromkeys(kinds)) # 去重保序
unknown = sorted(set(kinds) - PROJECTION_KINDS)
if unknown:
raise ProjectionError(f"投影类型非法: {unknown}")
if not kinds:
return []
ids: list[int] = []
for kind in kinds:
key = idempotency_key(work_id, target_chapter, kind, source_revision)
row = conn.execute(
"INSERT INTO example_projection_run(work_id, target_chapter, kind, source_revision, "
"source_text_hash, candidate_sha256, status, attempt, idempotency_key, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,'pending',1,%s,%s) "
"ON CONFLICT (tenant_id, idempotency_key) DO NOTHING RETURNING id",
(work_id, target_chapter, kind, source_revision, source_text_hash_bare,
candidate_sha256_bare, key, creator),
).fetchone()
if row is None:
row = conn.execute(
"SELECT id FROM example_projection_run WHERE tenant_id=0 AND idempotency_key=%s",
(key,),
).fetchone()
if row is None:
raise ProjectionError(f"投影幂等回读失败: {key}")
ids.append(row[0])
return ids
def mark_stale_before_revision(
conn, *, work_id: int, target_chapter: int | None, new_revision: int, creator: str = CREATOR
) -> int:
"""同事务失效:新 revision 提交后,旧 revision 的活动投影一律 stale。"""
cur = conn.execute(
"UPDATE example_projection_run SET status='stale', updater=%s "
"WHERE tenant_id=0 AND work_id=%s AND target_chapter IS NOT DISTINCT FROM %s "
"AND source_revision < %s AND status IN ('pending','completed','failed') AND deleted=false",
(creator, work_id, target_chapter, new_revision),
)
return cur.rowcount
def finish_projection(projection_id: int, status: str, *, detail: Any = None,
creator: str = "projection") -> dict[str, Any]:
"""投影 worker 报告 completed/failed;仅 pending 可报告,竞争或状态不对即失败关闭。"""
if status not in ("completed", "failed"):
raise ProjectionError("finish_projection 只接受 completed/failed")
import json
detail_json = json.dumps(detail, ensure_ascii=False) if detail is not None else None
with connect() as conn:
try:
cur = conn.execute(
"UPDATE example_projection_run SET status=%s, detail=%s::jsonb, updater=%s "
"WHERE id=%s AND status='pending' AND deleted=false",
(status, detail_json, creator, projection_id),
)
if cur.rowcount != 1:
raise ProjectionError(f"投影 {projection_id} 不在 pending 状态,拒绝报告 {status}")
conn.commit()
except Exception:
conn.rollback()
raise
return {"status": status, "projection_id": projection_id}
def retry_projection(projection_id: int, *, creator: str = "projection") -> dict[str, Any]:
"""显式重试 failed/stale 投影:回 pending 且 attempt+1;completed 不得重试。"""
with connect() as conn:
try:
row = conn.execute(
"UPDATE example_projection_run SET status='pending', attempt=attempt+1, "
"detail=NULL, updater=%s WHERE id=%s AND status IN ('failed','stale') "
"AND deleted=false RETURNING attempt",
(creator, projection_id),
).fetchone()
if row is None:
raise ProjectionError(f"投影 {projection_id} 不在 failed/stale 状态,拒绝重试")
conn.commit()
except Exception:
conn.rollback()
raise
return {"status": "pending", "projection_id": projection_id, "attempt": row[0]}
def refresh_staleness(*, work_id: int, creator: str = "projection") -> list[int]:
"""恢复巡检:按当前正文块 revision/哈希对账,把漂移的投影标 stale,返回受影响 id。"""
stale_ids: list[int] = []
with connect() as conn:
try:
current = {}
rows = conn.execute(
"SELECT c.order_no, b.revision, b.content_text FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND b.deleted=false",
(work_id,),
).fetchall()
for order_no, revision, text in rows:
if order_no not in current or revision > current[order_no][0]:
current[order_no] = (revision, hashlib.sha256(
(text or "").encode("utf-8")).hexdigest())
projections = conn.execute(
"SELECT id, target_chapter, source_revision, source_text_hash "
"FROM example_projection_run WHERE tenant_id=0 AND work_id=%s "
"AND status IN ('pending','completed','failed') AND deleted=false",
(work_id,),
).fetchall()
for proj_id, chapter, source_revision, source_hash in projections:
latest = current.get(chapter)
drifted = (
latest is None
or source_revision < latest[0]
or source_hash != latest[1]
)
if drifted:
conn.execute(
"UPDATE example_projection_run SET status='stale', updater=%s WHERE id=%s",
(creator, proj_id),
)
stale_ids.append(proj_id)
conn.commit()
except Exception:
conn.rollback()
raise
return stale_ids
__all__ = [
"PROJECTION_KINDS", "ProjectionError", "idempotency_key",
"register_pending_projections", "mark_stale_before_revision",
"finish_projection", "retry_projection", "refresh_staleness",
]

View File

@ -10,7 +10,7 @@ import pathlib
import sys
DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "db" / "scripts"
DB_SCRIPTS = pathlib.Path(__file__).resolve().parents[2] / "access-database" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402

View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""fact_delta reducer 的离线测试。
模型只能提类型化增量,证据引文必须真实出现在候选正文中(不得编造证据);
字段闭集、类型闭集、payload 合同、重复 ID 一律失败关闭。
跑法:.venv/bin/python .claude/skills/decide-candidate/scripts/test_fact_delta.py
"""
from __future__ import annotations
import copy
import pathlib
import sys
import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR, SKILLS_DIR / "assemble-context" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from fact_delta import ( # noqa: E402
DELTA_TYPES, FactDeltaError, validate_delta_batch, validate_delta_proposal,
)
BODY = "林深把黑纹缠上手臂,异种核心在胸腔里低鸣。何岚站在舱门口没有说话。"
def _proposal(delta_type="character_location_changed", *, delta_id="delta-1",
payload=None, quote="异种核心在胸腔里低鸣"):
if payload is None:
payload = {"characterName": "林深", "toLocation": "舱室底层"}
return {"deltaId": delta_id, "deltaType": delta_type,
"payload": payload, "evidenceQuote": quote}
class ValidateDeltaProposalTest(unittest.TestCase):
def test_each_delta_type_has_working_contract(self) -> None:
cases = {
"character_location_changed":
({"characterName": "林深", "toLocation": "舱室底层"}, "异种核心在胸腔里低鸣"),
"character_knowledge_added":
({"characterName": "何岚", "knowledge": "林深体内有异种核心"}, "何岚站在舱门口没有说话"),
"relationship_changed":
({"fromName": "林深", "toName": "何岚", "relation": "互相提防"}, "何岚站在舱门口没有说话"),
"hook_advanced":
({"hookId": "hook-voice", "action": "advanced",
"dueWindow": {"fromChapter": 5, "toChapter": 8}}, "异种核心在胸腔里低鸣"),
"timeline_event_added":
({"event": "隔离舱首次审讯结束"}, "何岚站在舱门口没有说话"),
"setting_added":
({"factType": "污染规则", "text": "黑纹扩散不可逆"}, "林深把黑纹缠上手臂"),
}
self.assertEqual(set(cases), DELTA_TYPES)
for delta_type, (payload, quote) in cases.items():
with self.subTest(delta_type=delta_type):
got = validate_delta_proposal(
_proposal(delta_type, payload=payload, quote=quote),
candidate_body=BODY, target_chapter=2)
self.assertEqual(got["deltaType"], delta_type)
self.assertTrue(got["subjectKey"])
def test_subject_key_derivation(self) -> None:
got = validate_delta_proposal(
_proposal("relationship_changed",
payload={"fromName": "林深", "toName": "何岚", "relation": "同盟"},
quote="何岚站在舱门口没有说话"),
candidate_body=BODY, target_chapter=2)
self.assertEqual(got["subjectKey"], "林深→何岚")
hook = validate_delta_proposal(
_proposal("hook_advanced", payload={"hookId": "hook-voice", "action": "planted"},
quote="异种核心在胸腔里低鸣"),
candidate_body=BODY, target_chapter=2)
self.assertEqual(hook["subjectKey"], "hook-voice")
def test_quote_not_found_fails_closed(self) -> None:
with self.assertRaises(FactDeltaError) as caught:
validate_delta_proposal(_proposal(quote="正文里不存在的句子"),
candidate_body=BODY, target_chapter=2)
self.assertEqual(caught.exception.code, "FACT_DELTA_QUOTE_NOT_FOUND")
def test_closed_field_set_and_type_enum(self) -> None:
extra = _proposal()
extra["extraField"] = 1
with self.assertRaises(FactDeltaError) as caught:
validate_delta_proposal(extra, candidate_body=BODY, target_chapter=2)
self.assertEqual(caught.exception.code, "FACT_DELTA_INVALID")
bad_type = _proposal(delta_type="character_resurrected")
with self.assertRaises(FactDeltaError) as caught:
validate_delta_proposal(bad_type, candidate_body=BODY, target_chapter=2)
self.assertEqual(caught.exception.code, "FACT_DELTA_TYPE_INVALID")
def test_payload_contract_enforced_per_type(self) -> None:
missing_field = _proposal(payload={"characterName": "林深"}) # 缺 toLocation
with self.assertRaises(FactDeltaError):
validate_delta_proposal(missing_field, candidate_body=BODY, target_chapter=2)
bad_hook_action = _proposal(
"hook_advanced", payload={"hookId": "h1", "action": "detonated"},
quote="异种核心在胸腔里低鸣")
with self.assertRaises(FactDeltaError):
validate_delta_proposal(bad_hook_action, candidate_body=BODY, target_chapter=2)
bad_due_window = _proposal(
"hook_advanced",
payload={"hookId": "h1", "action": "planted",
"dueWindow": {"fromChapter": 9, "toChapter": 3}},
quote="异种核心在胸腔里低鸣")
with self.assertRaises(FactDeltaError):
validate_delta_proposal(bad_due_window, candidate_body=BODY, target_chapter=2)
def test_batch_rejects_duplicate_ids(self) -> None:
batch = [_proposal(delta_id="delta-1"),
_proposal(delta_id="delta-1", payload={"characterName": "何岚",
"toLocation": "指挥舱"})]
with self.assertRaises(FactDeltaError) as caught:
validate_delta_batch(batch, candidate_body=BODY, target_chapter=2)
self.assertEqual(caught.exception.code, "FACT_DELTA_DUPLICATE_ID")
ok = validate_delta_batch(
[_proposal(delta_id="delta-1"),
_proposal(delta_id="delta-2", payload={"characterName": "何岚",
"toLocation": "指挥舱"})],
candidate_body=BODY, target_chapter=2)
self.assertEqual([item["deltaId"] for item in ok], ["delta-1", "delta-2"])
def test_quote_is_normalized_before_matching(self) -> None:
# 正文里的字面转义换行(模型 JSON 双重转义)归一为真换行后再匹配,与正文合同一致
escaped_body = BODY + "\\n舱灯闪了一下"
got = validate_delta_proposal(
_proposal(payload={"characterName": "林深", "toLocation": "舱室底层"},
quote="\n舱灯闪了一下"),
candidate_body=escaped_body, target_chapter=2)
self.assertEqual(got["deltaType"], "character_location_changed")
self.assertIn("\n舱灯闪了一下", escaped_body.replace("\\n", "\n"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""事实增量对 muse-example 真实库的集成测试。
覆盖:
- 提案登记幂等:同候选重复 deltaId 拒绝(不得覆盖);
- ChapterCommit 原子性:已批准增量与正文同一事务落账本;任一增量非法则整体回滚,
正文也不写入;
- 账本绑定:source_block_revision + command_id 与正文提交一致;
- 账本 append-only:UPDATE/DELETE 被触发器拒绝。
测试数据 unittest-delta- 前缀隔离;清理时短暂禁用账本防删触发器(try/finally 恢复)。
跑法(需 Tailscale 内网可达 muse-example):
.venv/bin/python .claude/skills/decide-candidate/scripts/test_fact_delta_db.py
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
import uuid
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR, SKILLS_DIR / "access-database" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from db import connect # noqa: E402
from fact_delta import FactDeltaError, propose_fact_deltas # noqa: E402
from write_canonical import ConflictError, accept # noqa: E402
SUFFIX = uuid.uuid4().hex[:10]
WORK_TITLE = f"unittest-delta-{SUFFIX}"
BODY = f"林深把黑纹缠上手臂,异种核心在胸腔里低鸣。何岚站在舱门口没有说话。{SUFFIX}"
_command_ids: list[str] = []
_candidate_ids: list[int] = []
_delta_ids: list[int] = []
_work_id: int | None = None
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _cleanup() -> None:
if _work_id is None:
return
with connect() as conn:
try:
conn.execute(
"ALTER TABLE example_fact_ledger DISABLE TRIGGER trg_example_fact_ledger_append_only")
conn.execute(
"ALTER TABLE example_user_decision DISABLE TRIGGER trg_example_user_decision_append_only")
try:
if _candidate_ids:
conn.execute("DELETE FROM example_user_decision WHERE candidate_id = ANY(%s)",
(_candidate_ids,))
if _command_ids:
conn.execute("DELETE FROM muse_content_command_log WHERE command_id = ANY(%s)",
(_command_ids,))
conn.execute("DELETE FROM example_fact_ledger WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM example_fact_delta WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_block_source_attribution WHERE work_id=%s",
(_work_id,))
conn.execute("DELETE FROM muse_content_block WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_chapter WHERE work_id=%s", (_work_id,))
if _candidate_ids:
conn.execute("DELETE FROM example_candidate WHERE id = ANY(%s)",
(_candidate_ids,))
conn.execute("DELETE FROM muse_content_work WHERE id=%s", (_work_id,))
conn.commit()
finally:
conn.execute(
"ALTER TABLE example_fact_ledger ENABLE TRIGGER trg_example_fact_ledger_append_only")
conn.execute(
"ALTER TABLE example_user_decision ENABLE TRIGGER trg_example_user_decision_append_only")
conn.commit()
except Exception:
conn.rollback()
raise
def _setup() -> int:
global _work_id
with connect() as conn:
work_id = conn.execute(
"INSERT INTO muse_content_work(owner_user_id, title, status, creator) "
"VALUES (1,%s,'writing','unittest') RETURNING id", (WORK_TITLE,)).fetchone()[0]
conn.execute(
"INSERT INTO muse_content_chapter(work_id, title, order_no, creator) "
"VALUES (%s,'测试章',1,'unittest')", (work_id,))
conn.commit()
_work_id = work_id
return work_id
def _insert_candidate(work_id: int, *, version: str) -> int:
with connect() as conn:
candidate_id = conn.execute(
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
"candidate_version, candidate_sha256, candidate_body, quality_policy_version, mode, "
"source_role, state, acceptance_eligible, semantic_status, semantic_report_sha256, creator) "
"VALUES (%s,1,%s,1,'production',%s,%s,%s,'writer-production-v1','continuation','writer',"
"'passed',TRUE,'passed',%s,'unittest') RETURNING id",
(work_id, f"unittest-delta-run-{SUFFIX}-{version}", version, _sha(BODY), BODY,
_sha("sem:" + BODY))).fetchone()[0]
conn.commit()
_candidate_ids.append(candidate_id)
return candidate_id
def _command_id(tag: str) -> str:
cid = f"unittest-delta-{tag}-{SUFFIX}"
_command_ids.append(cid)
return cid
def _good_delta(delta_id="delta-1"):
return {"deltaId": delta_id, "deltaType": "hook_advanced",
"payload": {"hookId": "hook-voice", "action": "advanced",
"dueWindow": {"fromChapter": 3, "toChapter": 6}},
"evidenceQuote": "异种核心在胸腔里低鸣"}
def test_propose_is_idempotent_reject(work_id: int) -> None:
candidate_sha = _sha(BODY)
out = propose_fact_deltas(
work_id=work_id, target_chapter=1, run_id=None,
candidate_sha256_bare=candidate_sha, candidate_body=BODY,
deltas=[_good_delta("delta-prop-1"), _good_delta("delta-prop-2")])
assert out["status"] == "proposed" and len(out["proposed_ids"]) == 2
_delta_ids.extend(out["proposed_ids"])
# 同候选重复 deltaId:失败关闭,不得覆盖
try:
propose_fact_deltas(
work_id=work_id, target_chapter=1, run_id=None,
candidate_sha256_bare=candidate_sha, candidate_body=BODY,
deltas=[_good_delta("delta-prop-1")])
raise AssertionError("重复 deltaId 必须被拒绝")
except AssertionError:
raise
except FactDeltaError as exc:
assert exc.code == "FACT_DELTA_DUPLICATE_ID"
with connect(readonly=True) as conn:
count = int(conn.execute(
"SELECT COUNT(*) FROM example_fact_delta WHERE candidate_sha256=%s AND delta_id='delta-prop-1'",
(candidate_sha,)).fetchone()[0])
assert count == 1
def test_commit_applies_approved_deltas_atomically(work_id: int) -> None:
candidate_id = _insert_candidate(work_id, version="1")
command_id = _command_id("commit-1")
result = accept(candidate_id, rationale="unittest", expected_revision=0,
command_id=command_id,
approved_deltas=[_good_delta("delta-acc-1"), _good_delta("delta-acc-2")])
assert result["status"] == "accepted" and len(result["delta_ids"]) == 2
_delta_ids.extend(result["delta_ids"])
with connect(readonly=True) as conn:
ledger_rows = conn.execute(
"SELECT delta_type, subject_key, source_block_revision, command_id, "
"source_candidate_sha256 FROM example_fact_ledger WHERE work_id=%s ORDER BY id",
(work_id,)).fetchall()
delta_rows = conn.execute(
"SELECT status, source_revision, decided_by FROM example_fact_delta WHERE id = ANY(%s)",
(result["delta_ids"],)).fetchall()
assert len(ledger_rows) == 2
for row in ledger_rows:
assert row[0] == "hook_advanced"
assert row[1] == "hook-voice"
assert row[2] == result["revision"], "账本必须绑同事务的正文 revision"
assert row[3] == command_id, "账本必须与正文提交同一幂等键"
assert row[4] == _sha(BODY)
for row in delta_rows:
assert row == ("accepted", result["revision"], "1")
def test_invalid_delta_rolls_back_entire_commit(work_id: int) -> None:
candidate_id = _insert_candidate(work_id, version="2")
command_id = _command_id("commit-bad")
forged = {"deltaId": "delta-forged", "deltaType": "setting_added",
"payload": {"factType": "污染规则", "text": "编造的设定"},
"evidenceQuote": "这句话根本不在正文里"}
try:
# expected_revision=1:上一测试已把正文块写到 rev1,给对的值才能真正走到增量校验
accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=command_id, approved_deltas=[_good_delta("delta-ok"), forged])
raise AssertionError("非法增量必须中止整个提交")
except AssertionError:
raise
except FactDeltaError as exc:
assert exc.code == "FACT_DELTA_QUOTE_NOT_FOUND", exc
with connect(readonly=True) as conn:
blocks = int(conn.execute(
"SELECT COUNT(*) FROM muse_content_block WHERE work_id=%s AND deleted=false",
(work_id,)).fetchone()[0])
ledger = int(conn.execute(
"SELECT COUNT(*) FROM example_fact_ledger WHERE work_id=%s", (work_id,)).fetchone()[0])
delta_acc = int(conn.execute(
"SELECT COUNT(*) FROM example_fact_delta WHERE work_id=%s AND status='accepted'",
(work_id,)).fetchone()[0])
state = conn.execute(
"SELECT state FROM example_candidate WHERE id=%s", (candidate_id,)).fetchone()[0]
# 整体回滚:正文块、账本、accepted 提案全部不得出现(上一条测试已落的 2 行账本不变)
assert blocks == 1, "非法增量不得写入正文"
assert ledger == 2, "非法增量不得半合并进账本"
assert delta_acc == 2, "合法增量也不得单独生效(同事务)"
assert state == "passed"
def test_propose_then_approve_no_unique_collision(work_id: int) -> None:
"""抽取先登记提案、用户批准后随正文接受:翻态正道,不撞唯一键。"""
candidate_id = _insert_candidate(work_id, version="3")
candidate_sha = _sha(BODY)
out = propose_fact_deltas(
work_id=work_id, target_chapter=1, run_id=None,
candidate_sha256_bare=candidate_sha, candidate_body=BODY,
deltas=[_good_delta("delta-flow-1")])
assert out["status"] == "proposed"
_delta_ids.extend(out["proposed_ids"])
command_id = _command_id("commit-flow")
# 前面的测试把正文块写到 rev1(非法增量整体回滚不改变它),给 rev1 才走到增量合并
result = accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=command_id, approved_deltas=[_good_delta("delta-flow-1")])
assert result["status"] == "accepted" and len(result["delta_ids"]) == 1
assert result["revision"] == 2
with connect(readonly=True) as conn:
row = conn.execute(
"SELECT status, decided_by, source_revision FROM example_fact_delta WHERE id=%s",
(result["delta_ids"][0],)).fetchone()
# 按 delta_ref 精确查(同 sha 的候选在本文件多个测试里复用过,按 sha 过滤会串)
ledger = conn.execute(
"SELECT delta_ref, source_block_revision, command_id FROM example_fact_ledger "
"WHERE delta_ref=%s", (result["delta_ids"][0],)).fetchall()
assert row == ("accepted", "1", result["revision"]), "提案行应翻 accepted 并绑正文 revision"
assert len(ledger) == 1, "该增量在账本中恰有一行"
assert ledger[0][0] == result["delta_ids"][0]
assert ledger[0][1] == result["revision"], "账本绑同事务正文 revision"
assert ledger[0][2] == command_id, "账本绑同一幂等键"
# 已裁决的提案不得再次接受(稳定错误码,不是裸 DB 异常);
# expected_revision 给当前 rev2,确保穿过 revision 校验真正走到增量阶段
candidate_id_2 = _insert_candidate(work_id, version="4")
try:
accept(candidate_id_2, rationale="unittest", expected_revision=2,
command_id=_command_id("commit-flow-2"),
approved_deltas=[_good_delta("delta-flow-1")])
raise AssertionError("已 accepted 的提案不得再次接受")
except AssertionError:
raise
except FactDeltaError as exc:
assert exc.code == "FACT_DELTA_ALREADY_DECIDED"
except Exception as exc:
raise AssertionError(f"必须是稳定错误码,得到 {type(exc).__name__}: {exc}")
def test_ledger_append_only(work_id: int) -> None:
try:
with connect() as conn:
conn.execute("UPDATE example_fact_ledger SET subject_key='hacked' WHERE work_id=%s",
(work_id,))
conn.commit()
raise AssertionError("账本必须 append-only")
except AssertionError:
raise
except Exception:
pass
try:
with connect() as conn:
conn.execute("DELETE FROM example_fact_ledger WHERE work_id=%s", (work_id,))
conn.commit()
raise AssertionError("账本必须 append-only")
except AssertionError:
raise
except Exception:
pass
def main() -> None:
work_id = _setup()
tests = (
("提案登记幂等拒绝", test_propose_is_idempotent_reject),
("ChapterCommit 原子合并增量", test_commit_applies_approved_deltas_atomically),
("非法增量整体回滚", test_invalid_delta_rolls_back_entire_commit),
("提案→批准翻态不撞唯一键", test_propose_then_approve_no_unique_collision),
("账本 append-only", test_ledger_append_only),
)
try:
for name, test in tests:
test(work_id)
print(f"PASS: {name}")
print("PASS:事实增量真实库集成测试全部通过")
finally:
_cleanup()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""投影登记与恢复对 muse-example 真实库的集成测试。
覆盖:
- 提交即登记:accept 同事务登记 pending 投影,绑正文 revision 与文本哈希;重放不重复登记;
- 换版即失效:新 revision 提交后旧投影全部 stale;
- 失败不冒充完成:stale/failed 不得直接置 completed(触发器兜底),failed 显式 retry 才回 pending;
- 恢复巡检:refresh_staleness 按当前正文对账,漂移投影标 stale。
测试数据 unittest-proj- 前缀隔离,结束物理清理(本表可变,直接 DELETE)。
跑法(需 Tailscale 内网可达 muse-example):
.venv/bin/python .claude/skills/decide-candidate/scripts/test_projection_db.py
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
import uuid
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR, SKILLS_DIR / "access-database" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from db import connect # noqa: E402
from projection_registry import ( # noqa: E402
ProjectionError, finish_projection, refresh_staleness, retry_projection,
)
from write_canonical import accept # noqa: E402
SUFFIX = uuid.uuid4().hex[:10]
WORK_TITLE = f"unittest-proj-{SUFFIX}"
_command_ids: list[str] = []
_candidate_ids: list[int] = []
_work_id: int | None = None
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _cleanup() -> None:
if _work_id is None:
return
with connect() as conn:
try:
conn.execute(
"ALTER TABLE example_user_decision DISABLE TRIGGER trg_example_user_decision_append_only")
try:
if _candidate_ids:
conn.execute("DELETE FROM example_user_decision WHERE candidate_id = ANY(%s)",
(_candidate_ids,))
if _command_ids:
conn.execute("DELETE FROM muse_content_command_log WHERE command_id = ANY(%s)",
(_command_ids,))
conn.execute("DELETE FROM example_projection_run WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_block_source_attribution WHERE work_id=%s",
(_work_id,))
conn.execute("DELETE FROM muse_content_block WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_chapter WHERE work_id=%s", (_work_id,))
if _candidate_ids:
conn.execute("DELETE FROM example_candidate WHERE id = ANY(%s)",
(_candidate_ids,))
conn.execute("DELETE FROM muse_content_work WHERE id=%s", (_work_id,))
conn.commit()
finally:
conn.execute(
"ALTER TABLE example_user_decision ENABLE TRIGGER trg_example_user_decision_append_only")
conn.commit()
except Exception:
conn.rollback()
raise
def _setup() -> int:
global _work_id
with connect() as conn:
work_id = conn.execute(
"INSERT INTO muse_content_work(owner_user_id, title, status, creator) "
"VALUES (1,%s,'writing','unittest') RETURNING id", (WORK_TITLE,)).fetchone()[0]
conn.execute(
"INSERT INTO muse_content_chapter(work_id, title, order_no, creator) "
"VALUES (%s,'测试章',1,'unittest')", (work_id,))
conn.commit()
_work_id = work_id
return work_id
def _insert_candidate(work_id: int, *, body: str, version: str) -> int:
with connect() as conn:
candidate_id = conn.execute(
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
"candidate_version, candidate_sha256, candidate_body, quality_policy_version, mode, "
"source_role, state, acceptance_eligible, semantic_status, semantic_report_sha256, creator) "
"VALUES (%s,1,%s,1,'production',%s,%s,%s,'writer-production-v1','continuation','writer',"
"'passed',TRUE,'passed',%s,'unittest') RETURNING id",
(work_id, f"unittest-proj-run-{SUFFIX}-{version}", version, _sha(body), body,
_sha("sem:" + body))).fetchone()[0]
conn.commit()
_candidate_ids.append(candidate_id)
return candidate_id
def _command_id(tag: str) -> str:
cid = f"unittest-proj-{tag}-{SUFFIX}"
_command_ids.append(cid)
return cid
def _projections(work_id: int) -> list[tuple]:
with connect(readonly=True) as conn:
return conn.execute(
"SELECT id, kind, source_revision, source_text_hash, status, attempt "
"FROM example_projection_run WHERE work_id=%s ORDER BY id", (work_id,)).fetchall()
def test_commit_registers_and_replay_is_noop(work_id: int) -> None:
body = f"投影测试正文第一版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="1")
command_id = _command_id("accept-1")
result = accept(candidate_id, rationale="unittest", expected_revision=0,
command_id=command_id, projection_kinds=("extraction", "summary"))
assert result["status"] == "accepted"
rows = _projections(work_id)
assert len(rows) == 2 and result["projection_ids"] == [row[0] for row in rows]
for row in rows:
assert row[2] == result["revision"], "投影必须绑本次正文 revision"
assert row[3] == _sha(body), "投影必须绑本次正文哈希"
assert row[4] == "pending"
replay = accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=command_id, projection_kinds=("extraction", "summary"))
assert replay["status"] == "already_applied"
assert len(_projections(work_id)) == 2, "重放不得重复登记投影"
def test_new_revision_stales_old_projections(work_id: int) -> None:
body = f"投影测试正文第二版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="2")
result = accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=_command_id("accept-2"), projection_kinds=("extraction",))
assert result["status"] == "accepted" and result["revision"] == 2
rows = _projections(work_id)
stale_rows = [row for row in rows if row[4] == "stale"]
pending_rows = [row for row in rows if row[4] == "pending"]
# rev1 的 extraction+summary 全部 stale;rev2 的 extraction 是唯一 pending
assert len(stale_rows) == 2 and all(row[2] == 1 for row in stale_rows)
assert len(pending_rows) == 1 and pending_rows[0][2] == 2
assert pending_rows[0][1] == "extraction"
def test_failed_never_masquerades_completed(work_id: int) -> None:
rows = _projections(work_id)
pending = next(row for row in rows if row[4] == "pending")
# worker 报告失败
finish_projection(pending[0], "failed", detail={"reason": "extractor_unbuilt"})
# 失败不得直接洗白成 completed(应用层条件 UPDATE 不命中)
try:
finish_projection(pending[0], "completed")
raise AssertionError("failed 投影不得报告 completed")
except AssertionError:
raise
except ProjectionError:
pass
# 绕过应用层直接 UPDATE 也被触发器拒绝
try:
with connect() as conn:
conn.execute("UPDATE example_projection_run SET status='completed' WHERE id=%s",
(pending[0],))
conn.commit()
raise AssertionError("触发器必须拒绝 failed→completed")
except AssertionError:
raise
except Exception:
pass
# 显式 retry 才能回 pending,且 attempt+1
retried = retry_projection(pending[0])
assert retried["status"] == "pending" and retried["attempt"] == 2
ok = finish_projection(pending[0], "completed")
assert ok["status"] == "completed"
# completed 不得重试
try:
retry_projection(pending[0])
raise AssertionError("completed 投影不得重试")
except AssertionError:
raise
except ProjectionError:
pass
def test_stale_projections_cannot_report_outcomes(work_id: int) -> None:
stale_row = next(row for row in _projections(work_id) if row[4] == "stale")
try:
with connect() as conn:
conn.execute("UPDATE example_projection_run SET status='completed' WHERE id=%s",
(stale_row[0],))
conn.commit()
raise AssertionError("stale→completed 必须被拒绝")
except AssertionError:
raise
except Exception:
pass
try:
with connect() as conn:
conn.execute("UPDATE example_projection_run SET status='failed' WHERE id=%s",
(stale_row[0],))
conn.commit()
raise AssertionError("stale→failed 必须被拒绝")
except AssertionError:
raise
except Exception:
pass
# stale 走 retry 恢复:attempt+1 回 pending,随后可以正常完成
retried = retry_projection(stale_row[0])
assert retried["status"] == "pending"
finish_projection(stale_row[0], "completed")
def test_refresh_staleness_detects_hash_drift(work_id: int) -> None:
# 手工造一条"漂移"投影:revision 与当前正文一致但哈希是旧的(模拟派生后正文被改)
with connect(readonly=True) as conn:
current = conn.execute(
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND c.order_no=1 AND b.deleted=false", (work_id,)).fetchone()[0]
with connect() as conn:
drifted_id = conn.execute(
"INSERT INTO example_projection_run(work_id, target_chapter, kind, source_revision, "
"source_text_hash, status, idempotency_key, creator) "
"VALUES (%s,1,'dashboard',%s,%s,'completed',%s,'unittest') RETURNING id",
(work_id, current, "f" * 64, f"unittest-drift-{SUFFIX}")).fetchone()[0]
conn.commit()
stale_ids = refresh_staleness(work_id=work_id)
assert drifted_id in stale_ids, "哈希漂移的投影必须被标 stale"
with connect(readonly=True) as conn:
status = conn.execute(
"SELECT status FROM example_projection_run WHERE id=%s", (drifted_id,)).fetchone()[0]
assert status == "stale"
def main() -> None:
work_id = _setup()
tests = (
("提交即登记且重放幂等", test_commit_registers_and_replay_is_noop),
("换版即失效旧投影", test_new_revision_stales_old_projections),
("失败不冒充完成与重试", test_failed_never_masquerades_completed),
("stale 不得报告结果", test_stale_projections_cannot_report_outcomes),
("恢复巡检发现哈希漂移", test_refresh_staleness_detects_hash_drift),
)
try:
for name, test in tests:
test(work_id)
print(f"PASS: {name}")
print("PASS:投影登记与恢复真实库集成测试全部通过")
finally:
_cleanup()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""write_canonical 接受通道对 muse-example 真实库的故障注入测试。
覆盖先审后入主链的机械不变量:
- 接受原子性:事务中断不留半提交(正文/归因/决策要么全有要么全无);
- command_id 重放幂等:同一命令第二次执行不重复写正文、决策;
- revision CAS:旧 expected_revision 拒绝,正文不被旧候选覆盖;
- 先审后入兜底:semantic_status 非 passed 的候选一律不得接受;
- 机械隔离:评测/诊断候选 DB 级拒绝。
测试数据用 unittest-commit- 前缀隔离;example_user_decision 是 append-only 表,
清理时短暂禁用其防删触发器(try/finally 保证恢复)。
跑法(需 Tailscale 内网可达 muse-example):
.venv/bin/python .claude/skills/decide-candidate/scripts/test_write_canonical_db.py
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
import uuid
from typing import Any
from unittest import mock
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
for path in (SCRIPT_DIR, SKILLS_DIR / "access-database" / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from db import connect # noqa: E402
import write_canonical # noqa: E402
from write_canonical import ConflictError, accept, discard # noqa: E402
SUFFIX = uuid.uuid4().hex[:10]
WORK_TITLE = f"unittest-commit-{SUFFIX}"
_command_ids: list[str] = []
_candidate_ids: list[int] = []
_work_id: int | None = None
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _cleanup() -> None:
"""物理清理测试行;append-only 决策表短暂禁用防删触发器,finally 恢复。"""
if _work_id is None:
return
with connect() as conn:
try:
conn.execute(
"ALTER TABLE example_user_decision DISABLE TRIGGER trg_example_user_decision_append_only")
try:
if _candidate_ids:
conn.execute(
"DELETE FROM example_user_decision WHERE candidate_id = ANY(%s)",
(_candidate_ids,))
if _command_ids:
conn.execute(
"DELETE FROM muse_content_command_log WHERE command_id = ANY(%s)",
(_command_ids,))
conn.execute(
"DELETE FROM muse_content_block_source_attribution WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_block WHERE work_id=%s", (_work_id,))
conn.execute("DELETE FROM muse_content_chapter WHERE work_id=%s", (_work_id,))
if _candidate_ids:
conn.execute(
"DELETE FROM example_candidate WHERE id = ANY(%s)", (_candidate_ids,))
conn.execute("DELETE FROM muse_content_work WHERE id=%s", (_work_id,))
conn.commit()
finally:
conn.execute(
"ALTER TABLE example_user_decision ENABLE TRIGGER trg_example_user_decision_append_only")
conn.commit()
except Exception:
conn.rollback()
raise
def _setup_work_and_chapter() -> int:
global _work_id
with connect() as conn:
work_id = conn.execute(
"INSERT INTO muse_content_work(owner_user_id, title, status, creator) "
"VALUES (1,%s,'writing','unittest') RETURNING id", (WORK_TITLE,)).fetchone()[0]
conn.execute(
"INSERT INTO muse_content_chapter(work_id, title, order_no, creator) "
"VALUES (%s,'测试章',1,'unittest')", (work_id,))
conn.commit()
_work_id = work_id
return work_id
def _insert_candidate(work_id: int, *, body: str, version: str, run_type: str = "production",
state: str = "passed", semantic_status: str | None = "passed",
semantic_sha: str | None = None) -> int:
"""直接造一个 Shadow 候选行(测试夹具,不经 persist_writer_execution)。"""
sha = _sha(body)
if semantic_status == "passed" and semantic_sha is None:
semantic_sha = _sha("semantic-report:" + sha)
with connect() as conn:
candidate_id = conn.execute(
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
"candidate_version, candidate_sha256, candidate_body, quality_policy_version, mode, "
"source_role, state, acceptance_eligible, semantic_status, semantic_report_sha256, creator) "
"VALUES (%s,1,%s,1,%s,%s,%s,%s,'writer-production-v1','continuation','writer',%s,"
"TRUE,%s,%s,'unittest') RETURNING id",
(work_id, f"unittest-commit-run-{SUFFIX}-{version}", run_type, version, sha, body,
state, semantic_status, semantic_sha)).fetchone()[0]
conn.commit()
_candidate_ids.append(candidate_id)
return candidate_id
def _command_id(tag: str) -> str:
cid = f"unittest-commit-{tag}-{SUFFIX}"
_command_ids.append(cid)
return cid
def _block_revision(work_id: int) -> int:
with connect(readonly=True) as conn:
row = conn.execute(
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND c.order_no=1 AND b.deleted=false", (work_id,)).fetchone()
return int(row[0])
def _candidate_state(candidate_id: int) -> str:
with connect(readonly=True) as conn:
return conn.execute(
"SELECT state FROM example_candidate WHERE id=%s", (candidate_id,)).fetchone()[0]
def _decision_count(candidate_id: int) -> int:
with connect(readonly=True) as conn:
return int(conn.execute(
"SELECT COUNT(*) FROM example_user_decision WHERE candidate_id=%s",
(candidate_id,)).fetchone()[0])
def test_accept_atomic_and_replay_idempotent(work_id: int) -> None:
body = f"测试正文第一版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="1")
command_id = _command_id("accept-1")
result = accept(candidate_id, rationale="unittest", expected_revision=0, command_id=command_id)
assert result["status"] == "accepted", result
assert result["revision"] == 1
assert _block_revision(work_id) == 1
assert _candidate_state(candidate_id) == "accepted"
assert _decision_count(candidate_id) == 1
# 同一 command_id 重放:不重复写正文、不新增决策
replay = accept(candidate_id, rationale="unittest", expected_revision=1, command_id=command_id)
assert replay["status"] == "already_applied", replay
assert _block_revision(work_id) == 1
assert _decision_count(candidate_id) == 1
with connect(readonly=True) as conn:
block_rows = int(conn.execute(
"SELECT COUNT(*) FROM muse_content_block WHERE work_id=%s AND deleted=false",
(work_id,)).fetchone()[0])
attribution_rows = int(conn.execute(
"SELECT COUNT(*) FROM muse_content_block_source_attribution WHERE work_id=%s AND deleted=false",
(work_id,)).fetchone()[0])
assert block_rows == 1 and attribution_rows == 1, (block_rows, attribution_rows)
def test_stale_expected_revision_rejected(work_id: int) -> None:
body = f"测试正文第二版 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="2")
# 当前正文块已在 revision=1;拿旧期望 0 接受必须拒绝,且正文不变
try:
accept(candidate_id, rationale="unittest", expected_revision=0,
command_id=_command_id("accept-stale"))
raise AssertionError("旧 expected_revision 必须被拒绝")
except AssertionError:
raise
except ConflictError as exc:
assert "REVISION_CONFLICT" in str(exc)
assert _block_revision(work_id) == 1, "拒绝后正文不得变化"
assert _candidate_state(candidate_id) == "passed", "拒绝后候选保持 passed 待人工处理"
# 正确期望 1 → 接受成 revision 2(旧候选不能覆盖新正文的顺序保证)
ok = accept(candidate_id, rationale="unittest", expected_revision=1,
command_id=_command_id("accept-v2"))
assert ok["status"] == "accepted" and ok["revision"] == 2
assert _block_revision(work_id) == 2
def test_semantic_backstop(work_id: int) -> None:
# 机械 passed 但语义缺失/未过:DB 兜底拒绝
no_semantic = _insert_candidate(work_id, body=f"无语义证据 {SUFFIX}", version="3",
semantic_status=None, semantic_sha=None)
failed_semantic = _insert_candidate(work_id, body=f"语义未过 {SUFFIX}", version="4",
semantic_status="failed")
for cid in (no_semantic, failed_semantic):
try:
accept(cid, rationale="unittest", expected_revision=_block_revision(work_id),
command_id=_command_id(f"accept-nosem-{cid}"))
raise AssertionError("未过语义审查的候选不得接受")
except AssertionError:
raise
except ConflictError as exc:
assert "语义审查" in str(exc), str(exc)
assert _candidate_state(no_semantic) == "passed"
assert _candidate_state(failed_semantic) == "passed"
# discard 不受语义兜底限制(丢弃是安全操作)
dropped = discard(failed_semantic, rationale="unittest",
command_id=_command_id("discard-failed-semantic"))
assert dropped["status"] == "discarded"
assert _candidate_state(failed_semantic) == "discarded"
def test_non_production_rejected(work_id: int) -> None:
eval_candidate = _insert_candidate(work_id, body=f"评测候选 {SUFFIX}", version="5",
run_type="eval")
try:
accept(eval_candidate, rationale="unittest", expected_revision=_block_revision(work_id),
command_id=_command_id("accept-eval"))
raise AssertionError("评测候选不得接受")
except AssertionError:
raise
except ConflictError as exc:
assert "eval" in str(exc)
assert _candidate_state(eval_candidate) == "passed"
def test_interrupted_accept_leaves_no_half_commit(work_id: int) -> None:
"""事务后半段注入故障:正文/归因/命令/决策全部回滚,候选保持 passed。"""
body = f"中断测试正文 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="6")
revision_before = _block_revision(work_id)
def _explode(*_args, **_kwargs):
raise RuntimeError("injected-fault: 决策归档前崩溃")
command_id = _command_id("accept-interrupted")
with mock.patch.object(write_canonical, "_refresh_work_metrics", side_effect=_explode):
try:
accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=command_id)
raise AssertionError("注入故障必须中止接受")
except AssertionError:
raise
except RuntimeError as exc:
assert "injected-fault" in str(exc)
# 半提交检查:正文 revision 不变、无归因新增、命令日志未落(整事务回滚)、决策为 0
assert _block_revision(work_id) == revision_before, "中断不得改变正文"
assert _decision_count(candidate_id) == 0, "中断不得留下决策"
assert _candidate_state(candidate_id) == "passed", "中断后候选必须保持 passed"
with connect(readonly=True) as conn:
logged = conn.execute(
"SELECT COUNT(*) FROM muse_content_command_log WHERE command_id=%s",
(command_id,)).fetchone()[0]
assert logged == 0, "命令日志随事务回滚,重放仍可执行"
# 中断后用同一 command_id 重试仍可成功(幂等键未被污染)
retried = accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=command_id)
assert retried["status"] == "accepted"
assert _block_revision(work_id) == revision_before + 1
def test_dry_run_writes_nothing(work_id: int) -> None:
body = f"试跑正文 {SUFFIX}"
candidate_id = _insert_candidate(work_id, body=body, version="7")
revision_before = _block_revision(work_id)
result = accept(candidate_id, rationale="unittest", expected_revision=revision_before,
command_id=_command_id("accept-dry"), dry_run=True)
assert result["status"] == "dry_run_ok"
assert _block_revision(work_id) == revision_before
assert _candidate_state(candidate_id) == "passed"
assert _decision_count(candidate_id) == 0
def main() -> None:
work_id = _setup_work_and_chapter()
tests = (
("accept 原子性与重放幂等", test_accept_atomic_and_replay_idempotent),
("旧 expected_revision 拒绝", test_stale_expected_revision_rejected),
("语义兜底拒绝", test_semantic_backstop),
("非生产候选拒绝", test_non_production_rejected),
("中断不留半提交", test_interrupted_accept_leaves_no_half_commit),
("dry-run 不落库", test_dry_run_writes_nothing),
)
try:
for name, test in tests:
test(work_id)
print(f"PASS: {name}")
print("PASS:write_canonical 真实库故障注入测试全部通过")
finally:
_cleanup()
if __name__ == "__main__":
main()

View File

@ -16,8 +16,8 @@ import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
CONTINUATION_DIR = SKILLS_DIR / "continuation" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "read-context" / "scripts"
CONTINUATION_DIR = SKILLS_DIR / "write-next-chapter" / "scripts"
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))

View File

@ -3,10 +3,12 @@
落库设计 §2.9 的单事务序列(任一失败整体回滚,绝不留"无来源指针的正式正文"):
accept preflight(主会话先调 check_writer_acceptance 纯函数做生产模式全检)
-> 读候选 + 硬校验(run_type=production、state=passed,DB 级兜底)
accept preflight(主会话先调 check_writer_acceptance 纯函数做生产模式全检,实时状态由 acceptance_state 重读)
-> 读候选 + 硬校验(run_type=production、state=passed、semantic_status=passed,DB 级兜底)
-> 写 muse_content_block(content_text,revision+1,CAS 乐观锁) [拿到 block_id]
-> 写 muse_content_block_source_attribution(block_id, revision, lineage_payload=候选身份;来源权威落块,架构-02 §3)
-> 合并已批准事实增量(fact_delta:校验引文与类型闭集,进 example_fact_ledger,绑 block_revision)
-> 登记投影(projection_registry:旧 revision 投影翻 stale,新 revision 登记 pending)
-> 写 muse_content_command_log(command_id 幂等审计)
-> 写 example_user_decision(canonical_block_id, command_id, 理据)
-> UPDATE example_candidate SET state='accepted'
@ -14,14 +16,19 @@
只由 confirm 入口调用;主会话与智能体不得自行拼接这些写。
--dry-run 试跑:完整走一遍事务再回滚,校验通过但不落库。
"""
import hashlib
import json
import sys
from pathlib import Path
# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts),不另硬编码连接串
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts"
# 复用 access-database Skill 锁死的 DSN,不另硬编码连接串
DB_SCRIPTS = Path(__file__).resolve().parents[2] / "access-database" / "scripts"
sys.path.insert(0, str(DB_SCRIPTS))
from db import connect # noqa: E402
from fact_delta import FactDeltaError, apply_accepted_deltas # noqa: E402
from projection_registry import ( # noqa: E402
mark_stale_before_revision, register_pending_projections,
)
CREATOR = "confirm"
@ -51,8 +58,16 @@ def _refresh_work_metrics(conn, work_id, updater):
def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
expected_revision=None, command_id=None, source_type="ai_candidate",
dry_run=False):
"""接受候选为正式正文。单事务;返回 {status, block_id, revision, decision_id, ...}。"""
approved_deltas=None, projection_kinds=None, dry_run=False):
"""接受候选为正式正文。单事务;返回 {status, block_id, revision, decision_id, ...}。
approved_deltas 非空时,这些**已被用户批准**的结构化事实增量随正文在同一事务进正典账本
(ChapterCommit);任一增量非法则整体回滚,绝不产生"正文已提交但事实半合并"。
抽取出的提案默认不在此列——升格必须先经显式批准(先审后入)。
projection_kinds 非空时,同事务把旧 revision 的投影标 stale 并按新 revision 登记 pending
投影(摘要/抽取/embedding 等派生物只能是正文的幂等投影,坏了可重建)。
"""
with connect() as conn:
try:
# 0) command_id 幂等:重放同一命令直接返回,不重复写
@ -63,15 +78,22 @@ def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
# 1) 读候选 + DB 级硬校验(preflight 的兜底,不靠调用方自觉)
row = conn.execute(
"SELECT id, work_id, target_chapter, run_id, attempt, run_type, candidate_version, "
"candidate_sha256, candidate_body, mode, source_role, state FROM example_candidate "
"candidate_sha256, candidate_body, mode, source_role, state, "
"semantic_status, semantic_report_sha256 FROM example_candidate "
"WHERE id=%s AND deleted=false", (candidate_id,)).fetchone()
if not row:
raise ConflictError(f"候选 {candidate_id} 不存在")
cid, work_id, chap, run_id, attempt, run_type, cver, csha, body, mode, source_role, state = row
(cid, work_id, chap, run_id, attempt, run_type, cver, csha, body, mode, source_role,
state, semantic_status, semantic_sha) = row
if run_type != "production":
raise ConflictError(f"run_type={run_type} 为评测/诊断候选,不得接受(05 §8.4)")
if state != "passed":
raise ConflictError(f"候选 state={state},只有 passed 可接受")
# 先审后入兜底(05 §5):机械门通过(state=passed)之外,还必须有过审的语义审查。
# 语义状态由 persist_writer_execution 校验真实报告后写入;编排层跳过 detector 时这里失败关闭。
if semantic_status != "passed" or not semantic_sha:
raise ConflictError(
f"候选 semantic_status={semantic_status},未通过语义审查的候选不得接受(先审后入)")
if not body:
raise ConflictError("候选正文为空,不能接受")
# 2) 定位章 → 现有正文块(一章一块);revision CAS 乐观锁
@ -116,6 +138,25 @@ def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
"VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s)",
(work_id, block_id, new_rev, source_type, str(cid), int(attempt or 1),
json.dumps(lineage, ensure_ascii=False), CREATOR))
# 3.5) 事实增量:已批准增量随正文同一事务进账本(ChapterCommit 原子性的一部分)
delta_ids = []
if approved_deltas:
delta_ids = apply_accepted_deltas(
conn, work_id=work_id, target_chapter=chap, run_id=run_id,
candidate_sha256_bare=csha, candidate_body=body,
deltas=list(approved_deltas), block_revision=new_rev,
command_id=command_id, decided_by=decided_by, rationale=rationale)
# 3.6) 投影:旧 revision 的派生物同事务标 stale,新 revision 登记 pending(可重建)
projection_ids = []
if projection_kinds:
mark_stale_before_revision(
conn, work_id=work_id, target_chapter=chap, new_revision=new_rev,
creator=CREATOR)
projection_ids = register_pending_projections(
conn, work_id=work_id, target_chapter=chap, kinds=list(projection_kinds),
source_revision=new_rev,
source_text_hash_bare=hashlib.sha256(body.encode("utf-8")).hexdigest(),
candidate_sha256_bare=csha, creator=CREATOR)
# 4) 命令幂等审计
if command_id:
conn.execute(
@ -143,10 +184,12 @@ def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None,
conn.rollback()
return {"status": "dry_run_ok", "block_id": block_id, "revision": new_rev,
"decision_id": dec_id, "word_count": word_count, "metrics": metrics,
"delta_ids": delta_ids, "projection_ids": projection_ids,
"note": "试跑已回滚,未落库"}
conn.commit()
return {"status": "accepted", "block_id": block_id, "revision": new_rev,
"decision_id": dec_id, "word_count": word_count, "metrics": metrics}
"decision_id": dec_id, "word_count": word_count, "metrics": metrics,
"delta_ids": delta_ids, "projection_ids": projection_ids}
except Exception:
conn.rollback()
raise
@ -203,6 +246,10 @@ if __name__ == "__main__":
pa.add_argument("--command-id", default=None)
pa.add_argument("--source-type", default="ai_candidate",
choices=["ai_candidate", "user_merge"])
pa.add_argument("--approved-deltas", default=None,
help="已批准事实增量的 JSON 文件路径(数组,DeltaProposal 合同)")
pa.add_argument("--projection-kinds", default=None,
help="随接受登记的投影类型,逗号分隔(如 extraction,summary);缺省不登记")
pa.add_argument("--dry-run", action="store_true")
pd = sub.add_parser("discard", help="丢弃候选")
pd.add_argument("candidate_id", type=int)
@ -214,9 +261,20 @@ if __name__ == "__main__":
args = ap.parse_args()
try:
if args.cmd == "accept":
deltas = None
if args.approved_deltas:
deltas = json.loads(Path(args.approved_deltas).read_text(encoding="utf-8"))
if not isinstance(deltas, list):
raise ConflictError("--approved-deltas 必须是 JSON 数组")
projection_kinds = None
if args.projection_kinds:
projection_kinds = [kind.strip() for kind in args.projection_kinds.split(",")
if kind.strip()]
result = accept(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale,
basis_ref=args.basis_ref, expected_revision=args.expected_revision,
command_id=args.command_id, source_type=args.source_type, dry_run=args.dry_run)
command_id=args.command_id, source_type=args.source_type,
approved_deltas=deltas, projection_kinds=projection_kinds,
dry_run=args.dry_run)
else:
result = discard(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale,
basis_ref=args.basis_ref, command_id=args.command_id, dry_run=args.dry_run)
@ -224,3 +282,6 @@ if __name__ == "__main__":
except ConflictError as e:
print(f"[拒绝] {e}", file=sys.stderr)
sys.exit(1)
except FactDeltaError as e:
print(f"[拒绝] {e.code}: {e}", file=sys.stderr)
sys.exit(1)

View File

@ -1,6 +1,6 @@
---
name: parse-book
description: 全书解析/拆书的功能合同(scenario: full_parse,分析槽位)。存量作品→规划逆向(大纲/细纲)+实体;参考书再加范式拆取(脱敏)。逐章内环复用 extract-knowledge。作品面升格管线已独立为 upgrade skill。
name: deconstruct-book
description: 从完整存量作品逆向拆出章节细纲、阶段大纲、实体线索和脱敏写作范式。需要解析用户旧稿或参考书全书时使用;不负责把作品面知识按窗升格,也不确认产物。
disable-model-invocation: true
---
@ -31,7 +31,7 @@ disable-model-invocation: true
- **顺序性只来自增量判重**(新实体要对着已积累实体判重合并),细纲逆推本身章间独立——先顺序跑保正确,并行化留作后续优化;
- 进度每 10 章报一行(章号/新实体数/累计分型统计)。
**M3 直调形态(创始人 2026-07-13 拍板,现行)**:循环体不再派 opus/haiku 子代理,改为 `scripts/parse_llm.py` 直调 New-API `MiniMax-M3`(经 llm skill)。
**M3 直调形态(创始人 2026-07-13 拍板,现行)**:循环体不再派 opus/haiku 子代理,改为 `scripts/parse_llm.py` 直调 New-API `MiniMax-M3`(经 `call-content-model`)。
**出卡权上移窗级(B4-S3 重构,现行)**:章级逐章出卡有三同根病(同功撞车/单章证不成跨章公式/间隔数字伪精确),治法=章级只产「范式候选线索」(并入脚手架 pass,正文只过一遍),出卡在窗级聚类归并——同一手法多章多次出现归并成一张母卡+实例章号,**间隔章数由实例章号差机械计算**(M3 禁自报数字)。跨窗/跨书同手法靠**嵌入判重**(初筛 ≥0.85 → M3 归并终判 merge/keep,拿不准保留)。大纲窗行(example_parse_outline,幂等键=窗起始章)是出卡窗的唯一切分依据。
@ -39,17 +39,17 @@ disable-model-invocation: true
```bash
# ① 章级 pass(细纲+实体+范式候选线索;断点续跑,重跑自动补失败章)
.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py chapters --work-id 4 --from 1 --to 50
.venv/bin/python .claude/skills/deconstruct-book/scripts/parse_llm.py chapters --work-id 4 --from 1 --to 50
# ② 窗级大纲聚合(每 5–10 万字:多章细纲+正文→阶段大纲;书末残窗无论大小必成窗)
.venv/bin/python .claude/skills/parse-book/scripts/parse_outline.py window --work-id 4
.venv/bin/python .claude/skills/deconstruct-book/scripts/parse_outline.py window --work-id 4
# ③ 窗级聚类出卡(窗=②的窗行;线索+细纲+阶段大纲→母卡;守卫+判重在 parse_ingest cards)
.venv/bin/python .claude/skills/parse-book/scripts/parse_llm.py cards --work-id 4
.venv/bin/python .claude/skills/deconstruct-book/scripts/parse_llm.py cards --work-id 4
# ④ 全书拆完:终检(逐窗细纲对账大纲 + 跨段连贯性纵览)
.venv/bin/python .claude/skills/parse-book/scripts/parse_outline.py check --work-id 4
# ⑤ 公共卡三角色审核(番茄作家/起点作家/主编,M3 常设步骤;见 review-cards skill)
.venv/bin/python .claude/skills/review-cards/scripts/review_cards.py review --batch <批次> --work-id 4
.venv/bin/python .claude/skills/deconstruct-book/scripts/parse_outline.py check --work-id 4
# ⑤ 公共卡三角色审核(番茄作家/起点作家/主编,M3 常设步骤;见 review-knowledge-cards Skill)
.venv/bin/python .claude/skills/review-knowledge-cards/scripts/review_cards.py review --batch <批次> --work-id 4
# 进度
.venv/bin/python .claude/skills/parse-book/scripts/parse_ingest.py progress
.venv/bin/python .claude/skills/deconstruct-book/scripts/parse_ingest.py progress
```
审核纪律:常设审核=M3(已用 opus 金标准校准,偏差 0.45 达标);fable/opus 只做起量前校准与起量后一次总审核(门禁与优化,不进流程循环)。
@ -58,7 +58,7 @@ disable-model-invocation: true
**窗行陷阱(放量首日实测)**:`--window` 参数变化后重切,旧窗行会按 from_order 占位,新的大窗被「已有大纲跳过」→ 中间章域永远漏出卡(验收期 1–3 章小窗占住 from_order=1,放量 1–34 章大窗被跳过)。**换窗参数重切前必须先删该书全部窗行**(窗行是可再生中间产物;卡挂「窗起」,cards 重出时按窗软删重出)。
**作品面升格管线已独立为 upgrade skill**:`upgrade_book` 实体卡按窗抽取(`upgrade.py windows/run/status`)、单卡质量修复、presence 冗余清理、legacy failed 窗恢复、同书 advisory lock 互斥,以及 reset 全量重抽准备与七域 `backup/verify/rehearse/restore` 备份恢复合同,全部见 `.claude/skills/upgrade/SKILL.md`。拆书产出(章级细纲/实体脚手架)是升格的输入;本 skill 不含升格 runbook。
**作品面知识抽取与维护已拆分**:`extract-work-knowledge` 负责 `windows/run/status` 正常抽取,`maintain-work-extraction` 负责单卡修复、presence 清理、legacy failed 恢复、reset 与七域备份恢复。拆书产出的章级细纲和实体脚手架是正常抽取输入;本 Skill 不含其 runbook。
**只读交叉核对工具(跨拆书与升格两管线)**:`scripts/parse_health.py`(全链终态体检:章级/窗大纲/范式卡/升格四层 + 全局健康红线)与 `scripts/parse_export.py`(终态样张导出:范式五书 + 单书升格实体卡)只读库渲染,不写任何状态,升格层仅做核对与样张。顽固敏感章抢救 `scripts/parse_salvage.py` 是拆书章级的常设工具,留本 skill。
@ -66,8 +66,8 @@ disable-model-invocation: true
## 步骤(自底向上,与创作期规划的自顶向下互为镜像)
1. 静态分章:import skill(规则,LLM 不参与);
2. **逐章内环——复用 `extract-knowledge` 的字段 checklist**:逆推本章细纲(章目标/关键事件/出场/伏笔动作/钩子)+抽实体增量;上下文=前 N-1 章已积累的细纲与实体(知识库从空增量生长,`作品+元数据+知识库+当前章`公式在此逐章成立);
1. 静态分章:`import-book` Skill(规则,LLM 不参与);
2. **逐章内环——复用 `extract-chapter-knowledge` 的字段 checklist**:逆推本章细纲(章目标/关键事件/出场/伏笔动作/钩子)+抽实体增量;上下文=前 N-1 章已积累的细纲与实体(知识库从空增量生长,`作品+元数据+知识库+当前章`公式在此逐章成立);
3. 自底向上聚合:章细纲→卷粗纲→主线一句话;伏笔跨章连线(哪章埋哪章收)在聚合时补;
4. 2a 补末章 narrative_state;2b 在脚手架上跑范式拆取(判据归型/字段成卡/判重合并例证);
5. 汇报:分章数/细纲覆盖率/实体分型统计/(2b)范式分型统计+设计发现。
@ -83,4 +83,4 @@ disable-model-invocation: true
## 输出合同
文件版:2a 落 `works/<书>/`(候选);2b 脚手架落参考书目录、范式卡入 `knowledge/范式/`(草稿)。PG 版(B2):结构化清单经主会话 db skill 写入(draft)+embed 嵌入。均不提交/不自确认。
文件版:2a 落 `works/<书>/`(候选);2b 脚手架落参考书目录、范式卡入 `knowledge/范式/`(草稿)。PG 版(B2):结构化清单经主会话 `access-database` 写入(draft)+`embed-knowledge` 嵌入。均不提交/不自确认。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""parse-book skill:终态样张导出(零 LLM,纯库读渲染 markdown)。
"""deconstruct-book Skill:终态样张导出(零 LLM,纯库读渲染 markdown)。
跑序末环「export 样张呈报」的固化实现(批9 收尾落地):
- patterns:五书范式卡样张——每书每型抽实例数最多的代表卡(实例多=跨章生长充分,
@ -18,7 +18,7 @@ import sys
import click
import psycopg
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "llm" / "scripts"))
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
# DSN 的真实来源是同目录的 parse_llm(升格执行器拆分后不再经 upgrade 转导)。
from parse_llm import DSN # noqa: E402

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""parse-book skill:全链终态体检(零 LLM 机械扫描)。
"""deconstruct-book Skill:全链终态体检(零 LLM 机械扫描)。
fable5 抽检优化项落地(创始人 2026-07-16):抽检报告中近半发现可机械化——
固化为体检脚本,每批跑完自动体检,AI 抽检只做机械查不了的判断题。
@ -13,7 +13,7 @@ import sys
import click
import psycopg
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "llm" / "scripts"))
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
# DSN/TENANT 的真实来源是同目录的 parse_llm(升格执行器拆分后不再经 upgrade 转导);_is_garbage 是死 import,删。
from parse_llm import DSN, TENANT # noqa: E402
from parse_ingest import IP_LEAK_WORDS # noqa: E402

View File

@ -20,9 +20,9 @@ import click
import psycopg
from psycopg.types.json import Jsonb
# 受控点依赖:嵌入走 embed skill 同一实现(同模型同维),归并判定走 llm skill 统一入口
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "embed" / "scripts"))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
# 受控点依赖:嵌入走 embed-knowledge,归并判定走 call-content-model
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "embed-knowledge" / "scripts"))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
from embed_drafts import _session, build_embed_text, embed_texts # noqa: E402
from llm import chat_governed, extract_json # noqa: E402 # 归并判定走全局额度治理入口
@ -30,7 +30,7 @@ DSN = ("postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-
"?keepalives=1&keepalives_idle=15&keepalives_interval=5&keepalives_count=3")
TENANT, ACTOR = 1, "1"
PATTERN_TYPES = {"craft", "combat", "emotion", "scene_pattern", "trope"} # 拍板①:首轮只拆五型
NGRAM = 15 # 脱敏红线:≥15 连续字与原文重合=违规(parse-book skill)
NGRAM = 15 # 脱敏红线:≥15 连续字与原文重合=违规(deconstruct-book)
# 版权 IP 与系统专名词表(复抽实证:机战无限为高达系同人,IP 背景词不在实体名池)。
# 词形边界(opus 终检教训):
# - "浮游炮"不入表——已泛化为通用武器品类词(同"光剑"),入表实测误伤 3 卡;
@ -350,7 +350,7 @@ def merge_judge(new_payload, old_payload, model="MiniMax-M3"):
f"【卡A(已入库)】\n{json.dumps(old_payload, ensure_ascii=False)}\n\n"
f"【卡B(新卡)】\n{json.dumps(new_payload, ensure_ascii=False)}")
try:
content, _, used = chat_governed(prompt, model=model)
content, _, used = chat_governed(prompt, model=model, caller="parse-book")
# 全局额度链全部拦截(content 为 None):保守判「keep」=不合并——判重失败绝不能误并,
# 也不该因此卡住或崩书(错误合并比重复卡更伤,宁重复不误并)
if content is None:
@ -484,7 +484,7 @@ def cards(work_id, from_order, file_, model):
ON CONFLICT (tenant_id, command_id) WHERE command_id IS NOT NULL DO NOTHING
RETURNING id""",
(Jsonb(payload), work_id, cid, ACTOR, ACTOR, TENANT)).fetchone()
# 新卡即时嵌入(下一窗/下一书判重立即可见;失败留给 embed skill 批量补)
# 新卡即时嵌入(下一窗/下一书判重立即可见;失败留给 embed-knowledge 批量补)
if row and vec is not None:
h = hashlib.sha256(f"{embed_text}|{EMBED_MODEL}".encode()).hexdigest()
conn.execute(

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""parse-book skill:M3 直调拆书执行器(B4-S3 重构:出卡权上移窗级)。
"""deconstruct-book Skill:M3 直调拆书执行器(B4-S3 重构:出卡权上移窗级)。
创始人拍板(2026-07-13):拆书内容生产 LLM=New-API MiniMax-M3(经 llm skill),
创始人拍板(2026-07-13):拆书内容生产 LLM=New-API MiniMax-M3(经 call-content-model),
不再派 opus/haiku 子代理。B4 fable 审查裁决(2026-07-13):章级逐章出卡有三同根病
(同功 family 撞车/单章证不成跨章公式/间隔数字伪精确),治法=章级只产「范式候选线索」,
出卡在窗级聚类归并(复用大纲窗切分)——同一手法多章多次出现归并为一张母卡+实例章号,
@ -23,9 +23,9 @@ import sys
import click
import psycopg
# 统一走 llm skill 入口(trust_env/重试/<think>剥离/JSON 容错都在那边)
# 统一走 call-content-model 入口(trust_env/重试/<think>剥离/JSON 容错都在那边)
# 敏感/额度降级链已上收 llm.chat_governed(全局统一治理),本模块不再自持降级链
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
from llm import chat_governed, extract_json # noqa: E402
from parse_outline import ensure_outline_coverage # noqa: E402
@ -176,7 +176,8 @@ def m3_json(prompt, model, need_keys, system=IDENTITY):
时由 chat_governed 沿全局链自动换模型、并按窗口预算/调用数治理;全链耗尽(used 为 None)时本
函数抛 SensitiveHardStop 交上层硬停(parse_upgrade/parse_salvage 靠它跳窗)。JSON 形状问题
非敏感,不换模型(原样抛 RuntimeError,上层按章跳过)。返回 (data, usage) 契约不变。"""
content, usage, used = chat_governed(prompt, model=model, system=system)
content, usage, used = chat_governed(prompt, model=model, system=system,
caller="parse-book")
if used is None: # 全局降级链全部耗尽(内容安全/不可用)——保留硬停语义
raise SensitiveHardStop(f"chat_governed 全局降级链全部耗尽(内容安全或模型不可用),model={model}")
# 拿到内容:JSON 形状校验,不合格带提示重试 1 次(仍走 chat_governed 全局治理)
@ -191,7 +192,7 @@ def m3_json(prompt, model, need_keys, system=IDENTITY):
if retry == 0:
content, u2, used2 = chat_governed(
prompt + f"\n\n【重试提示】上次输出无法解析({err}),请严格按输出规则只输出一个 JSON 对象。",
model=model, system=system)
model=model, system=system, caller="parse-book")
if used2 is None:
break # 重试提示也触发全链耗尽:当作该轮失败,落到下方 RuntimeError
# 只合并数值键:上游 usage 带嵌套 dict(如 *_tokens_details),dict+dict 会崩(放量实测)

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""parse-book skill:窗级大纲聚合(创始人 2026-07-13 拍板)。
"""deconstruct-book Skill:窗级大纲聚合(创始人 2026-07-13 拍板)。
大纲不从单章按比例抽(单章对大纲层可能零贡献),而是:
- window:5–10 万字窗(多章细纲+正文)聚合抽取一次窗级大纲 → example_parse_outline;
@ -13,7 +13,7 @@ import sys
import click
import psycopg
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "llm" / "scripts"))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
from llm import chat_governed, extract_json, SensitiveError # noqa: E402
# 敏感/额度降级链已上收 llm.chat_governed(BUDGET_CHAIN 全局统一 + 额度治理);本模块不再自持降级链。
@ -22,7 +22,7 @@ from llm import chat_governed, extract_json, SensitiveError # noqa: E402
def chat_degrade(prompt, model):
"""薄封装 chat_governed(全局统一降级链 + 额度治理),保留 (content, usage) 返回契约。
全链耗尽(used 为 None)时抛 SensitiveError,保留原「交上层跳窗」语义(_do_window/check 靠它跳窗)。"""
content, usage, used = chat_governed(prompt, model=model)
content, usage, used = chat_governed(prompt, model=model, caller="parse-outline")
if used is None:
raise SensitiveError("chat_governed 全局降级链全部耗尽(内容安全或模型不可用)")
return content, usage

View File

@ -15,7 +15,7 @@ import click
import psycopg
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "llm" / "scripts"))
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[2] / "call-content-model" / "scripts"))
from parse_llm import (m3_json, scaffold_prompt, ingest, SensitiveHardStop, # noqa: E402
DSN, TENANT)

View File

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

View File

@ -0,0 +1,97 @@
# 候选生成合同(design-story-foundation/v1)
本合同在派发作品设定候选前读取。它定义共同输入、候选结构和复核口径,不保存任何具体作品设定。
## 一、冻结任务包
主会话先组装任务包,再复制给全部候选子代理。只有 `candidateId` 和 `outputPath` 可以因候选不同而变化。
| 字段 | 内容 |
|---|---|
| `contractVersion` | 固定为 `design-story-foundation/v1` |
| `workTitle` | 当前作品名 |
| `sotPath` | 唯一前期设计文档路径 |
| `rootSetting` | 根设定全文,不用摘要替代 |
| `conversationRecord` | 与作品有关的用户原话,按时间排序 |
| `decisionLedger` | 每项标为已接受、已否决、待定或被新要求覆盖 |
| `unresolvedQuestions` | 会实质改变故事方向、仍需用户拍板的问题 |
| `retentionMilestones` | 前三章、前十章、前五十章及后续阶段落点 |
| `disclosureRules` | 作者总设定与读者阶段认知的分界 |
| `referenceEvidence` | 大纲直证、正文归纳、可借鉴、不可照搬四栏 |
| `sectionContract` | 统一二级标题、顺序、预留编号号段 |
| `quantityContract` | 每章 10—50 项、全文至少 100 项、有效字符下限 |
| `candidateId` | 当前子代理唯一编号 |
| `outputPath` | 当前子代理唯一输出文件 |
对话不能只给总结。原话用于防止整理时改义,`decisionLedger` 用来阻断已经否决的旧方案复活。出现矛盾时,子代理不得自行选择;它只按任务包里已写明的优先级执行,并把真正未决项留给用户。
## 二、统一文档形状
每份候选只允许一个一级标题。正文使用任务包冻结的二级标题,不增设三级标题。每个内容章紧跟一个号段标记:
```markdown
## 一、示例章节
<!-- S001-S050 -->
- **S001|设定名称:** 设定正文。
```
目录名称、顺序、号段必须逐字一致。候选序号可以出现在一级标题,不能进入共同目录。每章实际使用 10—50 个编号;空号允许,越界、倒序和重复不允许。
一项设定可以写多段,但必须围绕同一规则。正文自然交代以下内容,不使用整齐划一的表单腔:
- 规则是什么,在什么条件下生效。
- 谁因此获利,谁承担成本,失控时会发生什么。
- 它会在哪个阶段进入故事,通过什么事件让读者看见。
- 它怎样连接角色关系、资源压力、冲突或下一阶段入口。
达到篇幅门槛靠机制、差异、案例、后果和边界,不靠同义改写、套话、总结段或重复背景。
## 三、节奏与披露
候选先完成追读链,再扩写设定。三个早期里程碑都要同时具备“当期问题、行动目标、实际兑现、付出代价、章末新问题”。
- 前三章让读者看见异常和价值,不解释最终来源,不把终局阵营拉到台前。
- 前十章让优势经过对手或任务验证,同时暴露限制,完成第一个局部闭环。
- 前五十章完成初期主冲突和一次身份或能力抬升,再打开更大舞台。
- 后续阶段继续扩大问题尺度;早期劳动、训练、资源或关系线要换规模延续,不能用完即丢。
总设定写作者掌握的真相;阶段设定写读者当时能确认的事实。一个谜底可以在作者侧确定,但它的征兆、误判、局部解释和正式揭示必须分开放置。
## 四、独立性
候选子代理只能读取任务包、共同参考证据和自己的文件。不得搜索候选目录,不得读取其他候选,不得询问主会话“前一份怎么写”。主会话也不能把某份候选的优点转述给尚未完成的代理。
长文需要多轮时,在同一候选内部维护简短设计账本:已经确定的因果、数值、人物关系、阶段披露点和未完成章节。账本只属于该候选,续跑时与任务包一起提供。
## 五、表达复核
完成内容复核后再处理语言。表达层重点清理:
- 空泛评价代替具体事件,例如只说“极具张力”“层层递进”。
- 每段都用“不是……而是……”或三项排比制造伪力度。
- 所有设定项使用相同句式、相同结尾或固定总结句。
- 频繁使用“同时、此外、值得注意的是、总而言之”等连接词。
- 用旁白宣布人物多强、世界多危险,却没有任务、损失和对比支撑。
- 为了显得完整,提前解释终局真相或最终敌人的全貌。
复核可以改句子长短、用词和段落节奏,不能改事实、编号、目录、量级、代价或披露节点。
## 六、交付报告
所有候选过门后才生成比较报告。报告逐案回答:
1. 前三章靠什么让读者继续。
2. 前十章兑现了什么,暴露了什么限制。
3. 前五十章在哪个事件形成初期高潮。
4. 初期机制如何换规模进入中期和后期。
5. 这套方案最大的收益、代价和失速风险是什么。
报告只供用户选择,不宣告胜者。用户没有拍板前,不合并、不落库、不写正文。
## 七、用户授权后的逐章统合
用户明确要求综合多份候选时,独立生成阶段结束,进入串行统合阶段。统合不再要求来源隔离,但每个代理仍只处理一个当前章节,不能提前读取后续来源章节。详细输入、权威顺序、输出格式与失败边界见 [串行统合合同](serial-merge-contract.md)。
串行链的每个节点都要生成 fresh 会话。当前章只有经主会话审定并写入统合候选后,才可作为下一节点的权威前文。未审定的代理输出、分析摘要和舍弃方案不能进入下一节点上下文。

View File

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

View File

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""构建、调用、校验并接纳前期设计候选的逐章串行统合结果。"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import re
import subprocess
import sys
H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
H3_RE = re.compile(r"^#{3,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)
DECISION_RE = re.compile(r"<decision>\s*(.*?)\s*</decision>", re.DOTALL)
class SerialMergeError(ValueError):
"""串行统合合同不满足。"""
@dataclass(frozen=True)
class Section:
heading: str
text: str
def effective_chars(text: str) -> int:
return len(re.sub(r"\s+", "", text))
def sections(text: str) -> list[Section]:
matches = list(H2_RE.finditer(text))
result: list[Section] = []
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
result.append(Section(match.group(1).strip(), text[match.start():end].rstrip()))
return result
def exact_section(text: str, heading: str) -> Section:
matches = [section for section in sections(text) if section.heading == heading]
if len(matches) != 1:
raise SerialMergeError(f"章节“{heading}”必须且只能出现一次")
return matches[0]
def root_section(text: str) -> Section:
matches = [section for section in sections(text) if "根设定" in section.heading]
if len(matches) != 1:
raise SerialMergeError("根设定章节缺失或重复")
return matches[0]
def content_headings(text: str) -> list[str]:
return [section.heading for section in sections(text) if section.heading != "目录"]
def validate_prefix(integrated: str, source: str, heading: str) -> tuple[list[str], int]:
expected = content_headings(source)
if heading not in expected:
raise SerialMergeError(f"来源候选中没有章节“{heading}”")
index = expected.index(heading)
actual = content_headings(integrated)
if actual != expected[:index]:
raise SerialMergeError(
f"统合前文不是冻结目录前缀:期望 {expected[:index]},实际 {actual}"
)
return expected, index
def build_packet(args: argparse.Namespace) -> None:
root_text = args.root_doc.read_text(encoding="utf-8")
integrated_text = args.integrated_doc.read_text(encoding="utf-8")
source_texts = [path.read_text(encoding="utf-8") for path in args.candidates]
if not source_texts:
raise SerialMergeError("至少需要一份来源候选")
expected, index = validate_prefix(integrated_text, source_texts[0], args.heading)
source_shape = ["目录", *expected]
blocks: list[str] = []
ranges: set[tuple[str, ...]] = set()
for path, text in zip(args.candidates, source_texts, strict=True):
if [section.heading for section in sections(text)] != source_shape:
raise SerialMergeError(f"来源候选目录漂移:{path}")
current = exact_section(text, args.heading)
found_ranges = RANGE_RE.findall(current.text)
if len(found_ranges) != 1:
raise SerialMergeError(f"来源章节号段异常:{path}")
ranges.add(found_ranges[0])
blocks.append(f"### 来源候选:{path.name}\n\n{current.text}")
if len(ranges) != 1:
raise SerialMergeError(f"来源章节号段不一致:{sorted(ranges)}")
range_start, range_end = next(iter(ranges))
prior = integrated_text.rstrip()
packet = (
"# 逐章串行统合输入包\n\n"
f"- 当前序号:{index + 1}/{len(expected)}\n"
f"- 当前标题:{args.heading}\n"
f"- 冻结号段:S{int(range_start):03d}-S{int(range_end):03d}\n\n"
"## 最新根设定(最高权威)\n\n"
f"{root_section(root_text).text}\n\n"
"## 已审定统合前文(次高权威)\n\n"
f"{prior}\n\n"
"## 五份来源候选的当前章节\n\n"
+ "\n\n".join(blocks)
+ "\n"
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(packet, encoding="utf-8")
print(
f"SERIAL_MERGE_PACKET_OK: {args.heading},来源 {len(blocks)} 份,"
f"输入 {effective_chars(packet)} 有效字符"
)
def run_luna(args: argparse.Namespace) -> None:
prompt = (
f"你是当前第 {args.index} 章的独立串行统合代理。完整阅读合同与输入包,"
f"只处理“{args.heading}”。选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定,"
f"章节有效字符不少于 {args.min_chars}。严格遵守根设定与已统合前文,不读取或猜测后续章节。"
"输出必须且只能包含 <decision> 与 <chapter> 两个区块,不加代码围栏。"
)
command = [
"pi",
"--model",
args.model,
"--thinking",
args.thinking,
"--no-tools",
"--no-session",
"--no-context-files",
"--no-skills",
"--no-extensions",
"--mode",
"text",
"-p",
f"@{args.contract}",
f"@{args.packet}",
prompt,
]
completed = subprocess.run(
command,
cwd=args.cwd,
text=True,
capture_output=True,
timeout=args.timeout,
check=False,
)
if completed.returncode != 0:
if completed.stderr:
print(completed.stderr, file=sys.stderr)
raise SerialMergeError(f"Luna Max 调用失败,退出码 {completed.returncode}")
if not completed.stdout.strip():
raise SerialMergeError("Luna Max 返回空结果")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(completed.stdout, encoding="utf-8")
print(
f"SERIAL_MERGE_LUNA_OK: model={args.model} thinking={args.thinking} "
f"output={args.output}"
)
def parse_raw(
raw: str,
heading: str,
range_start: int,
range_end: int,
min_settings: int,
max_settings: int,
min_chars: int,
) -> tuple[str, str, list[int]]:
chapter_matches = CHAPTER_RE.findall(raw)
decision_matches = DECISION_RE.findall(raw)
if len(chapter_matches) != 1 or len(decision_matches) != 1:
raise SerialMergeError("输出必须各含一个 <decision> 与 <chapter> 区块")
chapter = chapter_matches[0].strip()
decision = decision_matches[0].strip()
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("章节草案出现三级或更深标题")
found_ranges = RANGE_RE.findall(chapter)
normalized_ranges = [(int(start), int(end)) for start, end in found_ranges]
expected_range = (range_start, range_end)
if len(normalized_ranges) != 1 or normalized_ranges[0] != expected_range:
raise SerialMergeError(
f"章节号段必须为 S{range_start:03d}-S{range_end:03d}"
)
ids = [int(match.group(1)[1:]) for match in SETTING_RE.finditer(chapter)]
if ids != sorted(ids) or len(ids) != len(set(ids)):
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:
raise SerialMergeError(
f"当前章 {len(ids)} 项,要求 {min_settings}—{max_settings} 项"
)
if effective_chars(chapter) < min_chars:
raise SerialMergeError(
f"当前章 {effective_chars(chapter)} 有效字符,低于 {min_chars}"
)
return decision, chapter, ids
def check_output(args: argparse.Namespace, accept: bool = False) -> None:
raw = args.raw.read_text(encoding="utf-8")
decision, chapter, ids = parse_raw(
raw,
args.heading,
args.range_start,
args.range_end,
args.min_settings,
args.max_settings,
args.min_chars,
)
if accept:
integrated_text = args.integrated_doc.read_text(encoding="utf-8")
source_text = args.source_candidate.read_text(encoding="utf-8")
validate_prefix(integrated_text, source_text, args.heading)
updated = integrated_text.rstrip() + "\n\n" + chapter.rstrip() + "\n"
args.integrated_doc.write_text(updated, encoding="utf-8")
action = "ACCEPTED" if accept else "CHECK_OK"
print(
f"SERIAL_MERGE_{action}: {args.heading},{len(ids)} 项,"
f"{effective_chars(chapter)} 有效字符;决策摘要 {effective_chars(decision)} 字符"
)
def shared_output_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--raw", type=Path, required=True)
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)
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description=__doc__)
subparsers = root.add_subparsers(dest="command", required=True)
packet = subparsers.add_parser("packet")
packet.add_argument("--root-doc", type=Path, required=True)
packet.add_argument("--integrated-doc", type=Path, required=True)
packet.add_argument("--heading", required=True)
packet.add_argument("--output", type=Path, required=True)
packet.add_argument("candidates", type=Path, nargs="+")
packet.set_defaults(handler=build_packet)
run = subparsers.add_parser("run")
run.add_argument("--packet", type=Path, required=True)
run.add_argument("--contract", type=Path, required=True)
run.add_argument("--heading", required=True)
run.add_argument("--index", type=int, required=True)
run.add_argument("--output", type=Path, required=True)
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("--timeout", type=int, default=1200)
run.set_defaults(handler=run_luna)
check = subparsers.add_parser("check")
shared_output_args(check)
check.set_defaults(handler=lambda args: check_output(args, accept=False))
accept = subparsers.add_parser("accept")
shared_output_args(accept)
accept.add_argument("--integrated-doc", type=Path, required=True)
accept.add_argument("--source-candidate", type=Path, required=True)
accept.set_defaults(handler=lambda args: check_output(args, accept=True))
return root
def main() -> int:
args = parser().parse_args()
try:
args.handler(args)
except (OSError, SerialMergeError, subprocess.TimeoutExpired) as error:
print(f"SERIAL_MERGE_FAILED: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,52 @@
#!/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

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""逐章串行统合工具的离线测试。"""
import argparse
from pathlib import Path
import tempfile
import unittest
from serial_merge import SerialMergeError, build_packet, parse_raw, validate_prefix
def candidate(name: str) -> str:
return f"""# {name}
## 目录
- 第一章
- 第二章
## 一、定位
<!-- S001-S050 -->
- **S001|{name}一:** 第一章来源内容足够长,用来验证前文章节不会混入当前来源区。
- **S002|{name}二:** 第一章第二条来源内容,同样只应该通过已审定统合前文出现。
## 二、世界
<!-- S051-S100 -->
- **S051|{name}三:** 第二章当前来源内容,应该进入发给新代理的比较输入包。
- **S052|{name}四:** 第二章另一条来源内容,用于确认五案当前章可以并列比较。
"""
class SerialMergeTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
def tearDown(self) -> None:
self.temp.cleanup()
def write(self, name: str, text: str) -> Path:
path = self.root / name
path.write_text(text, encoding="utf-8")
return path
def test_packet_contains_prior_and_only_current_source_sections(self) -> None:
root_doc = self.write(
"root.md",
"# 设计\n\n## 一、根设定\n\n- **前提:**这是一条足够长的根设定,用来验证输入包带着最高权威进入每个节点。\n\n## 二、其他\n",
)
sources = [self.write(f"c{index}.md", candidate(f"候选{index}")) for index in range(1, 3)]
integrated = self.write(
"integrated.md",
"# 统合\n\n## 目录\n\n- 第一章\n- 第二章\n\n## 一、定位\n\n<!-- S001-S050 -->\n\n- **S001|已定:** 这是主会话审定后的第一章,不是任何来源草案。\n",
)
output = self.root / "packet.md"
build_packet(
argparse.Namespace(
root_doc=root_doc,
integrated_doc=integrated,
heading="二、世界",
output=output,
candidates=sources,
)
)
text = output.read_text(encoding="utf-8")
self.assertIn("这是主会话审定后的第一章", text)
self.assertIn("第二章当前来源内容", text)
self.assertNotIn("第一章来源内容足够长", text)
def test_prefix_drift_is_rejected(self) -> None:
source = candidate("来源")
integrated = "# 统合\n\n## 目录\n\n## 二、世界\n"
with self.assertRaises(SerialMergeError):
validate_prefix(integrated, source, "二、世界")
def test_raw_output_contract(self) -> None:
raw = """<decision>保留能够产生剧情的规则。</decision>
<chapter>
## 二、世界
<!-- S051-S100 -->
- **S051|规则一:** 规则有明确条件、事件用途、失败代价以及后续阶段接口,不靠旁白成立。
- **S052|规则二:** 另一条规则与前文兼容,并通过人物行动和资源损失进入故事。
</chapter>"""
_, _, ids = parse_raw(raw, "二、世界", 51, 100, 2, 3, 20)
self.assertEqual([51, 52], ids)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""前期设计候选校验器的离线测试。"""
from pathlib import Path
import tempfile
import unittest
from validate_candidates import ValidationConfig, validate_candidates, validate_root
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"""# 候选
## 目录
- 第一章
- 第二章
## 一、故事发动机
<!-- S001-S050 -->
{first}
## {second_heading}
<!-- S051-S100 -->
{second}
"""
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,
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def write(self, name: str, content: str) -> Path:
path = self.root / name
path.write_text(content, encoding="utf-8")
return path
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)
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_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)
self.assertIn("SETTING_ORDER", codes)
def test_root_contract_accepts_clean_items(self) -> None:
root_doc = self.write(
"root.md",
"""# 前期设计
## 一、根设定
- **作品前提:**主角刚结束高考,随后进入拥有星际机甲的陌生时代并寻找立足之地。
- **披露要求:**前三章只呈现当前危机和能力征兆,终局答案留到后续阶段逐步揭开。
## 二、定盘
""",
)
problems = validate_root(root_doc, self.config)
self.assertEqual([], problems)
def test_root_rejects_process_language(self) -> None:
root_doc = self.write(
"root.md",
"""# 前期设计
## 一、根设定
- **代理要求:**Claude 子代理分别生成候选文档,再由主会话比较和选择最终方向。
## 二、定盘
""",
)
problems = validate_root(root_doc, self.config)
self.assertIn("ROOT_PROCESS_LEAK", {problem.code for problem in problems})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""校验作品设定初始化的根设定与同构候选,不调用模型、不访问数据库。"""
from __future__ import annotations
import argparse
import json
import re
import sys
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)
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)",
re.IGNORECASE,
)
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
@dataclass(frozen=True)
class Problem:
code: str
file: str
message: str
@dataclass(frozen=True)
class SectionReport:
heading: str
range_start: int
range_end: int
setting_count: int
@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 标记后统计可见字符。"""
cleaned = re.sub(r"[*_`#>\[\]()]", "", text)
return effective_chars(cleaned)
def _problem(code: str, path: Path, message: str) -> Problem:
return Problem(code=code, file=str(path), message=message)
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))
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, "未找到包含“根设定”的二级标题")]
start = headings[root_index].end()
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):
problems.append(_problem("ROOT_STRUCTURE", path, "根设定内部出现三级或更深标题"))
for term in PROCESS_TERMS:
if term in body:
problems.append(_problem("ROOT_PROCESS_LEAK", path, f"根设定包含设计过程词:{term}"))
labels: set[str] = set()
items = 0
for line_number, raw_line in enumerate(body.splitlines(), start=1):
line = raw_line.strip()
if not line or line == "---":
continue
match = ROOT_ITEM_RE.match(line)
if not match:
problems.append(
_problem("ROOT_ITEM_FORMAT", path, f"根设定第 {line_number} 个相对行不是单行加粗标签条目")
)
continue
items += 1
label = match.group(1).strip()
if label in labels:
problems.append(_problem("ROOT_LABEL_DUPLICATE", path, f"根设定标签重复:{label}"))
labels.add(label)
length = visible_chars(line.lstrip("- "))
if not config.root_min_chars <= length <= config.root_max_chars:
problems.append(
_problem(
"ROOT_ITEM_LENGTH",
path,
f"根设定“{label}”为 {length} 个可见字符,要求 {config.root_min_chars}—{config.root_max_chars}",
)
)
if items == 0:
problems.append(_problem("ROOT_EMPTY", path, "根设定没有可校验条目"))
return problems
def _parse_candidate(path: Path, 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] = []
all_ids: list[int] = []
previous_range_end = 0
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))
if not ranges and not setting_matches and heading_match.group(1).strip() == "目录":
continue
if len(ranges) != 1:
problems.append(
_problem("SECTION_RANGE", path, f"章节“{heading_match.group(1).strip()}”必须且只能有一个号段标记")
)
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}"))
if range_start <= previous_range_end:
problems.append(_problem("SECTION_RANGE", path, "章节号段未按顺序递增或发生重叠"))
previous_range_end = max(previous_range_end, range_end)
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(
_problem(
"SETTING_ID_OUT_OF_RANGE",
path,
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]
if visible_chars(item_text) < config.min_item_chars:
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,
)
)
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}")
)
report = CandidateReport(
file=str(path),
effective_chars=char_count,
setting_count=len(all_ids),
headings=headings,
sections=tuple(sections),
)
return report, problems
def validate_candidates(
paths: Iterable[Path],
config: ValidationConfig,
root_doc: Path | None = None,
) -> tuple[list[CandidateReport], list[Problem]]:
candidate_paths = sorted((Path(path) for path in paths), key=lambda item: str(item))
problems: list[Problem] = []
reports: list[CandidateReport] = []
if len(candidate_paths) < config.min_candidates:
problems.append(
Problem(
code="CANDIDATE_COUNT",
file="<group>",
message=f"只有 {len(candidate_paths)} 份候选,要求至少 {config.min_candidates} 份",
)
)
if root_doc is not None:
if not root_doc.is_file():
problems.append(_problem("ROOT_FILE_MISSING", root_doc, "根设定文档不存在"))
else:
problems.extend(validate_root(root_doc, config))
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)
reports.append(report)
problems.extend(file_problems)
if reports:
baseline = reports[0]
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:
problems.append(
Problem(
code="CANDIDATE_STRUCTURE_MISMATCH",
file=report.file,
message=f"目录或号段与基准候选 {baseline.file} 不一致",
)
)
return reports, problems
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="校验作品设定初始化候选")
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("--json", action="store_true", help="输出稳定 JSON")
return parser
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)
status = "ok" if not problems else "invalid"
code = "SETTING_INIT_OK" if not problems else "SETTING_INIT_VALIDATION_FAILED"
payload = {
"status": status,
"code": code,
"reports": [asdict(report) for report in reports],
"problems": [asdict(problem) for problem in problems],
}
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(f"{code}: {len(reports)} 份候选,{len(problems)} 个问题")
for report in reports:
print(f"- {report.file}: {report.setting_count} 项,{report.effective_chars} 有效字符")
for problem in problems:
print(f"[{problem.code}] {problem.file}: {problem.message}")
return 0 if not problems else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,9 +1,9 @@
---
name: embed
description: New-API 嵌入封装——Qwen3-Embedding-8B、dimensions=1024、禁系统代理、批量+失败重试;输入知识行(draft/entity)批量嵌入并写 example_knowledge_embedding,content_hash 幂等不重嵌。B2/B3 的向量生产端。
name: embed-knowledge
description: 使用固定 Qwen3 嵌入模型将知识草稿或实体批量写入 pgvector,并按内容哈希幂等处理 owner 与版本。知识行需要建立或刷新检索向量时使用;不嵌入参考书全文。
---
# embed —— 嵌入封装(New-API 网关)
# 嵌入知识内容
对应 muse API 面:AI 网关(嵌入)。通道事实见 [`db/连接信息.md`](../../../db/连接信息.md):BASE `http://100.64.0.8:3000`、模型 `Qwen/Qwen3-Embedding-8B`、请求体 `"dimensions":1024`(实测生效)、**禁系统代理**(`trust_env=False`)。
@ -11,16 +11,16 @@ description: New-API 嵌入封装——Qwen3-Embedding-8B、dimensions=1024、
```bash
# 批量补嵌 pending 草稿(无活向量,或活向量的当前 payload+model hash 已过期)
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py
.venv/bin/python .claude/skills/embed-knowledge/scripts/embed_drafts.py
# 指定 work(默认兼容参考书拆书批次,按 source_id)或限量
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --work-id 3 --limit 100
.venv/bin/python .claude/skills/embed-knowledge/scripts/embed_drafts.py --work-id 3 --limit 100
# 章后抽卡按作品的 draft.work_id 筛选(source_id 是章节 id)
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --work-id 12 --source-type chapter_extract
.venv/bin/python .claude/skills/embed-knowledge/scripts/embed_drafts.py --work-id 12 --source-type chapter_extract
# 自由文本试嵌(调试/B3 查询端复用同实现)
.venv/bin/python .claude/skills/embed/scripts/embed_drafts.py --probe "机甲近战的节奏控制"
.venv/bin/python .claude/skills/embed-knowledge/scripts/embed_drafts.py --probe "机甲近战的节奏控制"
```
## 合同
@ -35,9 +35,9 @@ description: New-API 嵌入封装——Qwen3-Embedding-8B、dimensions=1024、
## 离线验证
```bash
.venv/bin/python .claude/skills/embed/scripts/test_embed_drafts_offline.py
.venv/bin/python -m py_compile .claude/skills/embed/scripts/embed_drafts.py \
.claude/skills/embed/scripts/test_embed_drafts_offline.py
.venv/bin/python .claude/skills/embed-knowledge/scripts/test_embed_drafts_offline.py
.venv/bin/python -m py_compile .claude/skills/embed-knowledge/scripts/embed_drafts.py \
.claude/skills/embed-knowledge/scripts/test_embed_drafts_offline.py
```
离线测试只使用 fake connection 检查并发顺序、SQL 条件和 owner 反例,不连接真实数据库,不调用 embedding 或 reset。

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""embed skill:知识行批量嵌入(New-API / Qwen3-Embedding-8B / 1024 维)。
"""embed-knowledge Skill:知识行批量嵌入(New-API / Qwen3-Embedding-8B / 1024 维)。
合同见同 skill SKILL.md;通道事实见 db/连接信息.md。失败原样报错不静默。
"""

View File

@ -1,16 +1,16 @@
---
name: replay-eval
description: 回放评测的冻结与结果边界合同。把参考作品冻结到 as_of 章号,生成可审计的输入清单,并在生成/评分前阻断未来信息、未授权来源和全文留存。
name: evaluate-frozen-replay
description: 将参考作品冻结到 as_of 章号,编排细纲或正文的隔离回放,并生成可审计的逐样本结果。需要验证知识或上下文方案时使用;阻断未来信息、未授权来源和不可接受的候选流入生产。
disable-model-invocation: true
---
# 回放评测
本 skill 定义评测编排边界,不替代统一读取器,也不写正式规划或知识。Writer/冻结合同以 [父仓专题-03](../../../../design-docs/专题-03-AI编排上下文与质量评测实现规范.md) 为唯一 owner,Gate 裁决以 [父仓专题-04](../../../../design-docs/专题-04-生成质量门控与创作健康度设计方案.md) 为准,adapter 隔离以 [父仓专题-05](../../../../design-docs/专题-05-AI统一交互协议与外部AgentAdapter设计.md) 为准。Gate A 具体执行参数只认 `configs/writer-gate-a-deep-space-v1.json`。
本 Skill 定义评测编排边界,不替代统一读取器,也不写正式规划或知识。Writer/冻结合同以 [父仓专题-03](../../../../design-docs/专题-03-AI编排上下文与质量评测实现规范.md) 为唯一 owner,Gate 裁决以 [父仓专题-04](../../../../design-docs/专题-04-生成质量门控与创作健康度设计方案.md) 为准,adapter 隔离以 [父仓专题-05](../../../../design-docs/专题-05-AI统一交互协议与外部AgentAdapter设计.md) 为准。Gate A 具体执行参数只认 `configs/writer-gate-a-deep-space-v1.json`。
本 skill 是回放**编排层**,只做冻结、授权、防泄漏审计、回放编排与探针刷新:评分/裁决(rubric/gate,含 `writer_gate.py`/`writer_rubric.py`/`gate_input_builder.py`/`fine_outline_rubric.py`)由 **quality-gate** 提供;受控 Claude CLI 调用与 CAS/raw vault 底座(`claude_runtime.py`/`file_cas.py`/`raw_vault.py`)由 **runtime** 提供;候选检测由 **detect** 提供;冻结快照/授权校验/泄漏审计/只读装载能力(`build_snapshot.py`/`check_snapshot.py`/`audit_leakage.py`/`load_reference_work.py`)由 **snapshot** 提供。依赖箭头只向下,本 skill 不自带评分/裁决、运行时底座或冻结快照能力。
本 Skill 是回放**编排层**,只做冻结、授权、防泄漏审计、回放编排与探针刷新:评分/裁决由 `score-content-quality` 提供;Claude CLI 调用由 `execute-claude-task` 提供;CAS/raw 证据由 `record-run-evidence` 提供;候选检测由 `check-content-consistency` 提供;冻结快照与只读装载由 `freeze-context` 提供。依赖箭头只向下,本 Skill 不自带这些实现。
真实参考作品配置由 snapshot skill 的 `scripts/load_reference_work.py` 在 `REPEATABLE READ READ ONLY` 事务中从实验库组装;它只取作品元数据、窗级大纲、章级细纲摘要和预注册卡 ID 对应的候选卡历史。候选卡必须标记为 `eval_draft`,不能当作生产知识检索结果。
真实参考作品配置由 `freeze-context/scripts/load_reference_work.py` 在 `REPEATABLE READ READ ONLY` 事务中从实验库组装;它只取作品元数据、窗级大纲、章级细纲摘要和预注册卡 ID 对应的候选卡历史。候选卡必须标记为 `eval_draft`,不能当作生产知识检索结果。
来源版本只认原文件证明链:`example_reference_work.source_file` 必须唯一匹配成功 import task 和未软删 knowledge document,三方文件名一致,文档 `file_hash` 为 64 位小写 SHA-256,且 import `command_id` 以该 hash 前 16 位开头。`sourceHash=sha256:<hash>`,`sourceVersion=raw-file-v1:sha256:<hash>`;作品 revision 和导入章数不得改变原文件版本。
@ -42,18 +42,20 @@ disable-model-invocation: true
## 产物边界
- 原始 prompt、response、候选、标准事实摘要和完整输入只能留在仓库外受控 raw vault;必须受显式保留授权、租约和清理回执约束。
- 原始 prompt、response、候选、标准事实摘要和完整输入只能留在仓库外受控 raw vault;必须受显式保留授权、租约和迁移回执约束。正式评测每轮结束迁移到受控归档,删除必须另行明确授权。
- 最终报告只允许评分、摘要、章节定位、失败类别和哈希。
- 禁止把完整 Prompt/Response、供应商原始响应、原书正文、完整目标细纲、token、密钥或未脱敏授权资料写入 Git 仓库或安全摘要。
## 编排入口
- `scripts/run_replay.py --mode dry_run`:只执行授权、来源、冻结和三臂 manifest 预检,不调用模型;这是首个机制 smoke 入口。
- snapshot skill 的 `scripts/load_reference_work.py`:从 PostgreSQL 只读事务组装仓库外临时配置;读取 `example_reference_authorization_snapshot` 当前原文件版本的最新快照,组装 authorization 外层与 snapshot。缺授权记录仍生成可审计配置,但送入 `run_replay` 后必须保持 `blocked_authorization`。
- `freeze-context/scripts/load_reference_work.py`:从 PostgreSQL 只读事务组装仓库外临时配置;读取 `example_reference_authorization_snapshot` 当前原文件版本的最新快照,组装 authorization 外层与 snapshot。缺授权记录仍生成可审计配置,但送入 `run_replay` 后必须保持 `blocked_authorization`。
- `scripts/run_replay.py --mode execute`:在全部前置门通过后,依次执行三臂 planner、整组 schema、逐臂盲 detector、两个独立盲 judge、rubric 校验、稳定性门和去盲汇总;`--output-dir` 必须位于仓库外的临时目录。
- detector 输入输出均为 JSON;输入只有匿名候选 ID、候选和公共冻结到 `as_of` 的规划上下文,不含 arm 名、`cardInjection`、`cardManifest`、任何臂特有卡内容、目标章 proxy 或其他评委结果。卡注入合法性只由确定性预检负责。任一 `high` 严重度发现整组标记 `detector_blocked`,judge 调用数必须为 0;报告不合约时标记 `detector_invalid`。
- detector 输入输出均为 JSON;输入只有匿名候选 ID、候选和公共冻结到 `as_of` 的规划上下文,不含 arm 名、`cardInjection`、`cardManifest`、任何臂特有卡内容、目标章 proxy 或其他评委结果。卡注入合法性只由确定性预检负责。报告不合约属于系统失败,须在 judge 前失败关闭;合同合法的 `failed` / `needs_evidence` 属于候选质量信号,三臂均须保留候选与报告并继续盲评和 Gate,Gate A 只由 C 臂高严重度残留与硬约束覆盖率裁决质量失败。
- 合法的非 `passed` 报告在安全 manifest 与 CAS 中只保存按臂分组的 `semantic-diagnostic-v1`:固定结果枚举、固定原因/区段枚举、调用次数和有界阻断计数。完整报告只留受控 raw 和编排器内存中的 Gate builder 输入;安全输出不得保存错误消息、正文、引文、事实文本、补证查询/原因、任何检测项 ID/字段路径、纠错草稿或模型原始字段。检测器不合约时同样只保存固定闭集诊断并失败关闭。
- 任一样本发生系统失败、检测器不合约、盲评无效/不稳定、预算或 raw/CAS 失败后,整轮已无法构建完整 Gate 输入,必须立即停止后续样本并进入统一迁移;不得继续调用模型消耗预算,也不得因失败删除本轮诊断 raw。
- 两个 judge 使用不同 `judgeId` 和独立无会话进程。第二个 judge 的匿名候选顺序必须与第一个完全相反;任何 rubric 不合约标记 `judge_invalid`,任一同维差值大于 `0.5` 标记 `judge_unstable`,两者都不得标记 `completed`。
- 只有三臂 schema、detector、双 judge rubric 和稳定性门全部通过,才去盲生成逐维 `B-A` / `C-A` 差值矩阵并标记 `completed`。`--detector-bin`、`--judge-primary-bin`、`--judge-secondary-bin` 可分别指定本地 runner;未指定时复用 `--planner-bin`,测试只能使用 fake binary。
- 只有三臂 schema 合法、detector 均产出合同合法报告、双 judge rubric 与稳定性门通过,才去盲生成逐维 `B-A` / `C-A` 差值矩阵并标记 `completed`;detector 报告合同合法不等于其质量终态必须为 `passed`。`--detector-bin`、`--judge-primary-bin`、`--judge-secondary-bin` 可分别指定本地 runner;未指定时复用 `--planner-bin`,测试只能使用 fake binary。
- planner、detector、judge 子进程统一受 `--timeout-seconds` 限制,默认 300 秒;任一超时分别落盘 `planner_timeout`、`detector_timeout`、`judge_timeout`,不得继续进入后续阶段或标记 `completed`。
- `scripts/write_report.py`:从 `run_result.json` 生成独立严格 schema 的安全摘要,只接受受限标识符、枚举、数字、短安全摘要和 SHA-256;不会读取候选正文,也不会把候选路径以外的原始响应写入报告。
@ -69,11 +71,11 @@ disable-model-invocation: true
三臂都必须构造并校验完整 `WriterContext v1`,且固定 `mode=diagnostic_only`、`purpose=evaluation`、`acceptanceEligible=false`。adapter 随后投影 `WriterCreativeInput v2`;writer 模型只看到创作投影,不看到运行身份、manifest、hash、实验臂或验收状态:
- A:`evidenceStrategy=historical_prose_only`,保留连续四章脱敏基线,不放 `indexHints`。
- B:`evidenceStrategy=card_index_only`,只放 `retrievalResult.indexHints`,`proseEvidence` 为空;这是合同内可审计的诊断基线例外,不是生产旁路。
- C:`evidenceStrategy=card_index_plus_prose`,保留连续四章脱敏基线,并放冻结 `indexHints` 与配置中的补充原文证据。
- A:`evidenceStrategy=historical_prose_only`,保留连续四章脱敏基线,不放 `indexHints`,`patternReferences` 恒空。
- B:`evidenceStrategy=card_index_only`,只放 `retrievalResult.indexHints` 与冻结公共 `patternReferences`,`proseEvidence` 为空;这是合同内可审计的诊断基线例外,不是生产旁路。
- C:`evidenceStrategy=card_index_plus_prose`,保留连续四章脱敏基线,并放冻结 `indexHints`、与 B 相同的公共 `patternReferences` 及配置中的补充原文证据。
A/C 单变量回执在 `WriterCreativeInput v2` 上比较。正式 Gate A 的 `allowedDifferencePaths` 必须非空,A/C `contextSha256` 必须不同,且所有路径只能位于 `factConstraints` 或 `proseExcerpts`;`indexHints` 等模型不可见字段的差异不能使样本合格。
A/C 单变量回执在 `WriterCreativeInput v2` 上比较。正式 Gate A 的 `allowedDifferencePaths` 必须非空,A/C `contextSha256` 必须不同,且所有路径只能位于 `factConstraints`、`proseExcerpts` 或预注册的 `patternReferences`;`indexHints` 等模型不可见字段的差异不能使样本合格。
`indexHints` 每项只能包含 `cardId/name/type/content/sourceId/sourceVersion/asOf`,只能用于 `diagnostic_only` 的 `evaluation/diagnostic`,不得进入 writer 或 semantic detector 的模型输入。可信组装器只能把经来源回读确认的内容转成 `factEvidence` / `factConstraints`;eval_draft 的 `content` 不能直接成为 `supported/pass` 依据。所有 `asOf` 和来源章号不得超过样本冻结点。
@ -83,25 +85,39 @@ A/C 单变量回执在 `WriterCreativeInput v2` 上比较。正式 Gate A 的 `a
`newCharacterRatio` 定义为:具名 `requiredCharacters` 中,在 `asOfChapter` 前无记录的角色比例。真实 loader 必须在同一 `REPEATABLE READ READ ONLY` 事务内直接扫描冻结历史 Canonical 正文,按名字返回首次命中章和命中章集合,再重算 `knownBeforeAsOf/absentBeforeAsOf/newCharacterRatio`;卡内容不得参与这个判定。泛称角色不参与猜测;例如第 544 章的“内应”无法确定具体身份,必须记录 `newCharacterRatio=null` 和 `newCharacterRatioStatus=unresolved_generic_role`,不得默认成 0。
盲评使用独立的 `writer-blind-input-v3` 内容边界。可信 adapter 校验匿名候选 hash、共同细纲和 oracleTruthPack 后,只向 judge 模型投影匿名候选 ID+正文、细纲硬约束/可调节 beat/声明新事实、oracle 断言和 rubric。模型输入不得包含 `indexHints`、卡 manifest、`evidenceStrategy`、真实 A/B/C、各臂 WriterContext、raw 路径、reviewer 身份、任何 hash 或其他评委结果。模型只返回 `blind-judge-draft-v3` 的评分、理由、候选引文、受控证据引用、维度排序和 verdict;candidate/input/oracle/report/model-receipt hash 与字符 offset 由 adapter 绑定。映射只保留在编排器内存中,评分完成后才去盲。
盲评使用独立的 `writer-blind-input-v4` 内容边界。输入必须绑定预注册 `scenario` 和 `writer-replay-rubric-v2`;三位评委的场景或策略版本不一致时在模型调用前失败关闭。可信 adapter 校验匿名候选 hash、共同细纲和 oracleTruthPack 后,只向 judge 模型投影匿名候选 ID+正文、细纲硬约束/可调节 beat/声明新事实、oracle 断言,以及四个通用指标与当前场景评分策略的定义、判据和分数锚点。模型输入不得包含 `indexHints`、卡 manifest、`evidenceStrategy`、真实 A/B/C、各臂 WriterContext、raw 路径、reviewer 身份、任何 hash 或其他评委结果。adapter 可附带从当前候选确定性切出的逐字引文提示,以及当前候选/维度/断言/约束的精确顺序和期望数量;这些提示只降低格式误差,不替代本地完整集、引文和证据绑定校验。模型只返回 `blind-judge-draft-v3` 的评分、理由、候选引文、受控证据引用、维度排序和 verdict;candidate/input/oracle/report/model-receipt hash 与字符 offset 由 adapter 绑定。映射只保留在编排器内存中,评分完成后才去盲。盲评失败的安全摘要只保存错误码、评委/尝试/调用次数、数组计数和缺失/重复/越界数量,不保存正文、引文、理由或原始字段值。通用维度可跨章节聚合,兼容 ID `narrative_tension` 只按同一场景类型聚合,禁止跨场景平均后抵消单一类型退化。
正文 dry-run 命令:
```bash
PYTHONPATH=.claude/skills/replay-eval/scripts .venv/bin/python -m run_writer_replay \
--config .claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json \
PYTHONPATH=.claude/skills/evaluate-frozen-replay/scripts .venv/bin/python -m run_writer_replay \
--config .claude/skills/evaluate-frozen-replay/configs/writer-gate-a-deep-space-v1.json \
--dry-run
```
dry-run 只输出计划、manifest 和上下文摘要,不调用 writer、semantic detector 或 judge,不生成候选正文,也不得声称真实回放完成。`--execute` 必须在创建 runner、raw vault 或调用模型前校验预算、授权、冻结快照、三角色 profile、schema、prompt、内容模式和调用计划;任一不一致都失败关闭。
dry-run 只输出计划、manifest 和上下文摘要,不调用 writer、semantic detector 或 judge,不生成候选正文,也不得声称真实回放完成。`--execute` 必须在创建 runner、raw vault 或调用模型前校验预算、授权、冻结快照、三角色 profile、schema、prompt、内容模式、调用计划和显式 runtime 认证;任一不一致都失败关闭。默认真实 runtime 只接受最小环境白名单中的 API key、OAuth token 或绑定安全 base URL 的 auth token,不继承全局 Claude 配置目录;认证缺失时返回 `blocked_runtime_authentication / RUNTIME_AUTHENTICATION_REQUIRED`,不得消耗预算槽位或建立 raw vault。
预算合同分为不可变的 `plannedCalls` 和独立安全上限 `maxCalls`。Gate A 当前三角色各预注册 15 次、各 `maxCalls=150`、单次 cap `$5`、总预算 `$300`;启动预留只按 `plannedCalls * maxBudgetUsdPerCall`,即 `$225`,不得按 150 次计算。每次调用前账本同时检查角色计划槽位、`maxCalls`、累计实际成本和未执行计划的最坏预留;调用后只能使用可信 `ExecutionReceipt.totalCostUsd` 结算。缺回执成本、重复/错序结算、单次 cap 或总预算越界均 fail closed,未触发的第三评计划必须保留在 `remainingPlannedCalls`。
正式 execute 还必须显式传 `--raw-archive-dir <仓外绝对目录>`;缺失、相对路径、位于本轮输出目录内或与临时 vault 跨文件系统时,均在建立 vault 和调用模型前失败关闭。运行结束以 `rawDisposition.status=migrated` 和 `raw-vault-migration-receipt-v1` 证明产物已迁移,不再以删除后的 `closed` 作为完成条件。
离线完整链冒烟由 `scripts/test_run_writer_replay.py` 的单样本三臂 production fake 用例承担:它真实经过 writer 投影、机械门、semantic detector、双评委、raw vault、CAS 和 GateInputBuilder,但所有模型均为本地确定性 fake,不产生费用、不得作为 Gate 样本结果。真实一次调用能力只由 `refresh_runtime_probe.py` 的合成 writer 探针验证;它不代表 detector/judge 或五样本 Gate 已通过。
预算合同分为不可变的 `plannedCalls` 和独立安全上限 `maxCalls`。Gate A 当前预注册 writer 60 次(15 次基础写作 + 最多 45 次篇幅修订)、semantic detector 24 次(15 次基础检测 + 9 次纠错/API 重试余量)、blind judge 45 次(最多三位评委,每位基础调用后最多两个格式纠错/API 重试槽位);各 `maxCalls=150`、单次 cap `$5`、总预算 `$2250`。启动预留只按 `plannedCalls * maxBudgetUsdPerCall`,即 `$645`;`$2250` 是三角色各 150 次安全容量对应的有限执行上限,不是预计消费。本预算授权不等于正式 `--execute` 授权。每次调用前账本同时检查角色计划槽位、`maxCalls`、累计实际成本和未执行计划的最坏预留;调用后只能使用可信 `ExecutionReceipt.totalCostUsd` 结算。缺回执成本、重复/错序结算、单次 cap 或总预算越界均 fail closed,未触发的修订、纠错与第三评计划必须保留在 `remainingPlannedCalls`。
## 稳定运行纪律
正式 `--execute` 会发起长时间、多角色的模型调用,运行方式本身是合同的一部分:
- 正式 execute 必须由**持续等待的前台受控会话**运行,全程阻塞到进程自己退出并读到退出码;**禁止用 `run_in_background` 之类后台启动**。后台启动会被外部任务管理器在数分钟后按 `status=killed` 收掉,Python 来不及进入 `finally`,留下 open raw lease、无 manifest、无费用回执。
- **禁止用不带 `pipefail` 的 `python ... | tail` 捕获退出码**:管道退出码默认取最后一个命令(`tail`)的,Python 的非零码会被静默吞掉,失败关闭看起来像成功。要么开 `set -o pipefail`,要么直接读 Python 进程退出码。
- 进程内对**外部终止只承诺 SIGTERM / SIGINT 可收口**:`execute-claude-task` 会把两者转成受控异常,编排层再通过 `record-run-evidence` 迁移 raw、写安全 manifest、把在途且无回执的调用记为 `costUnknown=true` / `failureReason=EXECUTION_COST_UNKNOWN` 并禁止后续调用,最后以非零退出。**SIGKILL 无法捕获**,只能事后靠 raw vault 的迁移恢复与 CAS 扫描收敛残留。
- 受控终止进入最终迁移后,SIGTERM/SIGINT handler 必须保持 defer/ignore,覆盖 raw migration、CAS 收口和 manifest 原子写入;manifest 发布完成后才恢复调用方原 handler。
- 单个 raw lease 覆盖整轮串行回放。启动时只按三角色中最长的单次 `timeoutSeconds + cleanupMargin` 校验剩余租期;每次模型调用前再按当前角色的同一公式复检,复检必须发生在消耗预算槽位和外发调用之前。`plannedCalls * timeoutSeconds` 是预算/容量安全上界,不是运行时预测,不能因其理论总和超过 24 小时而直接阻断;租期不足时必须在下一笔调用前安全停止、迁移已有 raw 并保留未执行计划。
## 正文 Gate 输入
Gate 裁决器 `writer_gate.py`(评分/裁决模块归属 quality-gate skill,本 skill 只做回放编排并向其提交逐样本脱敏结果)只信任 `gate-input.json.samples[]` 的逐样本脱敏结果。有效样本数、作品数、场景覆盖、C 臂硬门统计、C-A 五维增量、退化比例和六类混淆项全部由判定器内部计算;顶层传入的同名聚合值和聚合 `confounds` 不参与裁决。六类混淆项是:假阴、假阳、泄露、评委不稳定、新角色无卡、场景选择偏差。前五类逐样本汇总;场景选择偏差是集合级混淆项,当评测集 `workId` 去重少于两个(单作品)或未覆盖全部预注册场景类型(只选部分卡友好场景)时产出 finding,标记样本对「卡是否有效」不具代表性、不得据此得出普适结论;它只作报告标注,不改写任何 Gate 终态。
Gate 裁决器 `writer_gate.py`(归属 `score-content-quality`,本 Skill 只提交逐样本脱敏结果)只信任 `gate-input.json.samples[]` 的逐样本脱敏结果。有效样本数、作品数、场景覆盖、C 臂硬门统计、C-A 五维增量、退化比例和六类混淆项全部由判定器内部计算;顶层传入的同名聚合值和聚合 `confounds` 不参与裁决。六类混淆项是:假阴、假阳、泄露、评委不稳定、新角色无卡、场景选择偏差。前五类逐样本汇总;场景选择偏差是集合级混淆项,当评测集 `workId` 去重少于两个(单作品)或未覆盖全部预注册场景类型(只选部分卡友好场景)时产出 finding,标记样本对「卡是否有效」不具代表性、不得据此得出普适结论;它只作报告标注,不改写任何 Gate 终态。
预算、授权或执行合同不合法时不能生成 Gate 输入。Gate A/B 的终态只能由 quality-gate 的 `writer_gate.py` 按父仓专题-04 的唯一顺序产生;本 skill 只做回放编排,不产生也不改写终态,人工不得改写。
预算、授权或执行合同不合法时不能生成 Gate 输入。Gate A/B 的终态只能由 `score-content-quality` 的 `writer_gate.py` 按父仓专题-04 的唯一顺序产生;本 Skill 只做回放编排,不产生也不改写终态,人工不得改写。
## 运行探针刷新工具
@ -116,8 +132,8 @@ Gate 裁决器 `writer_gate.py`(评分/裁决模块归属 quality-gate skill
离线用法(冒烟,不调模型):
```bash
.venv/bin/python .claude/skills/replay-eval/scripts/refresh_runtime_probe.py \
--config .claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json \
.venv/bin/python .claude/skills/evaluate-frozen-replay/scripts/refresh_runtime_probe.py \
--config .claude/skills/evaluate-frozen-replay/configs/writer-gate-a-deep-space-v1.json \
--output /tmp/writer-gate-a-deep-space-v1.probe-refreshed.json \
--dry-run
```
@ -125,8 +141,8 @@ Gate 裁决器 `writer_gate.py`(评分/裁决模块归属 quality-gate skill
真实刷新(经授权后由主代理执行,会发起一次真实 Claude 调用,受 writer profile 单次 cap $5 与冻结 deadline 约束):
```bash
.venv/bin/python .claude/skills/replay-eval/scripts/refresh_runtime_probe.py \
--config .claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json \
.venv/bin/python .claude/skills/evaluate-frozen-replay/scripts/refresh_runtime_probe.py \
--config .claude/skills/evaluate-frozen-replay/configs/writer-gate-a-deep-space-v1.json \
--output /tmp/writer-gate-a-deep-space-v1.probe-refreshed.json
```

View File

@ -49,7 +49,7 @@
"oracleInputProvenance": "oracle_reference_scaffold",
"maxContextChars": 140000,
"modelVersion": "claude-opus-4-8[1m]",
"adapterVersion": "writer-runtime-v1|claude-cli-2.1.211|binary-sha256-5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629",
"adapterVersion": "writer-runtime-v1|claude-cli-2.1.231|binary-sha256-ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c",
"sampling": {
"temperature": "unsupported",
"topP": "unsupported",
@ -62,8 +62,8 @@
"writer": {
"profileVersion": "writer-gate-a-claude-opus-v2",
"claudeExecutablePath": "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude",
"claudeExecutableSha256": "5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629",
"claudeCliVersion": "2.1.211",
"claudeExecutableSha256": "ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c",
"claudeCliVersion": "2.1.231",
"modelAlias": "opus",
"resolvedModelId": "claude-opus-4-8[1m]",
"effort": "high",
@ -73,8 +73,8 @@
"jsonSchemaId": "writer-draft-v2",
"jsonSchemaSha256": "sha256:a1fc5efbcd7aee11082b547eb4e156fe27abe0d669991d5825e3e69901278709",
"systemPromptId": "writer-gate-a-system-v2",
"systemPrompt": "你是这部书的执笔写手。根据本章细纲、叙事状态、事实约束和文风样本,把这一章写成鲜活连贯的正文。\n\n## 写作纪律\n1. 细纲的硬事件、结果方向、伏笔动作、章末钩子、必须出场实体是不可删除或反转的硬骨架;只有可调整节拍允许重排。\n2. 硬骨架是「要发生什么」,正文写「怎么发生」:每个节拍都展开成有动作、对话、感官细节的场景,绝不把细纲的描述句原样抄进正文。「XX三人被困出口,决定撤至通道,等XX送机甲」这类概述句,必须戏剧化成人物在做什么、说什么、感受到什么。\n3. 具体压倒抽象:名词给实物,动词给动作;情绪用行为与细节展示,不许直接宣告。\n4. 每场戏三件套:这场要什么、被什么挡住、落点在哪。\n5. 锚点(必须角色、章末钩子、硬事件)自然长在场景与对话里;章末钩子在结尾自然收出悬念,不是末尾补一句概述。\n6. 文风样本只取人物声音、动作习惯、叙事质感;事实约束只约束真伪。角色绝不说出不该知道的事。\n7. 篇幅落在给定区间内——用有戏剧张力的场景把正文写够,每个场景都有三件套;既不堆形容词注水,也不把该有的场景缩掉。不写未声明的新地名、能力、组织、身份、战绩或关系。",
"systemPromptSha256": "sha256:c66689ba23072da8be555acfe3d9260765dd6128299d058db4ae6370a9e68e2a",
"systemPrompt": "你是这部书的执笔写手。根据本章细纲、叙事状态、事实约束和文风样本,把这一章写成鲜活连贯的正文。\n\n## 写作纪律\n1. 细纲的硬事件、结果方向、伏笔动作、章末钩子、必须出场实体是不可删除或反转的硬骨架;只有可调整节拍允许重排。\n2. 硬骨架是「要发生什么」,正文写「怎么发生」:每个节拍都展开成有动作、对话、感官细节的场景,绝不把细纲的描述句原样抄进正文。「XX三人被困出口,决定撤至通道,等XX送机甲」这类概述句,必须戏剧化成人物在做什么、说什么、感受到什么。\n3. 具体压倒抽象:名词给实物,动词给动作;情绪用行为与细节展示,不许直接宣告。\n4. 每场戏三件套:这场要什么、被什么挡住、落点在哪。\n5. 锚点(必须角色、章末钩子、硬事件)自然长在场景与对话里;章末钩子在结尾自然收出悬念,不是末尾补一句概述。\n6. 文风样本只取人物声音、动作习惯、叙事质感;事实约束只约束真伪。角色绝不说出不该知道的事。\n7. 篇幅落在给定区间内——用有戏剧张力的场景把正文写够,每个场景都有三件套;既不堆形容词注水,也不把该有的场景缩掉。不写未声明的新地名、能力、组织、身份、战绩或关系。\n8. 连贯一致:不得与历史原文(近几章 proseEvidence)已确立的事实、人物状态、时空关系相冲突;场景转换、人物位移、机甲或能力的切换要交代因果过渡,不能留断裂(如人物上一刻在 A 机甲、下一刻声音从 B 机甲传出,必须交代怎么过去的);新出现的人物、舰船、能力、地点要有铺垫或一句话交代来历;句子主语与指代要清楚,避免让读者误解谁做了什么。",
"systemPromptSha256": "sha256:7d5b7de5378b4c9c376fce6115e2eb22797c28cb3db12f6b954e0aeaa487d17c",
"normalTerminalReasons": [
"completed"
],
@ -86,8 +86,8 @@
"semantic_detector": {
"profileVersion": "semantic-detector-gate-a-claude-opus-v3",
"claudeExecutablePath": "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude",
"claudeExecutableSha256": "5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629",
"claudeCliVersion": "2.1.211",
"claudeExecutableSha256": "ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c",
"claudeCliVersion": "2.1.231",
"modelAlias": "opus",
"resolvedModelId": "claude-opus-4-8[1m]",
"effort": "high",
@ -97,8 +97,8 @@
"jsonSchemaId": "semantic-detection-draft-v3",
"jsonSchemaSha256": "sha256:0432d705c0a78a057273f5bcda128e5d32161054a36931d2b7eec21d7289be15",
"systemPromptId": "semantic-detector-gate-a-system-v3",
"systemPrompt": "你是这部书的检测员。根据当前候选正文、冻结细纲与硬约束、事实证据和历史原文证据,对这一个候选做一次语义核查。\n\n## 核查范围\n1. 细纲的硬事件、结果方向、出场实体、伏笔动作与章末钩子,在正文里是否语义成立。\n2. 冻结事实、角色知情范围、人物行为逻辑、能力代价、地点规则、物品边界与叙事状态,正文是否与它们冲突。\n3. 正文是否引入了需要登记的新设定;是否存在必须补充证据才能判断的缺口。\n4. 谜底、真相和未来信息只能用来防止提前泄露,不能写进正文可见内容。\n\n## 核查纪律\n- 证据不足必须标 unknown 并说明缺口原因,不得猜成通过。\n- 每条结论都要引一句正文里可定位的原话作证据;引文必须真实出自正文。\n- 只依据给定的候选与证据判断,不把主观观感伪装成事实结论。\n- 一次只核查这一个候选,不继承其它会话,不负责改写正文。",
"systemPromptSha256": "sha256:0f4f4d69b22645be73034f3618db0a9a300ec019a93d8c72aaf7a6b4d44b122b",
"systemPrompt": "你是这部书的检测员。根据当前候选正文、冻结细纲与硬约束、事实证据和历史原文证据,对这一个候选做一次语义核查。\n\n## 核查范围\n1. 细纲的硬事件、结果方向、出场实体、伏笔动作与章末钩子,在正文里是否语义成立。\n2. 冻结事实、角色知情范围、人物行为逻辑、能力代价、地点规则、物品边界与叙事状态,正文是否与它们冲突。\n3. 正文是否引入了需要登记的新设定;是否存在必须补充证据才能判断的缺口。\n4. 谜底、真相和未来信息只能用来防止提前泄露,不能写进正文可见内容。\n\n## 引文铁律(最重要)\n- candidateQuote 必须是候选正文里**逐字相邻、真实存在**的一段原话。建议引**短句**(10–40 字,单句或同一段内),**绝不要把不相邻的几句拼接成一条引文**——长引用极易把中间隔了内容的句子缝在一起,导致校验失败。\n- 引用前先确认这几个字在正文里确实一字不差、紧挨着出现;有一点不确定就不引,改用 unknown 或证据缺口。**绝不允许编造正文里没有的话当引文。**\n- 引文选有辨识度的片段,不要引「。」「他说」这类到处出现的短词。\n\n## 输出各项含义\n- claims:对正文事实性陈述的核查,coverageState 取 supported(有据)/declared_new(新声明)/unknown(证据不足)/conflict(与既有事实冲突)。\n- findings:发现的问题,按严重度,如提前泄露、逻辑冲突、知情越界。\n- assertionVerdicts:对输入的 oracle 断言逐条裁决。\n- hardConstraintVerdicts:对细纲硬约束逐条裁决是否满足。\n- newSettingCandidates:正文引入、需要登记的新设定。\n- evidenceGaps:证据不足、需补检索才能判断的缺口。\n\n## 新内容判定(很重要)\n- 细纲已声明的内容——hardConstraints 里的硬事件、章末钩子、必须出场角色,以及 declaredNewFacts——是本章**应当揭示**的内容。正文落实它们时,识别为 declared_new(本章声明的新内容),**不要标成证据缺口或冲突**。\n- 本章新揭示的设定/能力/符号(如新机甲、新程序、新信号),只要是细纲要求的揭示,就是 declared_new,不因「前文没铺垫」而判为证据缺口。\n- 只有细纲**未声明**、又无历史原文或事实证据支撑、且无法由正文自洽解释的内容,才是证据缺口。\n\n## 核查纪律\n- 证据不足必须标 unknown 并说明缺口原因,不得猜成通过。\n- 只依据给定的候选与证据判断,不把主观观感伪装成事实结论。\n- 一次只核查这一个候选,不继承其它会话,不负责改写正文。\n\n## 纠错\n- 如果输入含 correction 字段(previousDraft 是你上一轮的产出,error 是它不合格的原因),请针对 error 修正后重新输出**完整**检测报告。最常见错误是 candidateQuote 不在候选正文里——请改用候选正文中逐字真实存在、紧挨着的短句作引文。",
"systemPromptSha256": "sha256:dbb146e325a112d5676f50e09af73e12f03a659c0824b27b2956f8c6ae64c9ec",
"normalTerminalReasons": [
"completed"
],
@ -110,8 +110,8 @@
"blind_judge": {
"profileVersion": "blind-judge-gate-a-claude-opus-v3",
"claudeExecutablePath": "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude",
"claudeExecutableSha256": "5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629",
"claudeCliVersion": "2.1.211",
"claudeExecutableSha256": "ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c",
"claudeCliVersion": "2.1.231",
"modelAlias": "opus",
"resolvedModelId": "claude-opus-4-8[1m]",
"effort": "high",
@ -121,8 +121,8 @@
"jsonSchemaId": "blind-judge-draft-v3",
"jsonSchemaSha256": "sha256:c2daa6ca501dd570e2936519f32b37af47d1cd23c50f1937336fc8ad3000889b",
"systemPromptId": "blind-judge-gate-a-system-v3",
"systemPrompt": "你是这部书的质量评委。对给定的匿名候选做一次独立评分:候选正文、所有候选共同的细纲、oracle 断言和评分标准(rubric)都在输入里。\n\n## 独立性(命根子)\n- 不因为\"需要收敛\"而调整严格度,不猜测别的评委会打几分。\n- 只用当前输入里的事实评分,不使用输入之外的事实。\n\n## 评分纪律\n- 逐候选、逐维给分(0–10,0.5 步长),每一维都给具体理由。\n- 每条判断引一句候选里可定位的原话作证据;引文必须真实出自候选。\n- 给出每个维度上匿名候选的完整排序,以及每个候选对全部 oracle 断言和硬约束的裁决;不省略、不增加对象。\n- 证据只来自候选、细纲、oracle 断言或你的推理,引用对象必须来自当前输入。",
"systemPromptSha256": "sha256:1d107f60bc670dc5bb3144bc04884eb4061f07331fce03154294e3831b86de69",
"systemPrompt": "你是这部书的质量评委。对给定的匿名候选做一次独立评分:候选正文、所有候选共同的细纲、oracle 断言和评分标准(rubric)都在输入里。\n\n## 独立性(命根子)\n- 不因为\"需要收敛\"而调整严格度,不猜测别的评委会打几分。\n- 只用当前输入里的事实评分,不使用输入之外的事实。\n\n## 引文铁律(最重要)\n- 每条判断的 candidateQuote 必须是候选正文里**逐字真实出现**的原话片段。引用前先确认这句话确实在候选里一字不差地存在;有一点不确定就不引。**绝不允许编造一句候选里没有的话当引文。**\n- 引文选有辨识度的片段,不要引「。」「他说」这类到处出现的短词。\n\n## 输出各项含义\n- candidateScores:对每个匿名候选、按 rubric 的每一维给分(0–10,0.5 步长)+ 具体 reason + 支撑该分的 candidateQuote + evidenceRefs。\n- evidenceRefs 的证据类型只允许 candidate(候选正文)/fine_outline(细纲)/oracle_assertion(oracle 断言)/judge_inference(你的推理),引用对象必须来自当前输入。\n- 每个维度给出匿名候选的完整排序;每个候选对全部 oracle 断言和硬约束逐条给 verdict;不省略、不增加对象。\n\n## 评分纪律\n- 逐候选、逐维给分,每一维都给具体理由。\n- 证据只来自候选、细纲、oracle 断言或你的推理。\n\n## 纠错\n- 如果输入含 correction 字段(previousDraft 是你上一轮的完整产出,error 是它不合格的原因),请只针对 error 修正后重新输出完整评审草稿。最常见错误是 candidateQuote 不在对应候选正文中;必须改用该候选正文里逐字相邻、真实存在的短句,不能改写或拼接。",
"systemPromptSha256": "sha256:f12d3edcf822419bdabf0f04ff139d8f7e3e5721b31456dc6b77a8d62c1a074d",
"normalTerminalReasons": [
"completed"
],
@ -135,42 +135,42 @@
"executionAuthorization": {
"runtimeProbe": {
"status": "successful",
"checkedAt": "2026-07-25T16:08:06+00:00",
"checkedAt": "2026-08-14T02:00:14+00:00",
"claudeExecutablePath": "/Users/qingse/.nvm/versions/node/v24.15.0/bin/claude",
"claudeExecutableSha256": "5a728a76198b6eca7f3c7cdbff43bab44b77b48c2108f7a3107d889773382629",
"claudeCliVersion": "2.1.211",
"claudeExecutableSha256": "ba790279cab6ef77b713864d4bf5f764fcea87d3a3eb7591a41f741e45212b5c",
"claudeCliVersion": "2.1.231",
"modelAlias": "opus",
"resolvedModelId": "claude-opus-4-8[1m]",
"executionProfileSha256": "sha256:ffbb0b9acbe9be2d3a95e8911fed3dc98db697c949840e4d57d15827bd79015f",
"executionReceiptSha256": "sha256:c345074abe9fec76446dca9e585bdf13282e9e108a2f148cddc04e9522ea35f0",
"structuredOutputSha256": "sha256:0cc933c5c9e6236a004913f1323a11f0b31a7c4d93de6746630231fe7ebf8f79",
"executionProfileSha256": "sha256:25168029a85cc2a8609b9ff6eda51e213ec04c7d7384ac9ca00eb0c0a48055ac",
"executionReceiptSha256": "sha256:d7ad7cae0531ce4891eb44df30bf03e25e4685163fdda1ba771a9605a528f4b7",
"structuredOutputSha256": "sha256:3aa0dcd09037fb46accc79491b7ba2b576b8b673beb9a4930edc4b8aba6657be",
"terminalReason": "completed",
"totalCostUsd": "0.042795",
"totalCostUsd": "0.048025",
"modelMatch": true,
"exitCode": 0,
"apiErrorStatus": null,
"receiptSha256": "sha256:53d7da7e153e052190a2d1febb74fc632a293e87f07e651e73071d4f28c8abde"
"receiptSha256": "sha256:f24b756cbd9160cd5ed33991d5bcdcf2e9190986f3d27646447be6d6ca7444b9"
},
"profileSha256": {
"writer": "sha256:ffbb0b9acbe9be2d3a95e8911fed3dc98db697c949840e4d57d15827bd79015f",
"semantic_detector": "sha256:717c94cd7a86e751c2ac3c6f40c11d14bfea36e958ef349888f0f4d66fe1582f",
"blind_judge": "sha256:e6bfe7e9a75cc7d97dcb26784461a1e5fce09b64c321e48340346a1e7a0b3694"
"writer": "sha256:25168029a85cc2a8609b9ff6eda51e213ec04c7d7384ac9ca00eb0c0a48055ac",
"semantic_detector": "sha256:8729e4733cfd79d373ac3c97ca4d3cf8961c9fbf2021c9d084a00b58de43890a",
"blind_judge": "sha256:433cd5cf3952a6fef18c8baff867a95323ffdcfc50d0e9b60040c612fd410903"
},
"budget": {
"status": "approved",
"reason": "用户已批准 Gate A 总预算 450 美元;plannedCalls 为 writer 45 次(15 基础 + 30 修订,每臂最多 2 次篇幅修订)、semantic_detector 与 blind_judge 各 15 次,maxCalls 为各 150 次安全上限;单次 cap 5 美元,启动预留按 plannedCalls 乘单次 cap,实际成本按可信回执累计。",
"totalBudgetUsd": "450.000000",
"reason": "用户要求以跑通 Gate A 完整流程为目标,当前总预算不设业务限制;2250 美元覆盖三角色各 150 次安全上限。plannedCalls 为 writer 60 次(15 基础 + 45 修订)、semantic_detector 24 次(15 基础 + 9 个格式纠错或瞬时 API 重试余量)、blind_judge 45 次(最多三位评委且每位至多两个格式纠错或瞬时 API 重试槽位);单次 cap 5 美元,实际成本按可信回执累计。",
"totalBudgetUsd": "2250.000000",
"plannedCalls": {
"writer": 45,
"semantic_detector": 15,
"blind_judge": 15
"writer": 60,
"semantic_detector": 24,
"blind_judge": 45
},
"maxCalls": {
"writer": 150,
"semantic_detector": 150,
"blind_judge": 150
},
"receiptSha256": "sha256:b68eaa66c32e96e6b739a0ff395116fb89de67fa30680dddf0b8f3dae8c1c397"
"receiptSha256": "sha256:764267881671d91d9c3ba65e9a858ba28c6a592079ffbe9d15457da291043847"
},
"rawRetention": {
"status": "approved",

View File

@ -17,14 +17,14 @@ import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence
from typing import Any, Callable, Mapping, Sequence
import psycopg
from psycopg.rows import dict_row
SCRIPT_DIR = Path(__file__).resolve().parent
READ_CONTEXT_SCRIPTS = SCRIPT_DIR.parents[1] / "read-context" / "scripts"
SNAPSHOT_SCRIPTS = SCRIPT_DIR.parents[1] / "snapshot" / "scripts"
READ_CONTEXT_SCRIPTS = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
SNAPSHOT_SCRIPTS = SCRIPT_DIR.parents[1] / "freeze-context" / "scripts"
sys.path.insert(0, str(READ_CONTEXT_SCRIPTS))
sys.path.insert(0, str(SNAPSHOT_SCRIPTS))
@ -50,7 +50,15 @@ from retrieve_writer_sources import ( # noqa: E402
build_retrieval_plan,
retrieve_writer_sources,
)
from writer_contract import han_count, normalize_text # noqa: E402
from writer_contract import ( # noqa: E402
PATTERN_NAME_MAX_CHARS,
PATTERN_POINTS_MAX_FIELDS,
PATTERN_POINT_MAX_CHARS,
PATTERN_SUMMARY_MAX_CHARS,
han_count,
normalize_text,
pattern_references_for_arm,
)
from writer_eval_preregister import build_balanced_preregistration # noqa: E402
@ -65,10 +73,17 @@ EXPECTED_ORACLE_INPUT_PROVENANCE = "oracle_reference_scaffold"
PREREGISTERED_MAX_CONTEXT_CHARS = 140_000
RUNTIME_ADAPTER_VERSION_PREFIX = "writer-runtime-v1"
BUDGET_ROLES = ("writer", "semantic_detector", "blind_judge")
# raw vault 对租约的硬上限是 24 小时(raw_vault.MAX_RETENTION),vault 跑完即自动清理;
# 此窗口只是「万一中途崩溃」后由 vault 回收孤儿租约的安全顶——正常跑完用不到它,
# 6 小时对五章装配绰绰有余,且严格小于 24 小时硬上限,保证 create_vault 校验通过。
RAW_RETENTION_WINDOW = timedelta(hours=6)
# raw vault 对租约的硬上限是 24 小时。五章三臂会串行执行多角色长调用,因此装配时
# 使用接近硬上限但留有时钟余量的窗口;execute 仍会在启动和每笔调用前复检。
RAW_RETENTION_WINDOW = timedelta(hours=23)
# 公共范式库五型(muse_knowledge_draft.draft_payload->>'型'):
# 套路 / 通用桥段 / 叙事技法 / 情感桥段 / 打斗桥段。C 臂按型各召回若干张。
PATTERN_CARD_TYPES = ("trope", "scene_pattern", "craft", "emotion", "combat")
# 每型最多取 2 张:五型合计 ≤ 10,严格低于总量硬上限,避免撑爆写手上下文预算。
PATTERN_TOP_PER_TYPE = 2
# 范式引用总量硬上限;即使提高每型 top,也不会超过这个数。
PATTERN_TOTAL_CAP = 12
class WriterReferenceWorkError(AdapterError):
@ -1531,14 +1546,183 @@ def load_writer_reference_rows(
}
def _default_pattern_card_searcher(
*, dsn: str, tenant_id: int
) -> Callable[..., list[dict[str, Any]]]:
"""惰性导入公共范式库检索器,返回签名 ``(intent, *, ttype, top)`` 的调用体。
WHY 惰性:离线测试与 dry-run 不应被迫加载数据库/嵌入依赖,也不能在装配时
真连库;只有生产入口 ``main`` 才显式取用本函数,把真实检索接入 C 臂。
"""
search_scripts = SCRIPT_DIR.parents[1] / "search-knowledge" / "scripts"
sys.path.insert(0, str(search_scripts))
from search import search_cards # noqa: E402 惰性导入,避免模块级副作用
def _searcher(intent: str, *, ttype: str, top: int) -> list[dict[str, Any]]:
# 公共范式还在 draft 双轨,但必须走专用检索面;dsn/tenant 显式绑定本次 loader,
# 防止真实正文来自一套快照、范式却被默认常量带到另一库或另一租户。
return search_cards(
intent,
scope="public_pattern",
ttype=ttype,
purpose="generation",
top=top,
dsn=dsn,
tenant_id=tenant_id,
)
return _searcher
def _flatten_pattern_point(value: Any) -> str:
"""把范式卡字段值拍平成文本。WHY:writingPoints 合同是「字符串→字符串」,而
search_cards 的 visibleFields 值可能是列表/对象,统一拍平后才能过合同。"""
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _truncate_for_writer(text: str, max_chars: int) -> str:
"""按 code point 截断到上限以内,超长补一个省略号并重新 NFC 归一化。
WHY:截断可能落在组合字符边界、导致结果不再是 NFC,而合同 _string 会复核
value == NFC(value);因此截断后必须再归一化一次,保证产出永远过得了合同。
"""
if len(text) <= max_chars:
return text
return normalize_text(text[: max(0, max_chars - 1)] + "…")
def _pattern_content_projection(card: Mapping[str, Any]) -> dict[str, Any]:
"""把 search_cards 的 name/summary/visibleFields 投影为合同允许的限量内容字段。
WHY(SoT 变更):写手要真正读到范式卡的名字、一句话摘要和写法要点,而不只是一个
来源标签;但 visibleFields 原始字段可能长达数千字,直接灌入会撑爆写手上下文预算,
因此逐字段截断、只取前若干个字段。上限与合同(writer_contract._pattern_source_ref)
共用同一组常量,合同侧再失败关闭复核,双重保证体量受控。
"""
content: dict[str, Any] = {}
name = normalize_text(str(card.get("name") or "")).strip()
if name:
content["name"] = _truncate_for_writer(name, PATTERN_NAME_MAX_CHARS)
summary = normalize_text(str(card.get("summary") or "")).strip()
if summary:
content["summary"] = _truncate_for_writer(summary, PATTERN_SUMMARY_MAX_CHARS)
visible = card.get("visibleFields")
if isinstance(visible, Mapping):
points: dict[str, str] = {}
# visibleFields 来自库内 jsonb,键序确定;按序取前 N 个非空字段作为写法要点。
for key, value in visible.items():
if len(points) >= PATTERN_POINTS_MAX_FIELDS:
break
point_key = normalize_text(str(key)).strip()
point_value = normalize_text(_flatten_pattern_point(value)).strip()
if not point_key or not point_value:
continue
points[point_key] = _truncate_for_writer(point_value, PATTERN_POINT_MAX_CHARS)
if points:
content["writingPoints"] = points
return content
def _retrieve_pattern_references(
intent: str,
*,
card_searcher: Callable[..., list[dict[str, Any]]],
types: Sequence[str] = PATTERN_CARD_TYPES,
top_per_type: int = PATTERN_TOP_PER_TYPE,
total_cap: int = PATTERN_TOTAL_CAP,
) -> list[dict[str, Any]]:
"""按本章检索意图,从公共范式库五型各召回 top-k 卡,投影为写手合同 patternReferences。
WHY:Writer Gate A 的 C 臂要验证「范式指导是否提升质量」,需要把公共范式卡接入
写手输入。每张卡投影成 WriterContext v1 ``patternReferences``:来源指针
(sourceId / sourceVersion / sourceType,保证可回读可审计)**外加内容字段**
(name/summary/writingPoints,保证写手真正读到范式卡的名字、摘要与写法要点)。
内容字段经 ``_pattern_content_projection`` 截断到合同上限以内,确保通过
``validate_writer_context`` 的 ``_pattern_source_ref`` 校验。search_cards 已直接给出
稳定的 ``sourceId``(draft:{id})与 ``sourceVersion``(draft-revision:{n}),正好复用。
总量受控:每型最多 top_per_type 张,且累计不超过 total_cap,避免撑爆上下文预算。
"""
intent_text = normalize_text(str(intent or "")).strip()
if not intent_text:
# 没有检索意图(细纲为空)就不召回,失败关闭而非注入空引用。
return []
references: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for card_type in types:
if len(references) >= total_cap:
break
# 剩余名额决定本型实际 top,保证累计严格不超过 total_cap。
top = min(top_per_type, total_cap - len(references))
if top <= 0:
break
for card in card_searcher(intent_text, ttype=card_type, top=top):
# WHY: SQL 是第一道范围门,loader 仍只接受专用公共范式面标记为可用于生产
# 检索的行;fake/未来替换实现若漏做范围过滤,也不能把治理草稿注入写手。
if (
card.get("retrievalScope") != "public_pattern"
or card.get("productionRetrievalEligible") is not True
or card.get("sourceKind") != "draft"
):
continue
source_id = normalize_text(str(card.get("sourceId") or "")).strip()
source_version = normalize_text(str(card.get("sourceVersion") or "")).strip()
if not source_id or not source_version:
# 缺稳定来源指针的卡不能进冻结上下文,跳过而非混入空引用。
continue
key = (source_version, source_id)
if key in seen:
# 跨型去重:同一张卡只注入一次。
continue
seen.add(key)
card_kind = normalize_text(str(card.get("type") or card_type)).strip() or card_type
references.append(
{
"sourceId": source_id,
"sourceVersion": source_version,
# sourceType 会成为写手最终看到的 kind;用范式卡的型作标识。
"sourceType": card_kind,
# SoT 变更:内容字段(名字/摘要/写法要点)随来源指针一起注入,写手
# 才能真正读到范式卡;此前只有上面三个指针字段,写手只见一个空标签。
**_pattern_content_projection(card),
}
)
if len(references) >= total_cap:
break
return references
def _pattern_references_for_arm(arm: str, c_references: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""装配端分臂的薄封装:语义唯一事实源在 writer_contract.pattern_references_for_arm。
WHY:装配与回放是两段独立 assemble 的链路,必须按完全相同的规则分臂(A 恒空 /
其余臂拿 C 候选),否则 C 臂真写读不到范式卡或 A 臂混入范式卡。判定逻辑一律走
合同模块,不在装配端另写一套;保留这个私有入口只为兼容既有离线测试的导入面。
"""
return pattern_references_for_arm(arm, c_references)
def assemble_writer_gate_config(
*,
base_config: Mapping[str, Any],
selector_config: Mapping[str, Any],
selector_digest: str,
rows: Mapping[str, Any],
pattern_card_searcher: Callable[..., list[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
"""把同一事务快照装配成 canonical_frozen_prose 五样本配置。"""
"""把同一事务快照装配成 canonical_frozen_prose 五样本配置。
``pattern_card_searcher`` 为 None 时不注入范式卡(A/C 两臂 patternReferences 均空),
保持历史行为与离线测试的零数据库依赖;生产入口显式传入真实检索器才启用 C 臂注入。
"""
common_controls = _validate_loader_controls(
base_config,
@ -1790,6 +1974,24 @@ def assemble_writer_gate_config(
"tokenBudget": assembly_token_budget,
}
)
# 检索意图取自写手细纲:硬约束(含大纲文字与门禁要求)+ 可调节拍。
# WHY:这两段是本章创作意图的最稠密表达,用它做向量检索能召回最贴合的范式卡。
pattern_intent = "\n".join(
[str(item) for item in fine_outline["hardConstraints"]]
+ [str(item) for item in fine_outline["adjustableBeats"]]
)
# 未提供检索器时为空,保持历史行为;提供时仅 C 臂经 _pattern_references_for_arm 取用。
c_pattern_references = (
_retrieve_pattern_references(pattern_intent, card_searcher=pattern_card_searcher)
if pattern_card_searcher is not None
else []
)
# 把 C 臂候选范式卡冻结进 writerContextInput.patternReferences,随 config.json 序列化。
# WHY:回放端(run_writer_replay)真写时会从 config.json 重新 assemble 各臂上下文;
# 若候选不写进 writerContextInput,C 臂真写就拿不到范式卡,实验失效。这里冻结全量
# 候选(含来源指针,只留在冻结上下文供审计回读),回放端读出后再经同一事实源
# pattern_references_for_arm 按臂分配——A 恒空,单变量规则两端只有一处定义。
context_input["patternReferences"] = _pattern_references_for_arm("C", c_pattern_references)
writer_contexts: dict[str, dict[str, Any]] = {}
try:
for arm, strategy in (
@ -1815,7 +2017,7 @@ def assemble_writer_gate_config(
recent_chapters=context_input["recentChapters"],
output_contract=context_input["outputContract"],
token_budget=assembly_token_budget,
pattern_references=context_input.get("patternReferences", []),
pattern_references=_pattern_references_for_arm(arm, c_pattern_references),
generated_at=context_input["generatedAt"],
evidence_strategy=strategy,
)["context"]
@ -1929,6 +2131,11 @@ def main() -> int:
selector_config=selectors,
selector_digest=selector_digest,
rows=rows,
# 生产装配才真连公共范式库:C 臂注入范式卡,A 臂保持空对照。
pattern_card_searcher=_default_pattern_card_searcher(
dsn=args.dsn,
tenant_id=args.tenant_id,
),
)
output_dir = args.output_dir or (
PRIVATE_TMP / f"writer-gate-a-{uuid.uuid4().hex}"

Some files were not shown because too many files have changed in this diff Show More