From 916f527c9f4090fcd355db689f2e911295e02e95 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 11 Sep 2026 02:42:19 +0800 Subject: [PATCH] =?UTF-8?q?W16=20=E7=AB=A0=E5=90=8E=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E4=B8=8E=E8=BF=9E=E7=BB=AD=E5=86=99=E4=BD=9C=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=EF=BC=88=E6=94=B6=E5=8F=A3=EF=BC=89=EF=BC=9AS01=20=E9=87=87?= =?UTF-8?q?=E7=BA=B3/=E4=BF=9D=E5=AD=98=E5=90=8C=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E7=AB=A0=E5=90=8E=E4=BB=BB=E5=8A=A1=EF=BC=88?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=95=B4=E4=BA=8B=E5=8A=A1=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=EF=BC=89=E3=80=81=E4=BA=94=E6=AD=A5=E6=B4=BE=E7=94=9F=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=88=E6=9D=A5=E6=BA=90=E5=A4=8D=E6=A3=80/?= =?UTF-8?q?=E6=8A=BD=E5=8F=96=E9=85=8D=E7=BD=AE/=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8A=BD=E5=8F=96/=E4=BA=8B=E5=AE=9E=E6=8F=90=E6=A1=88=20Shado?= =?UTF-8?q?w=20=E5=B9=82=E7=AD=89=E7=99=BB=E8=AE=B0/=E6=91=98=E8=A6=81?= =?UTF-8?q?=E4=B8=8E=E7=B4=A2=E5=BC=95=E7=BB=91=E5=AE=9A=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=A7=81=E4=B8=94=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=87=8D=E9=AA=8C=E9=87=8D=E8=AF=95=E3=80=81=E6=97=A7=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=B4=BE=E7=94=9F=E6=8B=92=E7=BB=9D=E3=80=81=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=BA=8B=E5=AE=9E=E5=A2=9E=E9=87=8F=E6=8A=80=E8=83=BD?= =?UTF-8?q?=EF=BC=9B11=20=E6=96=B0=E6=B6=88=E8=B4=B9=E7=BA=A7=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=88NC-w16-*=EF=BC=89=EF=BC=8C=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=20269=20/=20=E9=9D=9E=E5=AE=BF=E4=B8=BB=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=20220=EF=BC=9B=E6=95=B4=E5=8C=85=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=90=8E=E7=BD=AE=20verified=EF=BC=88P1=20md?= =?UTF-8?q?=E5=B0=BE=E7=BC=80=E5=B7=B2=E7=BB=9F=E4=B8=80=EF=BC=8C=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E5=90=9E=E5=BC=82=E5=B8=B8=E4=B8=8E=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=95=99=E5=90=8E=E7=BB=AD=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/skills/操作/目录.md | 1 + .agent/skills/操作/确认事实增量/SKILL.md | 28 ++ .agent/skills/操作/确认事实增量/目录.md | 3 + .../新版设计/改造计划/工作包清单.json | 14 +- docs/系统架构/新版设计/目标文件清单.json | 11 + .../新版设计/验证设计/创作与正式变更用例.md | 132 ++++++ .../新版设计/验证设计/测试用例清单.json | 177 +++++++ src/muse/任务运行/接口.py | 74 +++ src/muse/任务运行/模型.py | 23 + src/muse/启动.py | 23 + src/muse/正文写作/人工保存.py | 28 +- src/muse/正文写作/正式提交.py | 26 +- src/muse/编排/章后处理.py | 433 ++++++++++++++++++ tests/conftest.py | 240 ++++++++++ tests/集成/test_作品与人工正文.py | 4 + tests/集成/test_候选状态与并发.py | 142 ++++++ tests/集成/test_幂等与审阅版本.py | 52 +++ tests/集成/test_正式提交原子性.py | 97 ++++ tests/集成/test_章后失败与重建.py | 156 +++++++ 配置/流程模板/章后处理.yaml | 23 + 20 files changed, 1682 insertions(+), 5 deletions(-) create mode 100644 .agent/skills/操作/确认事实增量/SKILL.md create mode 100644 .agent/skills/操作/确认事实增量/目录.md create mode 100644 src/muse/编排/章后处理.py create mode 100644 tests/集成/test_候选状态与并发.py create mode 100644 tests/集成/test_幂等与审阅版本.py create mode 100644 tests/集成/test_正式提交原子性.py create mode 100644 tests/集成/test_章后失败与重建.py create mode 100644 配置/流程模板/章后处理.yaml 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: [抽取提案]