W16 章后派生与连续写作闭环(收口):S01 采纳/保存同事务登记章后任务(失败整事务回滚)、五步派生流程(来源复检/抽取配置/模型抽取/事实提案 Shadow 幂等登记/摘要与索引绑定)、失败可见且恢复重验重试、旧版本派生拒绝、确认事实增量技能;11 新消费级用例(NC-w16-*),离线 269 / 非宿主数据库 220;整包验证通过后置 verified(P1 md尾缀已统一,预算吞异常与降级测试留后续)。

This commit is contained in:
zizi 2026-09-11 02:42:19 +08:00
parent 63ead639f4
commit 916f527c9f
20 changed files with 1682 additions and 5 deletions

View File

@ -8,3 +8,4 @@
| 写下一章 | [SKILL.md](写下一章/SKILL.md) | 对目标章发起两阶段生成:受限探索、冻结回放、无工具写作与独立检查,产出模型候选供作者审阅。 | 需要该项操作时 | 只使用已实现的具名入口 |
| 扩写场景 | [SKILL.md](扩写场景/SKILL.md) | 对既有章节按扩写粒度发起生成,材料与检查与写下一章同一受控链路。 | 需要该项操作时 | 只使用已实现的具名入口 |
| 核对内容一致性 | [SKILL.md](核对内容一致性/SKILL.md) | 读取候选的独立检查报告:基础有效性与连续性发现,证据逐条可回读。 | 需要该项操作时 | 只使用已实现的具名入口 |
| 确认事实增量 | [确认事实增量/SKILL.md](确认事实增量/SKILL.md) | 章后派生事实提案的作者独立确认 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |

View File

@ -0,0 +1,28 @@
---
name: 确认事实增量
description: 章后派生事实提案的作者独立确认操作。采纳或保存正文后处理候选事实提案时使用。
---
# 确认事实增量
正文采纳或保存后,章后派生任务会为新章节提出候选事实提案(Shadow,未生效)。本技能供作者独立确认这些提案。
## 何时使用
- 一章正文采纳/保存且章后派生完成后,作者复核"本章新增了哪些事实"。
- 依据 [P05-章后处理与事实复核](../../../../docs/系统架构/新版设计/功能规格/P05-章后处理与事实复核.md):抽取结果不自动成为事实,必须逐条由作者确认。
## 操作步骤
1. 查看章后派生状态:任务记录中找该章的「章后处理」任务(或经 `查询章后状态`),读取"登记事实提案"步骤检查点中的提案清单。
2. 逐条打开审阅:工作台事实接口 `open_fact_review`。
3. 逐条决定:
- 采纳:`decide_world_fact`,动作采纳,附作者审阅凭据;确认后事实进入本书事实面并参与后续时点查询。
- 拒绝:动作拒绝;提案保留历史,不影响已确认设定。
4. 跳过项(引文定位失败等)在任务检查点"跳过"列表中单独可见;需要时人工补提案。
## 边界
- 只确认已存在的提案;不代作者新造事实、不修改提案内容。
- 确认是作者决定,走 S01 正式变更;任何自动化不得代替本步骤。
- 派生失败时先重试派生(恢复任务或按新版本重建),再确认事实。

View File

@ -0,0 +1,3 @@
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|------|----------|----------|----------|----------|
| 技能说明 | SKILL.md | 章后派生事实提案的作者独立确认操作 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |

View File

@ -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",

View File

@ -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",

View File

@ -5098,3 +5098,135 @@
- 环境:离线组件纯函数。
- 合同:[权威定义](../模块设计/B05-正文写作.md)。
<a id="nc-w16-w16c01"></a>
## NC-w16-w16c01 · test_两作品各连续两章派生确认事实并按时点消费__16c01a
- 位置:`tests/集成/test_章后失败与重建.py::test_两作品各连续两章派生确认事实并按时点消费__16c01a`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:两部作品各完成保存→章后派生→确认事实;确认事实进入正确时点且不跨作品。
- 预期:派生任务步骤完成;确认事实可查询;跨作品不可见。
- 环境:真实隔离 PG + 合成抽取宿主。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16c02"></a>
## NC-w16-w16c02 · test_抽取失败后正文可用且恢复重试完成__16c02b
- 位置:`tests/集成/test_章后失败与重建.py::test_抽取失败后正文可用且恢复重试完成__16c02b`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:抽取回合失败时任务失败而正文保留;恢复重验通过后完成。
- 预期:正文版本保留;步骤失败可见;恢复后完成。
- 环境:真实隔离 PG + 合成抽取宿主。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16c03"></a>
## NC-w16-w16c03 · test_编辑后旧版本派生被拒绝且按新版本重建__16c03c
- 位置:`tests/集成/test_章后失败与重建.py::test_编辑后旧版本派生被拒绝且按新版本重建__16c03c`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:正文前进后旧派生任务被来源复检拒绝,新版本任务正常完成。
- 预期:旧任务失败于来源复检;新任务绑定新版本。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16c04"></a>
## NC-w16-w16c04 · test_重建幂等不重复事实提案__16c04d
- 位置:`tests/集成/test_章后失败与重建.py::test_重建幂等不重复事实提案__16c04d`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:同来源版本重建派生任务不产生重复提案。
- 预期:提案唯一;摘要可回查。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16a01"></a>
## NC-w16-w16a01 · test_章后登记失败回滚整个正文提交__16a01e
- 位置:`tests/集成/test_正式提交原子性.py::test_章后登记失败回滚整个正文提交__16a01e`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:派生登记失败时正文、命令占用与任务一同回滚。
- 预期:正文不存在;命令未占用;成功路径同事务可见。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16a02"></a>
## NC-w16-w16a02 · test_采纳候选与派生登记原子可见__16a02f
- 位置:`tests/集成/test_正式提交原子性.py::test_采纳候选与派生登记原子可见__16a02f`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:候选采纳回执同事务呈现正文写入与章后任务登记。
- 预期:回执含任务;正文版本前进。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16b01"></a>
## NC-w16-w16b01 · test_同命令重放同回执且章后任务不重复__16b011
- 位置:`tests/集成/test_幂等与审阅版本.py::test_同命令重放同回执且章后任务不重复__16b011`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:相同保存命令重放返回既有回执且不重复登记派生。
- 预期:回执一致;任务唯一。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16b02"></a>
## NC-w16-w16b02 · test_同命令异参数拒绝__16b022
- 位置:`tests/集成/test_幂等与审阅版本.py::test_同命令异参数拒绝__16b022`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:相同命令身份携带不同请求被拒绝。
- 预期:COMMAND_PAYLOAD_MISMATCH。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16d01"></a>
## NC-w16-w16d01 · test_候选哈希不匹配采纳拒绝__16d013
- 位置:`tests/集成/test_候选状态与并发.py::test_候选哈希不匹配采纳拒绝__16d013`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:候选哈希与被审版本不一致时采纳被拒。
- 预期:REVISION_CONFLICT 或 SCOPE_DENIED。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16d02"></a>
## NC-w16-w16d02 · test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024
- 位置:`tests/集成/test_候选状态与并发.py::test_正文编辑后旧候选采纳拒绝但明确弃用允许__16d024`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:正文前进后旧候选不能采纳;重开审阅后可明确拒绝。
- 预期:陈旧采纳被拒;拒绝仅更新决定。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。
<a id="nc-w16-w16d03"></a>
## NC-w16-w16d03 · test_并发采纳唯一成功且正文版本唯一__16d035
- 位置:`tests/集成/test_候选状态与并发.py::test_并发采纳唯一成功且正文版本唯一__16d035`。
- 给定:隔离数据库中的作品与已采纳正文。
- 动作:两个候选基于同一版本时仅一个采纳成功。
- 预期:后者 SOURCE_STALE 或 REVISION_CONFLICT;正文版本唯一。
- 环境:真实隔离 PG。
- 合同:[权威定义](../功能规格/P05-章后处理与事实复核.md)。

View File

@ -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/回合开发/整包/"
}
]
}

View File

@ -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__ = [
"章后任务登记",
"任务登记参与者",
"组装章后任务请求",
"登记任务参与者",
"提供方配置",
"调用计价",
"配置快照",

View File

@ -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

View File

@ -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:
"""登记并发布生成正文流程;计价由调用方注入,模板默认取仓内可审配置。"""

View File

@ -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)),),
)

View File

@ -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)),),
)

View File

@ -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__ = [
"登记章后处理",
"发起章后处理",
"查询章后状态",
"流程定义_章后处理",
"章后处理步骤",
"章后处理错误",
"流程身份",
"流程版本",
"处理器版本集",
]

View File

@ -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(保存一章=保存一章, 跑任务=跑任务, 抽取输出=抽取输出, 环境=环境)

View File

@ -16,6 +16,10 @@ def 作品环境(应用测试库):
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
导入内置结构(元数据服务(连))
目录 = 参与者目录()
from muse.任务运行.接口 import 任务服务, 登记任务参与者
from muse.编排.接口 import 流程登记
登记任务参与者(目录, 任务服务(应用测试库[用途.生产], 流程登记()))
登记作品参与者(目录)
正式 = 正式变更服务(应用测试库[用途.生产], 目录)
服务 = 作品服务(应用测试库[用途.生产], 正式)

View File

@ -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"]

View File

@ -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"

View File

@ -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

View File

@ -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, "重建不得重复事实提案"

View File

@ -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: [抽取提案]