框架: 技能按动作-对象重组 + 先审后入创作闭环
一、技能重组(动作-对象命名) - 旧目录 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:
parent
cfa46e6e7a
commit
b0bc7a8745
@ -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)。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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. 开发评测边界
|
||||
|
||||
|
||||
@ -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. 任一审核、实验或运行回执,都能用库内一条记录定位到它所评价的正文或候选哈希。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 备份与重建脚本。
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
## 4. 只读可视化模块
|
||||
|
||||
- 一个纯 Python 标准库的本地 web 看板(web 层零框架、零新增依赖;psycopg 复用 `.venv` 现成的,非标准库),内网或 Tailscale 访问,形态参照 open-wenmo。
|
||||
- 只对数据库做只读查询并渲染,**不触发任何写操作**;接受、丢弃等写操作仍由 `confirm` skill 和主会话走,看板只展示结果。
|
||||
- 只对数据库做只读查询并渲染,**不触发任何写操作**;接受、丢弃等写操作仍由 `decide-candidate` Skill 和主会话走,看板只展示结果。
|
||||
- 它看到的内容等于库里的内容,因此它倒逼第 3 条“一切落库”。
|
||||
- 合同细节见 [08-数据权威与可视化领域](08-数据权威与可视化领域.md)。
|
||||
|
||||
|
||||
@ -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 信息披露的重点(怎么组织、为什么)
|
||||
|
||||
@ -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` 登记的闭集;任一高严重度问题由编排器阻断,检测员不改候选、不裁决卡效用。
|
||||
|
||||
## 禁区
|
||||
|
||||
|
||||
@ -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` 入库)。
|
||||
|
||||
@ -5,7 +5,7 @@ tools: Read, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
你是质量评委。功能合同以 `quality-gate` skill 为准。每次调用只完成一个独立评审,不修改候选、规划、知识卡、运行状态或任何文件。
|
||||
你是质量评委。功能合同以 `score-content-quality` Skill 为准。每次调用只完成一个独立评审,不修改候选、规划、知识卡、运行状态或任何文件。
|
||||
|
||||
## 正文回放边界
|
||||
|
||||
|
||||
@ -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` 做)。
|
||||
|
||||
@ -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);只借鉴其写法节奏与技巧,不当作事实约束,不照抄。
|
||||
|
||||
## 元数据纪律(怎么用元数据)
|
||||
|
||||
|
||||
@ -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 注入。
|
||||
|
||||
@ -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")
|
||||
@ -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)
|
||||
@ -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
|
||||
55
.claude/skills/access-database/scripts/test_skill_catalog.py
Normal file
55
.claude/skills/access-database/scripts/test_skill_catalog.py
Normal 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()
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: read-context
|
||||
description: 统一创作数据读取器的操作合同。按冻结点从 PostgreSQL 读取可信来源,组装完整审计上下文,再为各角色生成最小可见投影。
|
||||
name: assemble-context
|
||||
description: 按冻结点从 PostgreSQL 读取可信来源,组装可审计上下文,并为 writer、detector、judge、planner 或 extractor 生成最小投影。创作或评测调用模型前需要受控上下文时使用。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
@ -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 "
|
||||
@ -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(
|
||||
@ -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"
|
||||
@ -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):
|
||||
"""诊断卡索引提示只能进入不可接受的诊断上下文。"""
|
||||
|
||||
@ -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`,模型与维度钉死)。
|
||||
@ -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)
|
||||
|
||||
98
.claude/skills/capture-ai-flavor-cases/SKILL.md
Normal file
98
.claude/skills/capture-ai-flavor-cases/SKILL.md
Normal 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 不能投影样例、缺重验证回执不能确认,以及跨作品/反例门。
|
||||
@ -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` 的实体/范式卡。
|
||||
@ -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` = 错误修复回归。
|
||||
@ -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: 等待正反证据和人工标注。
|
||||
File diff suppressed because it is too large
Load Diff
@ -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: 合成反例,确认表面形式有叙事功能}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;仅作为候选,未进入生产规则。
|
||||
988
.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py
Normal file
988
.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py
Normal 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())
|
||||
267
.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py
Normal file
267
.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py
Normal 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)
|
||||
@ -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()
|
||||
@ -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` 的规划上下文和候选,不看目标章标准事实,不把评测答案倒灌回规划侧。
|
||||
|
||||
检查对象和证据格式:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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",
|
||||
]
|
||||
@ -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()
|
||||
@ -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))
|
||||
|
||||
@ -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"):
|
||||
@ -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——换范围重切前先归档旧产物目录。
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""clean skill:精确匹配删除执行器(LLM 建议 ≠ 必删,守卫规则最终裁决)+ 审计入库。
|
||||
"""clean-book-text Skill:精确匹配删除执行器(LLM 建议 ≠ 必删,守卫规则最终裁决)+ 审计入库。
|
||||
|
||||
输入 deletions JSON:[{chapter_order|chapter, exact, reason}](键名 ASCII,兼容中文键)。
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""clean skill:放量驱动——每书串行跑 prep→detect→apply 全链,可多进程分书并行。
|
||||
"""clean-book-text Skill:放量驱动——每书串行跑 prep→detect→apply 全链,可多进程分书并行。
|
||||
|
||||
断点续跑设计:
|
||||
- prep 仅在该书 manifest 缺失时执行(防覆盖已切窗);
|
||||
@ -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 端窗产物齐备可继续,审计可追——绝不拿空内容当成功
|
||||
@ -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
|
||||
@ -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)——
|
||||
@ -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。
|
||||
|
||||
## 红线
|
||||
136
.claude/skills/decide-candidate/scripts/acceptance_state.py
Normal file
136
.claude/skills/decide-candidate/scripts/acceptance_state.py
Normal 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"]
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
336
.claude/skills/decide-candidate/scripts/fact_delta.py
Normal file
336
.claude/skills/decide-candidate/scripts/fact_delta.py
Normal 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)
|
||||
195
.claude/skills/decide-candidate/scripts/projection_registry.py
Normal file
195
.claude/skills/decide-candidate/scripts/projection_registry.py
Normal 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",
|
||||
]
|
||||
@ -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
|
||||
|
||||
137
.claude/skills/decide-candidate/scripts/test_fact_delta.py
Normal file
137
.claude/skills/decide-candidate/scripts/test_fact_delta.py
Normal 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()
|
||||
301
.claude/skills/decide-candidate/scripts/test_fact_delta_db.py
Normal file
301
.claude/skills/decide-candidate/scripts/test_fact_delta_db.py
Normal 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()
|
||||
264
.claude/skills/decide-candidate/scripts/test_projection_db.py
Normal file
264
.claude/skills/decide-candidate/scripts/test_projection_db.py
Normal 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()
|
||||
@ -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()
|
||||
@ -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))
|
||||
@ -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)
|
||||
@ -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` 嵌入。均不提交/不自确认。
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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(
|
||||
@ -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 会崩(放量实测)
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
136
.claude/skills/design-story-foundation/SKILL.md
Normal file
136
.claude/skills/design-story-foundation/SKILL.md
Normal 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`。不得用部分结果冒充完成。
|
||||
@ -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 会话。当前章只有经主会话审定并写入统合候选后,才可作为下一节点的权威前文。未审定的代理输出、分析摘要和舍弃方案不能进入下一节点上下文。
|
||||
@ -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`。
|
||||
296
.claude/skills/design-story-foundation/scripts/serial_merge.py
Normal file
296
.claude/skills/design-story-foundation/scripts/serial_merge.py
Normal 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())
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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())
|
||||
@ -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。
|
||||
@ -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。失败原样报错不静默。
|
||||
"""
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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",
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user