diff --git a/.agent/skills/操作/目录.md b/.agent/skills/操作/目录.md
index 96a67da..6e9573e 100644
--- a/.agent/skills/操作/目录.md
+++ b/.agent/skills/操作/目录.md
@@ -8,3 +8,4 @@
| 写下一章 | [SKILL.md](写下一章/SKILL.md) | 对目标章发起两阶段生成:受限探索、冻结回放、无工具写作与独立检查,产出模型候选供作者审阅。 | 需要该项操作时 | 只使用已实现的具名入口 |
| 扩写场景 | [SKILL.md](扩写场景/SKILL.md) | 对既有章节按扩写粒度发起生成,材料与检查与写下一章同一受控链路。 | 需要该项操作时 | 只使用已实现的具名入口 |
| 核对内容一致性 | [SKILL.md](核对内容一致性/SKILL.md) | 读取候选的独立检查报告:基础有效性与连续性发现,证据逐条可回读。 | 需要该项操作时 | 只使用已实现的具名入口 |
+| 确认事实增量 | [确认事实增量/SKILL.md](确认事实增量/SKILL.md) | 章后派生事实提案的作者独立确认 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |
diff --git a/.agent/skills/操作/确认事实增量/SKILL.md b/.agent/skills/操作/确认事实增量/SKILL.md
new file mode 100644
index 0000000..4a9da29
--- /dev/null
+++ b/.agent/skills/操作/确认事实增量/SKILL.md
@@ -0,0 +1,28 @@
+---
+name: 确认事实增量
+description: 章后派生事实提案的作者独立确认操作。采纳或保存正文后处理候选事实提案时使用。
+---
+
+# 确认事实增量
+
+正文采纳或保存后,章后派生任务会为新章节提出候选事实提案(Shadow,未生效)。本技能供作者独立确认这些提案。
+
+## 何时使用
+
+- 一章正文采纳/保存且章后派生完成后,作者复核"本章新增了哪些事实"。
+- 依据 [P05-章后处理与事实复核](../../../../docs/系统架构/新版设计/功能规格/P05-章后处理与事实复核.md):抽取结果不自动成为事实,必须逐条由作者确认。
+
+## 操作步骤
+
+1. 查看章后派生状态:任务记录中找该章的「章后处理」任务(或经 `查询章后状态`),读取"登记事实提案"步骤检查点中的提案清单。
+2. 逐条打开审阅:工作台事实接口 `open_fact_review`。
+3. 逐条决定:
+ - 采纳:`decide_world_fact`,动作采纳,附作者审阅凭据;确认后事实进入本书事实面并参与后续时点查询。
+ - 拒绝:动作拒绝;提案保留历史,不影响已确认设定。
+4. 跳过项(引文定位失败等)在任务检查点"跳过"列表中单独可见;需要时人工补提案。
+
+## 边界
+
+- 只确认已存在的提案;不代作者新造事实、不修改提案内容。
+- 确认是作者决定,走 S01 正式变更;任何自动化不得代替本步骤。
+- 派生失败时先重试派生(恢复任务或按新版本重建),再确认事实。
diff --git a/.agent/skills/操作/确认事实增量/目录.md b/.agent/skills/操作/确认事实增量/目录.md
new file mode 100644
index 0000000..4548c6e
--- /dev/null
+++ b/.agent/skills/操作/确认事实增量/目录.md
@@ -0,0 +1,3 @@
+| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
+|------|----------|----------|----------|----------|
+| 技能说明 | SKILL.md | 章后派生事实提案的作者独立确认操作 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |
diff --git a/docs/系统架构/新版设计/改造计划/工作包清单.json b/docs/系统架构/新版设计/改造计划/工作包清单.json
index 90daa1c..0f721c4 100644
--- a/docs/系统架构/新版设计/改造计划/工作包清单.json
+++ b/docs/系统架构/新版设计/改造计划/工作包清单.json
@@ -2636,8 +2636,18 @@
"功能规格/P05-章后处理与事实复核.md",
"调用链路/关键旅程.md"
],
- "status": "planned",
- "execution_evidence": [],
+ "status": "verified",
+ "execution_evidence": [
+ {
+ "batch": "R2-20260909",
+ "date": "2026-09-11",
+ "summary": "章后派生与连续写作闭环整包落地:S01 采纳/保存事务经 S02.register_task 同事务登记章后任务(登记失败整事务回滚);五步派生流程(来源复检→抽取配置绑定→模型抽取→事实提案 Shadow 登记为幂等命令→摘要/索引派生绑定);失败步骤可见且恢复重验重试,旧版本派生被来源复检拒绝;确认事实增量技能接入主会话;两作品各连续两章工程旅程通过。11 个新消费级用例(NC-w16-*)通过;离线 269、非宿主数据库 220。独立整包验证通过(P1 md尾缀已随提交统一,P2 预算吞异常与降级测试留后续)。",
+ "evidence": [
+ ".agents.local/改造/R2-20260909/W16/回合开发/整包/执行证据.md",
+ ".agents.local/改造/R2-20260909/W16/回合验证/整包/验证报告.md"
+ ]
+ }
+ ],
"owned_file_paths": [
".agent/skills/操作/确认事实增量/SKILL.md",
".agent/skills/操作/确认事实增量/目录.md",
diff --git a/docs/系统架构/新版设计/目标文件清单.json b/docs/系统架构/新版设计/目标文件清单.json
index 16642f2..c1f0075 100644
--- a/docs/系统架构/新版设计/目标文件清单.json
+++ b/docs/系统架构/新版设计/目标文件清单.json
@@ -16401,6 +16401,9 @@
"design_location": "文件设计/测试与夹具.md#file-9adeb5b09af6e9db",
"legacy_sources": [],
"test_case_refs": [
+ "NC-w16-w16d01",
+ "NC-w16-w16d02",
+ "NC-w16-w16d03",
"TC-0fc8e056ab44",
"TC-45983a109225",
"TC-4a1c9a872099",
@@ -16633,6 +16636,8 @@
"legacy_sources": [],
"design_location": "文件设计/测试与夹具.md#file-906bb16ebd32a00f",
"test_case_refs": [
+ "NC-w16-w16b01",
+ "NC-w16-w16b02",
"TC-04de61917600",
"TC-1ca90d492167",
"TC-2e639e1cdb95",
@@ -16765,6 +16770,8 @@
"legacy_sources": [],
"design_location": "文件设计/测试与夹具.md#file-38ba596beb7da05e",
"test_case_refs": [
+ "NC-w16-w16a01",
+ "NC-w16-w16a02",
"TC-088d54c7fead",
"TC-092a8976b243",
"TC-32d3da269dec",
@@ -16855,6 +16862,10 @@
"legacy_sources": [],
"design_location": "文件设计/测试与夹具.md#file-de89f88083258850",
"test_case_refs": [
+ "NC-w16-w16c01",
+ "NC-w16-w16c02",
+ "NC-w16-w16c03",
+ "NC-w16-w16c04",
"TC-0c5ca15fcd56",
"TC-0e9f36d59c46",
"TC-137e87ea0267",
diff --git a/docs/系统架构/新版设计/验证设计/创作与正式变更用例.md b/docs/系统架构/新版设计/验证设计/创作与正式变更用例.md
index d719f26..5ddac2d 100644
--- a/docs/系统架构/新版设计/验证设计/创作与正式变更用例.md
+++ b/docs/系统架构/新版设计/验证设计/创作与正式变更用例.md
@@ -5098,3 +5098,135 @@
- 环境:离线组件纯函数。
- 合同:[权威定义](../模块设计/B05-正文写作.md)。
+
+
+
+## NC-w16-w16c01 · test_两作品各连续两章派生确认事实并按时点消费__16c01a
+
+- 位置:`tests/集成/test_章后失败与重建.py::test_两作品各连续两章派生确认事实并按时点消费__16c01a`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:两部作品各完成保存→章后派生→确认事实;确认事实进入正确时点且不跨作品。
+- 预期:派生任务步骤完成;确认事实可查询;跨作品不可见。
+- 环境:真实隔离 PG + 合成抽取宿主。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16c02 · test_抽取失败后正文可用且恢复重试完成__16c02b
+
+- 位置:`tests/集成/test_章后失败与重建.py::test_抽取失败后正文可用且恢复重试完成__16c02b`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:抽取回合失败时任务失败而正文保留;恢复重验通过后完成。
+- 预期:正文版本保留;步骤失败可见;恢复后完成。
+- 环境:真实隔离 PG + 合成抽取宿主。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16c03 · test_编辑后旧版本派生被拒绝且按新版本重建__16c03c
+
+- 位置:`tests/集成/test_章后失败与重建.py::test_编辑后旧版本派生被拒绝且按新版本重建__16c03c`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:正文前进后旧派生任务被来源复检拒绝,新版本任务正常完成。
+- 预期:旧任务失败于来源复检;新任务绑定新版本。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16c04 · test_重建幂等不重复事实提案__16c04d
+
+- 位置:`tests/集成/test_章后失败与重建.py::test_重建幂等不重复事实提案__16c04d`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:同来源版本重建派生任务不产生重复提案。
+- 预期:提案唯一;摘要可回查。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16a01 · test_章后登记失败回滚整个正文提交__16a01e
+
+- 位置:`tests/集成/test_正式提交原子性.py::test_章后登记失败回滚整个正文提交__16a01e`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:派生登记失败时正文、命令占用与任务一同回滚。
+- 预期:正文不存在;命令未占用;成功路径同事务可见。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16a02 · test_采纳候选与派生登记原子可见__16a02f
+
+- 位置:`tests/集成/test_正式提交原子性.py::test_采纳候选与派生登记原子可见__16a02f`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:候选采纳回执同事务呈现正文写入与章后任务登记。
+- 预期:回执含任务;正文版本前进。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16b01 · test_同命令重放同回执且章后任务不重复__16b011
+
+- 位置:`tests/集成/test_幂等与审阅版本.py::test_同命令重放同回执且章后任务不重复__16b011`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:相同保存命令重放返回既有回执且不重复登记派生。
+- 预期:回执一致;任务唯一。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16b02 · test_同命令异参数拒绝__16b022
+
+- 位置:`tests/集成/test_幂等与审阅版本.py::test_同命令异参数拒绝__16b022`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:相同命令身份携带不同请求被拒绝。
+- 预期:COMMAND_PAYLOAD_MISMATCH。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16d01 · test_候选哈希不匹配采纳拒绝__16d013
+
+- 位置:`tests/集成/test_候选状态与并发.py::test_候选哈希不匹配采纳拒绝__16d013`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:候选哈希与被审版本不一致时采纳被拒。
+- 预期:REVISION_CONFLICT 或 SCOPE_DENIED。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16d02 · test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024
+
+- 位置:`tests/集成/test_候选状态与并发.py::test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:正文前进后旧候选不能采纳;重开审阅后可明确拒绝。
+- 预期:陈旧采纳被拒;拒绝仅更新决定。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
+
+
+
+## NC-w16-w16d03 · test_并发采纳唯一成功且正文版本唯一__16d035
+
+- 位置:`tests/集成/test_候选状态与并发.py::test_并发采纳唯一成功且正文版本唯一__16d035`。
+- 给定:隔离数据库中的作品与已采纳正文。
+- 动作:两个候选基于同一版本时仅一个采纳成功。
+- 预期:后者 SOURCE_STALE 或 REVISION_CONFLICT;正文版本唯一。
+- 环境:真实隔离 PG。
+- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
+
diff --git a/docs/系统架构/新版设计/验证设计/测试用例清单.json b/docs/系统架构/新版设计/验证设计/测试用例清单.json
index c5889e1..7c9ba8f 100644
--- a/docs/系统架构/新版设计/验证设计/测试用例清单.json
+++ b/docs/系统架构/新版设计/验证设计/测试用例清单.json
@@ -122932,6 +122932,183 @@
"design_location": "验证设计/工作台与工程用例.md#nc-browser-generation-dialog",
"reason": "W15 通用生成、基本审校与候选:两阶段写手全链与独立检查,按功能价值新写。",
"code_sha256": "971edc8da5e0945d4d8f39f56dde83bbb4c103459f8a6c6148d473e195a1f6a1"
+ },
+ {
+ "case_id": "NC-w16-w16c01",
+ "file": "tests/集成/test_章后失败与重建.py",
+ "symbol": "test_两作品各连续两章派生确认事实并按时点消费__16c01a",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16c01",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "两部作品各完成保存→章后派生→确认事实;确认事实进入正确时点且不跨作品",
+ "then": [
+ "派生任务步骤完成",
+ "确认事实可查询",
+ "跨作品不可见"
+ ],
+ "environment": "真实隔离 PG + 合成抽取宿主",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16c02",
+ "file": "tests/集成/test_章后失败与重建.py",
+ "symbol": "test_抽取失败后正文可用且恢复重试完成__16c02b",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16c02",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "抽取回合失败时任务失败而正文保留;恢复重验通过后完成",
+ "then": [
+ "正文版本保留",
+ "步骤失败可见",
+ "恢复后完成"
+ ],
+ "environment": "真实隔离 PG + 合成抽取宿主",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16c03",
+ "file": "tests/集成/test_章后失败与重建.py",
+ "symbol": "test_编辑后旧版本派生被拒绝且按新版本重建__16c03c",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16c03",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "正文前进后旧派生任务被来源复检拒绝,新版本任务正常完成",
+ "then": [
+ "旧任务失败于来源复检",
+ "新任务绑定新版本"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16c04",
+ "file": "tests/集成/test_章后失败与重建.py",
+ "symbol": "test_重建幂等不重复事实提案__16c04d",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16c04",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "同来源版本重建派生任务不产生重复提案",
+ "then": [
+ "提案唯一",
+ "摘要可回查"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16a01",
+ "file": "tests/集成/test_正式提交原子性.py",
+ "symbol": "test_章后登记失败回滚整个正文提交__16a01e",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16a01",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "派生登记失败时正文、命令占用与任务一同回滚",
+ "then": [
+ "正文不存在",
+ "命令未占用",
+ "成功路径同事务可见"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16a02",
+ "file": "tests/集成/test_正式提交原子性.py",
+ "symbol": "test_采纳候选与派生登记原子可见__16a02f",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16a02",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "候选采纳回执同事务呈现正文写入与章后任务登记",
+ "then": [
+ "回执含任务",
+ "正文版本前进"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16b01",
+ "file": "tests/集成/test_幂等与审阅版本.py",
+ "symbol": "test_同命令重放同回执且章后任务不重复__16b011",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16b01",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "相同保存命令重放返回既有回执且不重复登记派生",
+ "then": [
+ "回执一致",
+ "任务唯一"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16b02",
+ "file": "tests/集成/test_幂等与审阅版本.py",
+ "symbol": "test_同命令异参数拒绝__16b022",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16b02",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "相同命令身份携带不同请求被拒绝",
+ "then": [
+ "COMMAND_PAYLOAD_MISMATCH"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16d01",
+ "file": "tests/集成/test_候选状态与并发.py",
+ "symbol": "test_候选哈希不匹配采纳拒绝__16d013",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16d01",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "候选哈希与被审版本不一致时采纳被拒",
+ "then": [
+ "REVISION_CONFLICT 或 SCOPE_DENIED"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16d02",
+ "file": "tests/集成/test_候选状态与并发.py",
+ "symbol": "test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16d02",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "正文前进后旧候选不能采纳;重开审阅后可明确拒绝",
+ "then": [
+ "陈旧采纳被拒",
+ "拒绝仅更新决定"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
+ },
+ {
+ "case_id": "NC-w16-w16d03",
+ "file": "tests/集成/test_候选状态与并发.py",
+ "symbol": "test_并发采纳唯一成功且正文版本唯一__16d035",
+ "design_location": "验证设计/创作与正式变更用例.md#nc-w16-w16d03",
+ "contract": "功能规格/P05-章后处理与事实复核.md",
+ "given": "隔离数据库中的作品与已采纳正文",
+ "when": "两个候选基于同一版本时仅一个采纳成功",
+ "then": [
+ "后者 SOURCE_STALE 或 REVISION_CONFLICT",
+ "正文版本唯一"
+ ],
+ "environment": "真实隔离 PG",
+ "verification_state": "已实现未执行",
+ "execution_evidence": ".agents.local/改造/R2-20260909/W16/回合开发/整包/"
}
]
}
diff --git a/src/muse/任务运行/接口.py b/src/muse/任务运行/接口.py
index 212dacd..edcc110 100644
--- a/src/muse/任务运行/接口.py
+++ b/src/muse/任务运行/接口.py
@@ -40,6 +40,7 @@ from muse.任务运行.模型 import (
步骤计划,
状态冲突,
租约失效,
+ 章后任务登记,
运行事件,
领取凭证,
)
@@ -106,6 +107,17 @@ class 任务服务:
self.处理器.获取(步骤.处理器ID, 步骤.处理器版本)
return 存储.创建(请求, 计划)
+ def 创建任务于(self, 连, 请求: 任务请求, 流程ID: str, 版本: str) -> str:
+ """在调用方事务内创建任务;供 S01 参与者同事务登记派生任务复用全部校验。"""
+ if 请求.执行用途 is not self.数据库.用途:
+ raise 任务错误("任务用途与装配用途不一致")
+ 存储 = 任务存储(连, self.数据库.用途)
+ 计划 = 存储.读取计划(流程ID, 版本)
+ self.处理器.核对计划(计划)
+ for 步骤 in 计划.步骤:
+ self.处理器.获取(步骤.处理器ID, 步骤.处理器版本)
+ return 存储.创建(请求, 计划)
+
def 读取任务(self, 任务ID: str) -> 任务快照:
with self._存储() as 存储:
return 存储.快照(任务ID)
@@ -256,7 +268,69 @@ class 任务服务:
存储.对账(任务ID, 尝试ID, 已保存输出, 对账回执)
+class 任务登记参与者:
+ """S01 同事务参与者:采纳正文与登记章后派生任务原子提交。"""
+
+ 身份 = "S02.register_task"
+ 请求类型 = 章后任务登记
+
+ def __init__(self, 服务: 任务服务) -> None:
+ self.服务 = 服务
+
+ def 提交(self, 连, 登记: 章后任务登记) -> dict:
+ try:
+ 请求 = 组装章后任务请求(self.服务, 登记)
+ except 任务错误:
+ raise
+ if not _计划已发布(连, self.服务, 登记.流程ID, 登记.流程版本):
+ # 部署未装配章后流程时如实呈现,不静默、不阻断正文提交。
+ return {"章后派生": "flow_not_published", "流程": 登记.流程ID}
+ 任务ID = self.服务.创建任务于(连, 请求, 登记.流程ID, 登记.流程版本)
+ return {"章后任务": 任务ID}
+
+
+def _计划已发布(连, 服务: 任务服务, 流程ID: str, 版本: str) -> bool:
+ try:
+ with 服务._存储() as 存储:
+ 存储.读取计划(流程ID, 版本)
+ return True
+ except 任务错误:
+ return False
+
+
+def 组装章后任务请求(服务: 任务服务, 登记: 章后任务登记) -> 任务请求:
+ """由登记合同与当前发布资源组装完整任务请求;身份与版本全部来自装配。"""
+ 策略 = 角色策略目录.从发布包()
+ return 任务请求(
+ 任务类型=登记.流程ID,
+ 命令ID=登记.命令ID,
+ 作者=登记.作者,
+ 执行用途=服务.数据库.用途,
+ 内容用途=登记.内容用途,
+ 输入=登记.冻结输入,
+ 角色策略版本=策略.定义["version"],
+ 资源发布身份=策略.资源发布身份,
+ 冻结上下文={
+ "source_scope": 登记.冻结输入,
+ "schema_versions": {},
+ "authorization": "post-adoption-derivation",
+ "budget": {},
+ "stop_conditions": {},
+ },
+ 作品ID=登记.作品ID,
+ )
+
+
+def 登记任务参与者(目录, 服务: 任务服务) -> None:
+ """把同事务任务登记参与者加入 S01 目录;构建装配时调用一次。"""
+ 目录.登记参与者(任务登记参与者(服务))
+
+
__all__ = [
+ "章后任务登记",
+ "任务登记参与者",
+ "组装章后任务请求",
+ "登记任务参与者",
"提供方配置",
"调用计价",
"配置快照",
diff --git a/src/muse/任务运行/模型.py b/src/muse/任务运行/模型.py
index 2ffa353..861da70 100644
--- a/src/muse/任务运行/模型.py
+++ b/src/muse/任务运行/模型.py
@@ -108,6 +108,29 @@ class 任务请求:
raise 任务错误("排他范围必须绑定任务已授权的作品或来源")
+@dataclass(frozen=True, slots=True)
+class 章后任务登记:
+ """S01 采纳事务内登记章后派生任务的具名请求。
+
+ 只携带业务已冻结的事实(作者、作品、来源版本);运行配置由装配注入,
+ 参与者据此组装完整 任务请求,接入层不重建第二套任务语义。
+ """
+
+ 作者: str
+ 命令ID: str
+ 作品ID: str
+ 内容用途: 内容用途
+ 冻结输入: dict[str, Any]
+ 流程ID: str = "章后处理"
+ 流程版本: str = "1"
+
+ def __post_init__(self) -> None:
+ if not self.作者 or not self.命令ID or not self.作品ID:
+ raise 任务错误("章后任务登记缺少作者、命令或作品身份")
+ if not isinstance(self.冻结输入, dict) or not self.冻结输入:
+ raise 任务错误("章后任务登记缺少冻结输入")
+
+
@dataclass(frozen=True, slots=True)
class 步骤计划:
步骤ID: str
diff --git a/src/muse/启动.py b/src/muse/启动.py
index 105de6c..a438e5a 100644
--- a/src/muse/启动.py
+++ b/src/muse/启动.py
@@ -22,6 +22,7 @@ from muse.任务运行.接口 import (
只读工具集,
执行上下文,
模型执行器,
+ 登记任务参与者,
角色策略目录,
证据服务,
调用计价,
@@ -188,6 +189,7 @@ def 构建(配置: 应用配置, *, 时钟: 时钟 | None = None, 流程: 流程
正文 = 正文服务(数据库, 正式)
登记 = 流程 or 流程登记()
任务 = 任务服务(数据库, 登记)
+ 登记任务参与者(目录, 任务)
return 应用装配(
配置=配置,
时钟=时钟 or 真实时钟(),
@@ -203,6 +205,27 @@ def 构建(配置: 应用配置, *, 时钟: 时钟 | None = None, 流程: 流程
)
+def 装配章后处理(
+ 装配: 应用装配,
+ 计价,
+ 模板路径: Path | None = None,
+ *,
+ 抽取配置ID: str | None = None,
+) -> None:
+ """登记并发布章后处理流程;抽取配置由部署装配声明,作者配置仍走验证启用链。"""
+
+ from muse.编排.接口 import 流程服务
+ from muse.编排.章后处理 import 登记章后处理
+ from muse.编排.载入模板 import 载入流程定义
+
+ 登记 = 装配.流程登记
+ if 登记 is None or 装配.任务运行 is None:
+ raise 装配未完成("任务运行与流程登记未注入")
+ 登记章后处理(登记, 装配, 计价, 抽取配置ID=抽取配置ID)
+ 定义 = 载入流程定义(模板路径 or Path("配置/流程模板/章后处理.yaml"))
+ 流程服务(装配.任务运行, 登记).发布(定义)
+
+
def 装配生成正文(装配: 应用装配, 计价, 模板路径: Path | None = None) -> None:
"""登记并发布生成正文流程;计价由调用方注入,模板默认取仓内可审配置。"""
diff --git a/src/muse/正文写作/人工保存.py b/src/muse/正文写作/人工保存.py
index aa8f473..23b7197 100644
--- a/src/muse/正文写作/人工保存.py
+++ b/src/muse/正文写作/人工保存.py
@@ -6,7 +6,7 @@ from muse.作品规划.接口 import 读取章节归属
from muse.正式变更.接口 import 依赖引用, 修改影响, 参与操作, 变更错误, 固定哈希, 审阅内容, 提交计划
from muse.正文写作.存储 import 正文存储
from muse.正文写作.模型 import 人工正文保存, 正文草稿
-from muse.正文写作.正文格式 import 文稿身份
+from muse.正文写作.正文格式 import 文稿身份, 正文结构哈希
@dataclass(frozen=True)
@@ -59,8 +59,32 @@ class 正文保存入口:
def 准备(self, 连, 身份, 命令, 当前):
请求 = 命令.业务请求
草稿 = 解析新稿(正文存储(连), 请求)
+ from muse.任务运行.模型 import 章后任务登记
+ from muse.共享.调用身份 import 内容用途
+
+ 章节 = 读取章节归属(连, 身份.作者, 请求.chapter_id)
return 提交计划(
- (参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),),
+ (
+ 参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),
+ 参与操作(
+ "S02.register_task",
+ 章后任务登记(
+ 作者=身份.作者,
+ 命令ID=f"post-{命令.命令ID}",
+ 作品ID=章节["work_id"],
+ 内容用途=内容用途.抽取,
+ 冻结输入={
+ "章后": {
+ "work_id": 章节["work_id"],
+ "chapter_id": 请求.chapter_id,
+ "branch_id": 请求.branch_id,
+ "revision": 当前.数据版本 + 1,
+ "document_hash": 正文结构哈希(草稿),
+ }
+ },
+ ),
+ ),
+ ),
(修改影响(命令.目标, "document_changed", str(当前.数据版本), str(当前.数据版本 + 1)),),
)
diff --git a/src/muse/正文写作/正式提交.py b/src/muse/正文写作/正式提交.py
index b16a204..1635270 100644
--- a/src/muse/正文写作/正式提交.py
+++ b/src/muse/正文写作/正式提交.py
@@ -67,9 +67,33 @@ class 正文候选决定入口:
if 行["base_revision"] != 当前.数据版本 or 行["base_hash"] != 正文结构哈希(基线):
raise 变更错误("SOURCE_STALE", "候选依据的正文已改变,不能直接覆盖作者新稿")
草稿 = 正文存储.恢复草稿(行)
+ 章节 = 读取章节归属(连, 身份.作者, 行["chapter_id"])
请求 = 人工正文保存(行["chapter_id"], 行["branch_id"], 当前.数据版本, document=草稿)
+ from muse.任务运行.模型 import 章后任务登记
+ from muse.共享.调用身份 import 内容用途
+
return 提交计划(
- (参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),),
+ (
+ 参与操作("B05.write_document", 正文写入(身份.作者, 请求, 草稿)),
+ 参与操作(
+ "S02.register_task",
+ 章后任务登记(
+ 作者=身份.作者,
+ 命令ID=f"post-{命令.命令ID}",
+ 作品ID=章节["work_id"],
+ 内容用途=内容用途.抽取,
+ 冻结输入={
+ "章后": {
+ "work_id": 章节["work_id"],
+ "chapter_id": 行["chapter_id"],
+ "branch_id": 行["branch_id"],
+ "revision": 当前.数据版本 + 1,
+ "document_hash": 正文结构哈希(草稿),
+ }
+ },
+ ),
+ ),
+ ),
(修改影响(当前.目标, "document_changed", str(当前.数据版本), str(当前.数据版本 + 1)),),
)
diff --git a/src/muse/编排/章后处理.py b/src/muse/编排/章后处理.py
new file mode 100644
index 0000000..4fc99ed
--- /dev/null
+++ b/src/muse/编排/章后处理.py
@@ -0,0 +1,433 @@
+"""章后派生旅程:来源复检、抽取配置绑定、模型抽取、事实提案登记与摘要/索引记录。
+
+由 S01 采纳事务经 S02.register_task 同事务登记任务,按冻结的正文版本执行;
+事实提案是待作者确认的 Shadow 候选(确认事实增量技能),派生失败不回滚正文。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+from datetime import UTC, datetime, timedelta
+from decimal import Decimal
+from typing import Any
+from uuid import uuid4
+
+from muse.任务运行.接口 import (
+ 任务快照,
+ 执行上下文,
+ 模型请求,
+ 步骤处理器,
+ 步骤结果,
+ 步骤计划,
+)
+from muse.任务运行.模型调用 import 请求字节
+from muse.任务运行.角色会话 import 组装角色请求
+from muse.任务运行.角色策略 import 角色策略目录
+from muse.任务运行.预算管理 import (
+ 任务预算计划,
+ 角色预算,
+ 预算状态冲突,
+ 预算管理,
+)
+from muse.共享.调用身份 import 内容用途, 调用身份
+from muse.共享.错误 import Muse错误, 配置错误
+from muse.基础设施.数据库.连接 import 数据库工厂
+from muse.故事世界.接口 import 事实提案请求, 定位字面引文, 正文依据
+from muse.正文写作.存储 import 正文存储
+from muse.正文写作.正文格式 import 可见文本, 文稿身份, 正文结构哈希
+from muse.编排.接口 import 流程登记
+from muse.编排.流程版本 import 流程定义
+
+流程身份 = "章后处理"
+流程版本 = "1"
+
+处理器版本集 = {
+ "post.verify": "1",
+ "post.config": "1",
+ "post.extract": "1",
+ "post.facts": "1",
+ "post.record": "1",
+}
+
+# 派生抽取的预算包络:计划一次、安全容量两次、单次金额上限为部署常数;超限失败即停。
+_抽取角色预算 = 角色预算("extractor", 2, 2, Decimal("2"))
+_抽取预算总金额 = Decimal("4")
+_抽取预算批准引用 = "post-derivation-envelope-1"
+
+
+class 章后处理错误(Muse错误):
+ 错误码 = "POST_FLOW_INVALID"
+
+
+_抽取输出合同 = {
+ "type": "object",
+ "properties": {
+ "summary": {"type": "string", "minLength": 1},
+ "facts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {"type": "string", "minLength": 1},
+ "object_id": {"type": "string", "minLength": 1},
+ "quote": {"type": "string", "minLength": 1},
+ "content": {"type": "object"},
+ },
+ "required": ["type", "object_id", "quote", "content"],
+ "additionalProperties": False,
+ },
+ },
+ },
+ "required": ["summary", "facts"],
+ "additionalProperties": False,
+}
+
+
+def _工厂(装配) -> 数据库工厂:
+ return 数据库工厂(装配.配置.数据库, 装配.配置.运行用途)
+
+
+def _冻结来源(任务: 任务快照) -> dict[str, Any]:
+ 来源 = (任务.冻结输入.get("输入") or {}).get("章后") or {}
+ for 名 in ("work_id", "chapter_id", "branch_id", "revision", "document_hash"):
+ if 名 not in 来源:
+ raise 章后处理错误(f"章后冻结输入缺少 {名}")
+ return 来源
+
+
+def _来源复检(装配, 来源: dict[str, Any]) -> dict[str, Any]:
+ """冻结版本与当前正文一致才放行;不一致即旧结果不能适用。"""
+
+ with _工厂(装配).连接(只读=True) as 连:
+ 存储 = 正文存储(连)
+ 文档ID = 文稿身份(来源["chapter_id"], 来源["branch_id"])
+ 行 = 存储.读取(文档ID)
+ if 行 is None:
+ raise 章后处理错误(f"目标正文不存在:{来源['chapter_id']}")
+ if int(行["revision"]) != int(来源["revision"]) or 行["document_hash"] != 来源["document_hash"]:
+ raise 章后处理错误(
+ f"章后来源已变化(当前 r{行['revision']}),旧版本派生结果不能适用;按新版本重建"
+ )
+ return {"行": 行, "文档ID": 文档ID, "存储": 存储}
+
+
+def 登记章后处理(登记: 流程登记, 装配, 计价, *, 抽取配置ID: str | None = None) -> None:
+ """登记章后流程处理器、类型与恢复检查;抽取配置由部署装配声明。"""
+
+ 世界 = 装配.要求故事世界()
+
+ 登记.登记类型(流程身份, 必需保护=())
+
+ def 恢复复检(任务: 任务快照) -> None:
+ _来源复检(装配, _冻结来源(任务))
+
+ 登记.登记恢复检查(流程身份, 流程版本, 恢复复检)
+
+ def 来源复检(上下文: 执行上下文) -> 步骤结果:
+ 来源 = _冻结来源(上下文.任务)
+ _来源复检(装配, 来源)
+ return 步骤结果(
+ 输出={"chapter_id": 来源["chapter_id"], "revision": 来源["revision"]},
+ 检查点={"来源": 来源},
+ )
+
+ def 绑定抽取配置(上下文: 执行上下文) -> 步骤结果:
+ 任务 = 上下文.任务
+ 配置ID = (任务.冻结输入.get("输入") or {}).get("配置ID") or 抽取配置ID
+ if not 配置ID:
+ raise 配置错误("章后抽取未绑定运行配置;重建时经发起入口明确指定")
+ from muse.任务运行.配置版本 import 配置版本管理
+
+ 工厂 = _工厂(装配)
+ 快照 = 配置版本管理(工厂).冻结到任务(任务.任务ID, 配置ID)
+ 预算 = 预算管理(工厂, 快照.内容.预算策略引用)
+ try:
+ 预算.登记任务预算(
+ 任务.任务ID,
+ 任务预算计划(
+ _抽取预算总金额,
+ (_抽取角色预算,),
+ _抽取预算批准引用,
+ datetime.now(UTC) + timedelta(hours=6),
+ ),
+ )
+ except 预算状态冲突:
+ pass # 恢复重试时包络已登记且哈希一致;不一致会被上一步骤拒绝。
+ return 步骤结果(
+ 输出={"配置ID": 配置ID},
+ 检查点={"配置ID": 配置ID, "预算账户": 快照.内容.预算策略引用},
+ )
+
+ def 抽取提案(上下文: 执行上下文) -> 步骤结果:
+ 任务 = 上下文.任务
+ 来源 = _冻结来源(任务)
+ with _工厂(装配).连接(只读=True) as 连:
+ 草稿 = 正文存储(连).读取草稿(
+ 文稿身份(来源["chapter_id"], 来源["branch_id"]), int(来源["revision"])
+ )
+ from muse.任务运行.配置版本 import 配置版本管理
+
+ 声明 = 配置版本管理(_工厂(装配)).读取任务绑定(任务.任务ID).内容.角色配置["extractor"]
+ 请求 = 模型请求(
+ 调用ID=str(uuid4()),
+ provider=声明["provider"],
+ model=声明["model"],
+ thinking=声明.get("thinking"),
+ 系统提示=(
+ "你是本作品的抽取员,对刚定稿的一章做章后抽取:"
+ "给出续写所需的本章摘要与候选事实;每条事实必须附正文原句引文,"
+ "不新增正文没有的设定;输出一个 JSON 对象。"
+ ),
+ 用户输入=json.dumps(
+ {
+ "chapter_id": 来源["chapter_id"],
+ "revision": 来源["revision"],
+ "正文": 可见文本(草稿),
+ },
+ ensure_ascii=False,
+ ),
+ 输出合同=_抽取输出合同,
+ 最大输出token=8_000,
+ 总期限秒=600.0,
+ )
+ 请求 = 组装角色请求(角色策略目录.从发布包(), "extractor", 请求)
+ 执行器 = 装配.要求模型执行器(上下文, 计价)
+ 请求字节哈希 = hashlib.sha256(请求字节(请求)).hexdigest()
+ 授权 = 装配.要求原文().批准保留(
+ 任务.任务ID,
+ 任务.作者,
+ "raw-" + 请求.调用ID,
+ 来源版本=f"{来源['chapter_id']}@r{来源['revision']}",
+ 哈希=(请求字节哈希,),
+ 内容用途="extraction",
+ 方式="persistent",
+ 有效期=datetime.now(UTC) + timedelta(minutes=30),
+ 调用ID=请求.调用ID,
+ 调用请求哈希=请求字节哈希,
+ )
+ 交付 = asyncio.run(执行器.执行(上下文, 请求, 阶段="抽取", 原文授权ID=授权))
+ return 步骤结果(
+ 输出={"摘要字数": len(交付.内容["summary"]), "事实数": len(交付.内容["facts"])},
+ 检查点={"抽取": 交付.内容, "调用ID": 交付.调用ID, "证据回执": 交付.证据回执},
+ )
+
+ def 登记事实提案(上下文: 执行上下文) -> 步骤结果:
+ 任务 = 上下文.任务
+ 来源 = _冻结来源(任务)
+ 抽取点 = 前步检查点(上下文, "抽取提案")["抽取"]
+ with _工厂(装配).连接(只读=True) as 连:
+ 草稿 = 正文存储(连).读取草稿(
+ 文稿身份(来源["chapter_id"], 来源["branch_id"]), int(来源["revision"])
+ )
+ 用途值 = 任务.冻结输入.get("内容用途", 内容用途.抽取.value)
+ 身份 = 调用身份(任务.作者, None, 装配.配置.运行用途, 内容用途(用途值))
+ 登记: list[dict[str, Any]] = []
+ 跳过: list[dict[str, Any]] = []
+ for 项 in 抽取点.get("facts") or []:
+ try:
+ 结构选择 = 世界.新事实结构(身份, 来源["work_id"], 项["object_id"], 项["type"], 1)
+ 引文 = 定位字面引文(草稿, 项["quote"])
+ except Muse错误 as 错误:
+ 跳过.append({"object_id": 项.get("object_id"), "原因": 错误.错误码})
+ continue
+ 依据 = 正文依据(
+ 来源["chapter_id"],
+ 来源["branch_id"],
+ int(来源["revision"]),
+ 来源["document_hash"],
+ (引文,),
+ )
+ # 命令身份绑定(章, 版本, 内容哈希, 对象):同任务重试与跨任务重建都不重复提案。
+ 命令ID = (
+ "postfact-"
+ + 确定性哈希(
+ [来源["chapter_id"], 来源["revision"], 来源["document_hash"], 项["object_id"]]
+ )[:16]
+ )
+ 回执 = 世界.提出事实(
+ 身份,
+ 命令ID,
+ 事实提案请求(
+ 来源["work_id"],
+ 项["object_id"],
+ 0,
+ 结构选择,
+ 项.get("content") or {},
+ (依据,),
+ 来源["chapter_id"],
+ ),
+ )
+ 登记.append(
+ {
+ "proposal_id": 回执["results"][0]["proposal_id"],
+ "object_id": 项["object_id"],
+ "命令ID": 命令ID,
+ }
+ )
+ return 步骤结果(
+ 输出={"提案数": len(登记), "跳过数": len(跳过)},
+ 检查点={"提案": 登记, "跳过": 跳过},
+ )
+
+ def 登记摘要与索引(上下文: 执行上下文) -> 步骤结果:
+ 来源 = _冻结来源(上下文.任务)
+ 抽取点 = 前步检查点(上下文, "抽取提案")["抽取"]
+ 索引 = {
+ "document_id": str(文稿身份(来源["chapter_id"], 来源["branch_id"])),
+ "revision": int(来源["revision"]),
+ "document_hash": 来源["document_hash"],
+ "结构哈希": _结构哈希(装配, 来源),
+ "说明": "检索索引建立属 W20 索引维护;此处只登记派生绑定",
+ }
+ return 步骤结果(
+ 输出={"摘要字数": len(抽取点["summary"])},
+ 检查点={"摘要": 抽取点["summary"], "索引": 索引},
+ )
+
+ 登记.登记处理器(步骤处理器("post.verify", "1", 来源复检, "post-source", "post-source"))
+ 登记.登记处理器(步骤处理器("post.config", "1", 绑定抽取配置, "post-source", "post-config"))
+ 登记.登记处理器(
+ 步骤处理器(
+ "post.extract",
+ "1",
+ 抽取提案,
+ "post-config",
+ "post-extraction",
+ 角色="extractor",
+ )
+ )
+ 登记.登记处理器(
+ 步骤处理器("post.facts", "1", 登记事实提案, "post-extraction", "post-proposals")
+ )
+ 登记.登记处理器(
+ 步骤处理器("post.record", "1", 登记摘要与索引, "post-extraction", "post-summary")
+ )
+
+
+def _结构哈希(装配, 来源: dict[str, Any]) -> str:
+ with _工厂(装配).连接(只读=True) as 连:
+ 草稿 = 正文存储(连).读取草稿(
+ 文稿身份(来源["chapter_id"], 来源["branch_id"]), int(来源["revision"])
+ )
+ return 正文结构哈希(草稿)
+
+
+def 前步检查点(上下文: 执行上下文, 步骤ID: str) -> dict[str, Any]:
+ for 项 in 上下文.任务.步骤:
+ if 项.get("step_id") == 步骤ID:
+ 检查点 = 项.get("checkpoint") or {}
+ if not 检查点:
+ raise 章后处理错误(f"前置步骤 {步骤ID} 没有可用检查点")
+ return 检查点
+ raise 章后处理错误(f"前置步骤 {步骤ID} 尚未执行")
+
+
+def 确定性哈希(载荷: list[Any]) -> str:
+ from muse.正式变更.接口 import 固定哈希
+
+ return 固定哈希(载荷)
+
+
+def 章后处理步骤() -> tuple[步骤计划, ...]:
+ return (
+ 步骤计划("来源复检", "post.verify", "1"),
+ 步骤计划("绑定抽取配置", "post.config", "1", 依赖=("来源复检",)),
+ 步骤计划("抽取提案", "post.extract", "1", 依赖=("绑定抽取配置",), 角色="extractor"),
+ 步骤计划("登记事实提案", "post.facts", "1", 依赖=("抽取提案",)),
+ 步骤计划("登记摘要与索引", "post.record", "1", 依赖=("抽取提案",)),
+ )
+
+
+def 流程定义_章后处理() -> 流程定义:
+ return 流程定义(流程身份, 流程版本, 章后处理步骤())
+
+
+def 发起章后处理(
+ 装配,
+ 身份,
+ 命令ID: str,
+ *,
+ work_id: str,
+ chapter_id: str,
+ 分支: str = "main",
+ 配置ID: str | None = None,
+) -> dict[str, str]:
+ """按当前正文版本登记章后派生任务;采纳同事务自动登记之外的重试/重建入口。"""
+
+ from muse.任务运行.接口 import 组装章后任务请求
+ from muse.任务运行.模型 import 章后任务登记
+ from muse.任务运行.配置版本 import 配置版本管理
+
+ with _工厂(装配).连接(只读=True) as 连:
+ 行 = 正文存储(连).读取(文稿身份(chapter_id, 分支))
+ if 行 is None:
+ raise 章后处理错误(f"目标正文不存在:{chapter_id}")
+ 来源 = {
+ "work_id": work_id,
+ "chapter_id": chapter_id,
+ "branch_id": 分支,
+ "revision": int(行["revision"]),
+ "document_hash": 行["document_hash"],
+ }
+ 服务 = 装配.任务运行
+ 请求 = 组装章后任务请求(
+ 服务,
+ 章后任务登记(
+ 作者=身份.作者,
+ 命令ID=命令ID,
+ 作品ID=work_id,
+ 内容用途=内容用途.抽取,
+ 冻结输入={"章后": 来源, "配置ID": 配置ID},
+ ),
+ )
+ 任务ID = 服务.创建任务(请求, 流程身份, 流程版本)
+ if 配置ID:
+ 配置版本管理(_工厂(装配)).冻结到任务(任务ID, 配置ID)
+ return {"task_id": 任务ID}
+
+
+def 查询章后状态(装配, 作者: str, chapter_id: str, *, 分支: str = "main") -> dict[str, Any] | None:
+ """返回该章最近一次章后派生状态;无派生时返回 None。供进度查询与确认事实增量使用。"""
+
+ 服务 = 装配.任务运行
+ 最佳: 任务快照 | None = None
+ for 快照 in 服务.列出任务(作者):
+ if 快照.流程.流程ID != 流程身份:
+ continue
+ 来源 = (快照.冻结输入.get("输入") or {}).get("章后") or {}
+ if 来源.get("chapter_id") != chapter_id or 来源.get("branch_id") != 分支:
+ continue
+ 最佳 = 快照 # 列出按创建时间倒序;首个命中即最近一次派生。
+ break
+ if 最佳 is None:
+ return None
+ 摘要 = 事实 = 索引 = None
+ for 项 in 最佳.步骤:
+ 检查点 = 项.get("checkpoint") or {}
+ if 项["step_id"] == "登记摘要与索引" and 检查点:
+ 摘要, 索引 = 检查点.get("摘要"), 检查点.get("索引")
+ if 项["step_id"] == "登记事实提案" and 检查点:
+ 事实 = 检查点.get("提案")
+ return {
+ "task_id": 最佳.任务ID,
+ "状态": 最佳.状态.value,
+ "revision": (最佳.冻结输入.get("输入") or {}).get("章后", {}).get("revision"),
+ "摘要": 摘要,
+ "事实提案": 事实,
+ "索引": 索引,
+ }
+
+
+__all__ = [
+ "登记章后处理",
+ "发起章后处理",
+ "查询章后状态",
+ "流程定义_章后处理",
+ "章后处理步骤",
+ "章后处理错误",
+ "流程身份",
+ "流程版本",
+ "处理器版本集",
+]
diff --git a/tests/conftest.py b/tests/conftest.py
index bd15122..a38bad0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,7 @@ import json
import os
import socket
import sys
+from decimal import Decimal
from pathlib import Path
from typing import Any
@@ -309,3 +310,242 @@ def 应用测试库(隔离数据库URL: str, tmp_path: Path):
finally:
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理:
管理.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(库名)))
+
+
+# ---------------------------------------------------------------- W16 章后派生共享环境
+
+
+class 合成派生计价:
+ 版本 = "synthetic-price-1"
+
+ def 金额(self, 结果):
+ return Decimal("0.125") if 结果.用量 else None
+
+
+class _离线派生配置检查:
+ 身份 = "synthetic-derivation-validation"
+
+ def 验证(self, 内容, 执行用途):
+ from muse.任务运行.模型 import 内容哈希
+ from muse.任务运行.配置版本 import 配置验证证据
+
+ return 配置验证证据(
+ 内容哈希(内容.冻结()),
+ 内容.角色策略版本,
+ 内容.资源发布身份,
+ 执行用途,
+ ("synthetic-derivation-contract",),
+ "runtime",
+ )
+
+
+@pytest.fixture
+def 章后环境(应用测试库, tmp_path):
+ """作品、章后流程、抽取配置与合成宿主一次装配;抽取剧本由用例注入。"""
+
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from threading import Thread
+
+ from muse.任务运行.角色策略 import 角色策略目录
+ from muse.任务运行.配置版本 import (
+ 凭据引用,
+ 提供方配置,
+ 运行配置内容,
+ 配置版本管理,
+ )
+ from muse.任务运行.预算管理 import 预算管理, 额度策略
+ from muse.作品规划.接口 import 档案保存
+ from muse.元数据.接口 import 元数据服务, 导入内置结构
+ from muse.共享.调用身份 import 内容用途, 用途, 调用身份
+ from muse.启动 import 构建, 装配章后处理
+ from muse.配置 import 应用配置
+
+ 库 = 应用测试库[用途.生产]
+ with 应用测试库[用途.维护].连接() as 连, 连.transaction():
+ 导入内置结构(元数据服务(连))
+ 策略 = 角色策略目录.从发布包()
+ 装配 = 构建(
+ 应用配置(
+ 库.引用,
+ 策略.资源发布身份,
+ 运行用途=库.用途,
+ 原文暂存=str(tmp_path / "raw"),
+ )
+ )
+ 装配章后处理(装配, 合成派生计价(), 抽取配置ID="post-config")
+
+ 收到: list[dict] = []
+ 剧本: list = []
+
+ def 回应(数据):
+ 收到.append(数据)
+ 动作 = 剧本.pop(0) if 剧本 else {"类型": "空对象"}
+ if 动作.get("类型") == "失败":
+ raise RuntimeError("合成宿主按剧本失败")
+ 输出 = [
+ {
+ "type": "message",
+ "content": [
+ {
+ "type": "output_text",
+ "text": json.dumps(动作.get("文本", {}), ensure_ascii=False),
+ }
+ ],
+ }
+ ]
+ 载荷 = {
+ "type": "response.completed",
+ "response": {
+ "id": f"post-response-{len(收到)}",
+ "model": "claude-opus-4-8[1M]",
+ "status": "completed",
+ "output": 输出,
+ "usage": {"input_tokens": 5, "output_tokens": 7},
+ },
+ }
+ return ("data: " + json.dumps(载荷, ensure_ascii=False) + "\n\n").encode()
+
+ class 请求处理(BaseHTTPRequestHandler):
+ def do_POST(self):
+ try:
+ json.loads(self.rfile.read(int(self.headers["Content-Length"])))
+ 输出 = 回应({})
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Content-Length", str(len(输出)))
+ self.end_headers()
+ self.wfile.write(输出)
+ except Exception:
+ self.send_error(500)
+
+ def log_message(self, *args):
+ pass
+
+ from muse.编排.生成正文 import 处理器版本集 as 生成处理器集
+ from muse.编排.章后处理 import 处理器版本集 as 章后处理器集
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), 请求处理)
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ 凭据 = tmp_path / "post-key"
+ 凭据.write_text("synthetic-derivation-credential")
+ 原配置 = 运行配置内容(
+ "direct",
+ "1",
+ 策略.定义["version"],
+ 策略.资源发布身份,
+ "synthetic",
+ {
+ "extractor": {
+ "provider": "synthetic",
+ "model": "claude-opus-4-8[1M]",
+ "thinking": "high",
+ "stage_tools": {"抽取": []},
+ }
+ },
+ (凭据引用("provider-key", "受控存储", str(凭据)),),
+ (
+ 提供方配置(
+ "synthetic",
+ "responses",
+ f"http://127.0.0.1:{server.server_port}/v1/responses",
+ "provider-key",
+ ),
+ ),
+ 合成派生计价().版本,
+ )
+ 管理 = 配置版本管理(库, _离线派生配置检查())
+ 管理.保存草案("post-config", "1", 原配置)
+ 启用回执 = 管理.验证版本("post-config", "1")
+ 管理.启用("post-config", "1", 验证回执=启用回执, 批准引用="synthetic-approval", 预期代次=0)
+ 预算 = 预算管理(库, "synthetic")
+ 预算.登记策略(额度策略("synthetic", "1", Decimal("20"), 40))
+
+ 作者 = 调用身份("post-author", None, 库.用途, 内容用途.抽取)
+ 作品 = 装配.要求作品()
+
+ def 新作品(work_id: str, 章数: int = 2, 章前缀: str = "ch-") -> None:
+ 结构选择 = 作品.新档案结构(作者, work_id, "work_core", 1)
+ 作品.保存档案(
+ 作者,
+ f"create-{work_id}",
+ 档案保存(
+ work_id,
+ 0,
+ {"名称": work_id, "题材定位": "现实", "主题立意": "守约"},
+ 结构选择,
+ ),
+ )
+ for i in range(1, 章数 + 1):
+ 作品.添加章节(
+ 作者,
+ f"add-{work_id}-{章前缀}{i}",
+ work_id,
+ f"{章前缀}{i}",
+ f"第{i}章",
+ 预期目录版本=i - 1,
+ )
+
+ 新作品("post-work", 3)
+
+ 环境 = {
+ "库": 库,
+ "装配": 装配,
+ "作者": 作者,
+ "剧本": 剧本,
+ "收到": 收到,
+ "新作品": 新作品,
+ "处理器能力": sorted(set(生成处理器集) | set(章后处理器集)),
+ }
+ yield 环境
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+@pytest.fixture
+def 章后工具(章后环境):
+ """章后旅程的具名操作:保存一章、驱动任务、构造抽取剧本。"""
+
+ from types import SimpleNamespace
+ from uuid import uuid4
+
+ from muse.任务运行.接口 import 任务状态
+ from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
+
+ 环境 = 章后环境
+
+ def 保存一章(章: str, 文本: str, 命令: str | None = None, 预期版本: int = 0):
+ 回执 = (
+ 环境["装配"]
+ .要求正文()
+ .保存人工(
+ 环境["作者"],
+ 命令 or f"save-{章}-{uuid4().hex[:6]}",
+ 章,
+ 预期版本,
+ 正文草稿((段落(f"{章}-p1", (文本节点(文本),)),)),
+ )
+ )
+ 来源 = next(
+ (r for r in 回执["results"] if isinstance(r, dict) and r.get("章后任务")),
+ None,
+ )
+ assert 来源 is not None, f"章后任务未登记:{回执['results']}"
+ return 来源["章后任务"], 回执
+
+ def 跑任务(任务ID: str):
+ 运行 = 环境["装配"].任务运行
+ while True:
+ 快照 = 运行.读取任务(任务ID)
+ if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
+ return 快照
+ 领取 = 运行.领取步骤("worker", 环境["处理器能力"])
+ assert 领取 is not None, "任务未完成却没有可领取步骤"
+ 运行.执行一步(领取)
+
+ def 抽取输出(摘要: str, 事实: list[dict]) -> dict:
+ return {"类型": "文本", "文本": {"summary": 摘要, "facts": 事实}}
+
+ return SimpleNamespace(保存一章=保存一章, 跑任务=跑任务, 抽取输出=抽取输出, 环境=环境)
diff --git a/tests/集成/test_作品与人工正文.py b/tests/集成/test_作品与人工正文.py
index 708eab8..51bde0e 100644
--- a/tests/集成/test_作品与人工正文.py
+++ b/tests/集成/test_作品与人工正文.py
@@ -16,6 +16,10 @@ def 作品环境(应用测试库):
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
导入内置结构(元数据服务(连))
目录 = 参与者目录()
+ from muse.任务运行.接口 import 任务服务, 登记任务参与者
+ from muse.编排.接口 import 流程登记
+
+ 登记任务参与者(目录, 任务服务(应用测试库[用途.生产], 流程登记()))
登记作品参与者(目录)
正式 = 正式变更服务(应用测试库[用途.生产], 目录)
服务 = 作品服务(应用测试库[用途.生产], 正式)
diff --git a/tests/集成/test_候选状态与并发.py b/tests/集成/test_候选状态与并发.py
new file mode 100644
index 0000000..4b17329
--- /dev/null
+++ b/tests/集成/test_候选状态与并发.py
@@ -0,0 +1,142 @@
+"""候选状态与并发:CAS 定位、编辑后旧候选拒绝、并发采纳唯一成功。"""
+
+import pytest
+from pydantic import TypeAdapter
+
+from muse.共享.错误 import Muse错误
+from muse.正式变更.接口 import 作者动作
+from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
+
+pytestmark = pytest.mark.数据库
+
+
+def _建候选(环境, 命令: str, 文本: str, 预期版本: int = 0):
+ 作者 = 环境["作者"]
+ 候选 = (
+ 环境["装配"]
+ .要求正文()
+ .创建人工候选(
+ 作者,
+ 命令,
+ "ch-1",
+ 预期版本,
+ 正文草稿((段落("p1", (文本节点(文本),)),)),
+ )
+ )
+ 候选ID = next(
+ r["candidate_id"] for r in 候选["results"] if isinstance(r, dict) and "candidate_id" in r
+ )
+ 候选行 = 环境["装配"].要求正文().读取候选(作者, 候选ID)
+ from muse.正文写作.接口 import 候选定位
+
+ 定位 = 候选定位(
+ 候选行["candidate_id"],
+ 候选行["revision"],
+ 候选行["candidate_hash"],
+ 候选行["document_id"],
+ 候选行["current_document_revision"],
+ )
+ 审阅 = 环境["装配"].要求正文().打开候选审阅(作者, 定位)
+ return 定位, 审阅
+
+
+def test_候选哈希不匹配采纳拒绝__16d013(章后环境, 章后工具):
+ 环境 = 章后环境
+ from muse.正文写作.接口 import 候选定位
+
+ 定位, 审阅 = _建候选(环境, "cas-cand-1", "林深在码头。")
+ 坏定位 = 候选定位(
+ 定位.candidate_id,
+ 定位.candidate_revision,
+ "0" * 64,
+ 定位.target_ref,
+ 定位.expected_revision,
+ )
+ with pytest.raises(Muse错误) as 异常:
+ 环境["装配"].要求正文().决定候选(
+ 环境["作者"],
+ "cas-adopt-bad",
+ 坏定位,
+ 作者动作.采纳,
+ 审阅["review_id"],
+ 审阅["review_hash"],
+ ("document",),
+ )
+ assert 异常.value.错误码 in {"REVISION_CONFLICT", "SCOPE_DENIED"}
+
+
+def test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024(章后环境, 章后工具):
+ 环境 = 章后环境
+ 定位, 审阅 = _建候选(环境, "stale-cand-1", "候选版本的正文。")
+ # 正文前进一格:旧候选基线陈旧。
+ 环境["剧本"].append(章后工具.抽取输出("编辑后的摘要。", []))
+ 章后工具.保存一章("ch-1", "作者直接改过的新正文。")
+ with pytest.raises(Muse错误) as 异常:
+ 环境["装配"].要求正文().决定候选(
+ 环境["作者"],
+ "stale-adopt-1",
+ 定位,
+ 作者动作.采纳,
+ 审阅["review_id"],
+ 审阅["review_hash"],
+ ("document",),
+ )
+ assert 异常.value.错误码 in {"SOURCE_STALE", "REVISION_CONFLICT"}
+ # 陈旧候选仍可明确弃用:作者端对当前版本重开审阅后拒绝。
+ from muse.正文写作.接口 import 候选定位
+
+ 当前行 = 环境["装配"].要求正文().读取候选(环境["作者"], 定位.candidate_id)
+ 重开定位 = 候选定位(
+ 当前行["candidate_id"],
+ 当前行["revision"],
+ 当前行["candidate_hash"],
+ 当前行["document_id"],
+ 当前行["current_document_revision"],
+ )
+ 重开审阅 = 环境["装配"].要求正文().打开候选审阅(环境["作者"], 重开定位)
+ 环境["装配"].要求正文().决定候选(
+ 环境["作者"],
+ "stale-reject-1",
+ 重开定位,
+ 作者动作.拒绝,
+ 重开审阅["review_id"],
+ 重开审阅["review_hash"],
+ (),
+ )
+ 行 = 环境["装配"].要求正文().读取候选(环境["作者"], 定位.candidate_id)
+ assert 行["decision"] == "reject"
+ # 作者正文不受影响。
+ 正文 = 环境["装配"].要求正文().读取正文(环境["作者"], "ch-1")
+ assert TypeAdapter(正文草稿).validate_python(正文["document"])
+
+
+def test_并发采纳唯一成功且正文版本唯一__16d035(章后环境, 章后工具):
+ 环境 = 章后环境
+ 正文服务 = 环境["装配"].要求正文()
+ 定位1, 审阅1 = _建候选(环境, "conc-cand-1", "并发候选甲。")
+ 定位2, 审阅2 = _建候选(环境, "conc-cand-2", "并发候选乙。")
+ 环境["剧本"].append(章后工具.抽取输出("并发摘要一。", []))
+ 回执1 = 正文服务.决定候选(
+ 环境["作者"],
+ "conc-adopt-1",
+ 定位1,
+ 作者动作.采纳,
+ 审阅1["review_id"],
+ 审阅1["review_hash"],
+ ("document",),
+ )
+ # 第二个候选基于同一旧版本:采纳时基线已前进,必须被拒绝。
+ with pytest.raises(Muse错误) as 异常:
+ 正文服务.决定候选(
+ 环境["作者"],
+ "conc-adopt-2",
+ 定位2,
+ 作者动作.采纳,
+ 审阅2["review_id"],
+ 审阅2["review_hash"],
+ ("document",),
+ )
+ assert 异常.value.错误码 in {"SOURCE_STALE", "REVISION_CONFLICT"}
+ 正文 = 正文服务.读取正文(环境["作者"], "ch-1")
+ assert 正文["revision"] == 1
+ assert 回执1["receipt_id"]
diff --git a/tests/集成/test_幂等与审阅版本.py b/tests/集成/test_幂等与审阅版本.py
new file mode 100644
index 0000000..8a782e0
--- /dev/null
+++ b/tests/集成/test_幂等与审阅版本.py
@@ -0,0 +1,52 @@
+"""命令幂等与审阅版本:同命令重放同回执、章后任务不重复、异参数拒绝。"""
+
+import pytest
+
+from muse.共享.错误 import Muse错误
+from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
+
+pytestmark = pytest.mark.数据库
+
+
+def test_同命令重放同回执且章后任务不重复__16b011(章后环境, 章后工具):
+ 环境 = 章后环境
+ 环境["剧本"].append(章后工具.抽取输出("幂等摘要。", []))
+ 任务1, 回执1 = 章后工具.保存一章("ch-1", "林深在码头。雨停了。", 命令="idem-save-1")
+ 章后工具.跑任务(任务1)
+
+ # 完全相同的命令重放:返回既有回执,不再创建第二个章后任务。
+ 回执2 = (
+ 环境["装配"]
+ .要求正文()
+ .保存人工(
+ 环境["作者"],
+ "idem-save-1",
+ "ch-1",
+ 0,
+ 正文草稿((段落("ch-1-p1", (文本节点("林深在码头。雨停了。"),)),)),
+ )
+ )
+ assert 回执2["receipt_id"] == 回执1["receipt_id"]
+ 任务集 = {
+ s.任务ID
+ for s in 环境["装配"].任务运行.列出任务(环境["作者"].作者)
+ if s.流程.流程ID == "章后处理"
+ }
+ assert len(任务集) == 1
+
+
+def test_同命令异参数拒绝__16b022(章后环境, 章后工具):
+ 环境 = 章后环境
+ from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
+
+ 环境["剧本"].append(章后工具.抽取输出("首次摘要。", []))
+ 章后工具.保存一章("ch-1", "第一版内容。", 命令="idem-save-x")
+ with pytest.raises(Muse错误) as 异常:
+ 环境["装配"].要求正文().保存人工(
+ 环境["作者"],
+ "idem-save-x",
+ "ch-1",
+ 0,
+ 正文草稿((段落("ch-1-p1", (文本节点("不同的内容。"),)),)),
+ )
+ assert 异常.value.错误码 == "COMMAND_PAYLOAD_MISMATCH"
diff --git a/tests/集成/test_正式提交原子性.py b/tests/集成/test_正式提交原子性.py
new file mode 100644
index 0000000..89b5e72
--- /dev/null
+++ b/tests/集成/test_正式提交原子性.py
@@ -0,0 +1,97 @@
+"""正式提交原子性:章后派生登记与正文写入同事务,失败一起回滚。"""
+
+import pytest
+
+from muse.任务运行.角色策略 import 角色策略目录
+from muse.共享.错误 import Muse错误
+from muse.启动 import 构建
+from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
+from muse.配置 import 应用配置
+
+pytestmark = pytest.mark.数据库
+
+
+def test_章后登记失败回滚整个正文提交__16a01e(章后环境, 章后工具):
+ 环境 = 章后环境
+ # 用一个未装配章后处理器的同名装配发起保存:流程计划已在库,参与者创建任务时
+ # 处理器合同核对失败,整个 S01 事务回滚——正文不能只写一半。
+ 孤儿装配 = 构建(
+ 应用配置(
+ 环境["库"].引用,
+ 角色策略目录.从发布包().资源发布身份,
+ 运行用途=环境["库"].用途,
+ 原文暂存=None,
+ )
+ )
+ with pytest.raises((Muse错误, RuntimeError, OSError)):
+ 孤儿装配.要求正文().保存人工(
+ 环境["作者"],
+ "atomic-rollback-1",
+ "ch-1",
+ 0,
+ 正文草稿((段落("p1", (文本节点("不会被保存的一章。"),)),)),
+ )
+ # 正文未写入、命令未占用、章后任务未登记:三者同生共死。
+ with 环境["库"].连接(只读=True) as 连:
+ 占用 = 连.execute(
+ "SELECT count(*) FROM muse_change_command WHERE command_id=%s",
+ ("atomic-rollback-1",),
+ ).fetchone()[0]
+ assert 占用 == 0
+ assert 孤儿装配.任务运行.列出任务(环境["作者"].作者) == []
+
+ # 正常装配下保存成功:正文与章后任务同事务落地,回执同时呈现两者。
+ 环境["剧本"].append(章后工具.抽取输出("原子性摘要。", []))
+ 任务1, 回执 = 章后工具.保存一章("ch-1", "林深在码头。", 命令="atomic-ok-1")
+ 登记结果 = [r.get("章后任务") for r in 回执["results"] if isinstance(r, dict)]
+ assert 任务1 in 登记结果
+ 正文 = 环境["装配"].要求正文().读取正文(环境["作者"], "ch-1")
+ assert 正文["revision"] == 1
+
+
+def test_采纳候选与派生登记原子可见__16a02f(章后环境, 章后工具):
+ 环境 = 章后环境
+ from muse.正式变更.接口 import 作者动作
+ from muse.正文写作.接口 import 候选定位
+
+ 作者 = 环境["作者"]
+ 候选 = (
+ 环境["装配"]
+ .要求正文()
+ .创建人工候选(
+ 作者,
+ "atomic-cand-1",
+ "ch-1",
+ 0,
+ 正文草稿((段落("p1", (文本节点("林深在码头整理行装。"),)),)),
+ )
+ )
+ 候选ID = next(
+ r["candidate_id"] for r in 候选["results"] if isinstance(r, dict) and "candidate_id" in r
+ )
+ 候选行 = 环境["装配"].要求正文().读取候选(作者, 候选ID)
+ 定位 = 候选定位(
+ 候选行["candidate_id"],
+ 候选行["revision"],
+ 候选行["candidate_hash"],
+ 候选行["document_id"],
+ 候选行["current_document_revision"],
+ )
+ 审阅 = 环境["装配"].要求正文().打开候选审阅(作者, 定位)
+ 回执 = (
+ 环境["装配"]
+ .要求正文()
+ .决定候选(
+ 作者,
+ "atomic-adopt-1",
+ 定位,
+ 作者动作.采纳,
+ 审阅["review_id"],
+ 审阅["review_hash"],
+ ("document",),
+ )
+ )
+ 登记结果 = [r.get("章后任务") for r in 回执["results"] if isinstance(r, dict)]
+ assert any(登记结果), 回执["results"]
+ 正文 = 环境["装配"].要求正文().读取正文(作者, "ch-1")
+ assert 正文["revision"] == 1
diff --git a/tests/集成/test_章后失败与重建.py b/tests/集成/test_章后失败与重建.py
new file mode 100644
index 0000000..b958b72
--- /dev/null
+++ b/tests/集成/test_章后失败与重建.py
@@ -0,0 +1,156 @@
+"""章后派生失败与重建:两作品连续两章、抽取失败恢复、旧版本派生拒绝与重建幂等。"""
+
+import pytest
+from pydantic import TypeAdapter
+
+from muse.共享.错误 import Muse错误
+from muse.故事世界.接口 import 事实定位
+from muse.正式变更.接口 import 作者动作
+from muse.正文写作.接口 import 正文草稿
+
+pytestmark = pytest.mark.数据库
+
+
+def _事实(引文: str, object_id: str = "lin-shen") -> list[dict]:
+ return [
+ {
+ "type": "character",
+ "object_id": object_id,
+ "quote": 引文,
+ "content": {"名称": "林深"},
+ }
+ ]
+
+
+def _确认提案(环境, 提案ID: str, object_id: str) -> None:
+ 世界 = 环境["装配"].要求故事世界()
+ 作者 = 环境["作者"]
+ 提案 = 世界.读取提案(作者, 提案ID)
+ 定位 = 事实定位(
+ 提案["proposal_id"], 提案["revision"], 提案["proposal_hash"], "world:" + object_id, 0
+ )
+ 审阅 = 世界.打开事实审阅(作者, 定位)
+ 世界.决定事实(
+ 作者,
+ f"confirm-{object_id}-{提案ID[:8]}",
+ 定位,
+ 作者动作.采纳,
+ 审阅["review_id"],
+ 审阅["review_hash"],
+ ("object",),
+ )
+
+
+def test_两作品各连续两章派生确认事实并按时点消费__16c01a(章后环境, 章后工具):
+ 环境 = 章后环境
+ 环境["新作品"]("post-work-b", 2, "b-ch-")
+
+ 环境["剧本"].append(章后工具.抽取输出("第一章:林深滞留码头。", _事实("林深把伞收在了门边")))
+ 任务1, _ = 章后工具.保存一章("ch-1", "林深把伞收在了门边,雨还没停。")
+ 快照1 = 章后工具.跑任务(任务1)
+ assert 快照1.状态.value == "completed"
+ 检查点1 = {s["step_id"]: (s.get("checkpoint") or {}) for s in 快照1.步骤}
+ 索引1 = 检查点1["登记摘要与索引"]["索引"]
+ assert 索引1["revision"] == 1 and 索引1["document_hash"]
+ 提案1 = 检查点1["登记事实提案"]["提案"]
+ assert len(提案1) == 1 and not 检查点1["登记事实提案"]["跳过"]
+ _确认提案(环境, 提案1[0]["proposal_id"], "lin-shen")
+
+ 环境["剧本"].append(章后工具.抽取输出("第二章:林深离开码头。", []))
+ 任务2, _ = 章后工具.保存一章("ch-2", "林深沿着码头走了出去。")
+ assert 章后工具.跑任务(任务2).状态.value == "completed"
+
+ # 连续写作按正确时点消费确认事实:截至第一章的世界状态包含已确认事实。
+ 世界 = 环境["装配"].要求故事世界()
+ 时点 = 世界.查询世界(环境["作者"], "post-work", "ch-1")
+ 对象 = {o["object_id"]: o for o in 时点["facts"]}
+ assert 对象["lin-shen"]["content"]["名称"] == "林深"
+
+ # 第二部作品同旅程;确认事实不跨作品。
+ 环境["剧本"].append(章后工具.抽取输出("B 第一章。", []))
+ 任务B, _ = 章后工具.保存一章("b-ch-1", "B 章文本。")
+ assert 章后工具.跑任务(任务B).状态.value == "completed"
+ 时点B = 世界.查询世界(环境["作者"], "post-work-b", "b-ch-1")
+ assert "lin-shen" not in {o["object_id"] for o in 时点B["facts"]}
+
+
+def test_抽取失败后正文可用且恢复重试完成__16c02b(章后环境, 章后工具):
+ 环境 = 章后环境
+ from muse.任务运行.接口 import 任务状态
+
+ # 输出合同不满足:调用已结算但终态无效 → 步骤失败、任务失败(无未知调用)。
+ 环境["剧本"].append({"类型": "文本", "文本": {"summary": "只有摘要"}})
+ 任务1, 回执 = 章后工具.保存一章("ch-1", "林深把伞收在了门边。")
+ with pytest.raises((Muse错误, RuntimeError, OSError)):
+ 章后工具.跑任务(任务1)
+ 终态 = 环境["装配"].任务运行.读取任务(任务1)
+ assert 终态.状态 == 任务状态.已失败
+ 抽取步 = next(s for s in 终态.步骤 if s["step_id"] == "抽取提案")
+ assert 抽取步["state"] == "failed"
+ # 正文成功保存:派生失败不回滚作者正文。
+ 正文 = 环境["装配"].要求正文().读取正文(环境["作者"], "ch-1")
+ assert 正文["revision"] == 1
+ assert TypeAdapter(正文草稿).validate_python(正文["document"])
+
+ 环境["剧本"].append(章后工具.抽取输出("恢复后的摘要。", []))
+ 环境["装配"].任务运行.控制任务(
+ 任务1, 环境["作者"].作者, 任务状态.已失败, "恢复", 命令ID=f"resume-{任务1[:8]}"
+ )
+ 快照 = 章后工具.跑任务(任务1)
+ assert 快照.状态.value == "completed"
+ assert 回执["receipt_id"]
+
+
+def test_编辑后旧版本派生被拒绝且按新版本重建__16c03c(章后环境, 章后工具):
+ 环境 = 章后环境
+ # 保存 r1 不跑派生;编辑出 r2 后再跑 r1 任务,必须被来源复检拒绝。
+ 任务1, _ = 章后工具.保存一章("ch-1", "第一版正文,林深在码头。")
+ # r1 在来源复检即失败,不消费抽取剧本;r2 消费队头的新版本摘要。
+ 环境["剧本"].append(章后工具.抽取输出("新版本摘要。", []))
+ 任务2, _ = 章后工具.保存一章("ch-1", "第二版正文,林深在码头整理行装。", 预期版本=1)
+ with pytest.raises((Muse错误, RuntimeError, OSError)):
+ 章后工具.跑任务(任务1)
+ 快照1 = 环境["装配"].任务运行.读取任务(任务1)
+ assert 快照1.状态.value == "failed"
+ assert any(s["step_id"] == "来源复检" and s["state"] == "failed" for s in 快照1.步骤), (
+ 快照1.步骤
+ )
+
+ 快照2 = 章后工具.跑任务(任务2)
+ assert 快照2.状态.value == "completed"
+ from muse.编排.章后处理 import 查询章后状态
+
+ 状态 = 查询章后状态(环境["装配"], 环境["作者"].作者, "ch-1")
+ assert 状态["revision"] == 2 and 状态["摘要"] == "新版本摘要。"
+ # 旧任务的提案登记不得覆盖新版本投影:旧任务未产出提案。
+ 旧步骤 = {s["step_id"]: s for s in 快照1.步骤}
+ assert not (旧步骤["登记事实提案"].get("checkpoint") or {}).get("提案")
+
+
+def test_重建幂等不重复事实提案__16c04d(章后环境, 章后工具):
+ 环境 = 章后环境
+ 事实 = _事实("林深在码头")
+ 环境["剧本"].append(章后工具.抽取输出("摘要一。", 事实))
+ 任务1, _ = 章后工具.保存一章("ch-1", "林深在码头。雨停了。")
+ 章后工具.跑任务(任务1)
+
+ from muse.编排.章后处理 import 发起章后处理, 查询章后状态
+
+ 重建 = 发起章后处理(
+ 环境["装配"],
+ 环境["作者"],
+ f"rebuild-{任务1[:8]}",
+ work_id="post-work",
+ chapter_id="ch-1",
+ )
+ 环境["剧本"].append(章后工具.抽取输出("摘要一。", 事实))
+ 章后工具.跑任务(重建["task_id"])
+ 状态 = 查询章后状态(环境["装配"], 环境["作者"].作者, "ch-1")
+ assert 状态["摘要"] == "摘要一。"
+ with 环境["库"].连接(只读=True) as 连:
+ 数 = 连.execute(
+ "SELECT count(DISTINCT p.proposal_id) FROM muse_fact_proposal p "
+ "WHERE p.work_id=%s AND p.object_id=%s",
+ ("post-work", "lin-shen"),
+ ).fetchone()[0]
+ assert 数 == 1, "重建不得重复事实提案"
diff --git a/配置/流程模板/章后处理.yaml b/配置/流程模板/章后处理.yaml
new file mode 100644
index 0000000..80a63a1
--- /dev/null
+++ b/配置/流程模板/章后处理.yaml
@@ -0,0 +1,23 @@
+flow_id: 章后处理
+version: "1"
+steps:
+ - step_id: 来源复检
+ processor: post.verify
+ processor_version: "1"
+ - step_id: 绑定抽取配置
+ processor: post.config
+ processor_version: "1"
+ depends_on: [来源复检]
+ - step_id: 抽取提案
+ processor: post.extract
+ processor_version: "1"
+ depends_on: [绑定抽取配置]
+ role: extractor
+ - step_id: 登记事实提案
+ processor: post.facts
+ processor_version: "1"
+ depends_on: [抽取提案]
+ - step_id: 登记摘要与索引
+ processor: post.record
+ processor_version: "1"
+ depends_on: [抽取提案]