W16 章后派生与连续写作闭环(收口):S01 采纳/保存同事务登记章后任务(失败整事务回滚)、五步派生流程(来源复检/抽取配置/模型抽取/事实提案 Shadow 幂等登记/摘要与索引绑定)、失败可见且恢复重验重试、旧版本派生拒绝、确认事实增量技能;11 新消费级用例(NC-w16-*),离线 269 / 非宿主数据库 220;整包验证通过后置 verified(P1 md尾缀已统一,预算吞异常与降级测试留后续)。
This commit is contained in:
parent
63ead639f4
commit
916f527c9f
@ -8,3 +8,4 @@
|
||||
| 写下一章 | [SKILL.md](写下一章/SKILL.md) | 对目标章发起两阶段生成:受限探索、冻结回放、无工具写作与独立检查,产出模型候选供作者审阅。 | 需要该项操作时 | 只使用已实现的具名入口 |
|
||||
| 扩写场景 | [SKILL.md](扩写场景/SKILL.md) | 对既有章节按扩写粒度发起生成,材料与检查与写下一章同一受控链路。 | 需要该项操作时 | 只使用已实现的具名入口 |
|
||||
| 核对内容一致性 | [SKILL.md](核对内容一致性/SKILL.md) | 读取候选的独立检查报告:基础有效性与连续性发现,证据逐条可回读。 | 需要该项操作时 | 只使用已实现的具名入口 |
|
||||
| 确认事实增量 | [确认事实增量/SKILL.md](确认事实增量/SKILL.md) | 章后派生事实提案的作者独立确认 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |
|
||||
|
||||
28
.agent/skills/操作/确认事实增量/SKILL.md
Normal file
28
.agent/skills/操作/确认事实增量/SKILL.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 确认事实增量
|
||||
description: 章后派生事实提案的作者独立确认操作。采纳或保存正文后处理候选事实提案时使用。
|
||||
---
|
||||
|
||||
# 确认事实增量
|
||||
|
||||
正文采纳或保存后,章后派生任务会为新章节提出候选事实提案(Shadow,未生效)。本技能供作者独立确认这些提案。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 一章正文采纳/保存且章后派生完成后,作者复核"本章新增了哪些事实"。
|
||||
- 依据 [P05-章后处理与事实复核](../../../../docs/系统架构/新版设计/功能规格/P05-章后处理与事实复核.md):抽取结果不自动成为事实,必须逐条由作者确认。
|
||||
|
||||
## 操作步骤
|
||||
|
||||
1. 查看章后派生状态:任务记录中找该章的「章后处理」任务(或经 `查询章后状态`),读取"登记事实提案"步骤检查点中的提案清单。
|
||||
2. 逐条打开审阅:工作台事实接口 `open_fact_review`。
|
||||
3. 逐条决定:
|
||||
- 采纳:`decide_world_fact`,动作采纳,附作者审阅凭据;确认后事实进入本书事实面并参与后续时点查询。
|
||||
- 拒绝:动作拒绝;提案保留历史,不影响已确认设定。
|
||||
4. 跳过项(引文定位失败等)在任务检查点"跳过"列表中单独可见;需要时人工补提案。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只确认已存在的提案;不代作者新造事实、不修改提案内容。
|
||||
- 确认是作者决定,走 S01 正式变更;任何自动化不得代替本步骤。
|
||||
- 派生失败时先重试派生(恢复任务或按新版本重建),再确认事实。
|
||||
3
.agent/skills/操作/确认事实增量/目录.md
Normal file
3
.agent/skills/操作/确认事实增量/目录.md
Normal file
@ -0,0 +1,3 @@
|
||||
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| 技能说明 | SKILL.md | 章后派生事实提案的作者独立确认操作 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 |
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)。
|
||||
|
||||
|
||||
@ -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/回合开发/整包/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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__ = [
|
||||
"章后任务登记",
|
||||
"任务登记参与者",
|
||||
"组装章后任务请求",
|
||||
"登记任务参与者",
|
||||
"提供方配置",
|
||||
"调用计价",
|
||||
"配置快照",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
"""登记并发布生成正文流程;计价由调用方注入,模板默认取仓内可审配置。"""
|
||||
|
||||
|
||||
@ -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)),),
|
||||
)
|
||||
|
||||
|
||||
@ -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)),),
|
||||
)
|
||||
|
||||
|
||||
433
src/muse/编排/章后处理.py
Normal file
433
src/muse/编排/章后处理.py
Normal 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__ = [
|
||||
"登记章后处理",
|
||||
"发起章后处理",
|
||||
"查询章后状态",
|
||||
"流程定义_章后处理",
|
||||
"章后处理步骤",
|
||||
"章后处理错误",
|
||||
"流程身份",
|
||||
"流程版本",
|
||||
"处理器版本集",
|
||||
]
|
||||
@ -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(保存一章=保存一章, 跑任务=跑任务, 抽取输出=抽取输出, 环境=环境)
|
||||
|
||||
@ -16,6 +16,10 @@ def 作品环境(应用测试库):
|
||||
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
|
||||
导入内置结构(元数据服务(连))
|
||||
目录 = 参与者目录()
|
||||
from muse.任务运行.接口 import 任务服务, 登记任务参与者
|
||||
from muse.编排.接口 import 流程登记
|
||||
|
||||
登记任务参与者(目录, 任务服务(应用测试库[用途.生产], 流程登记()))
|
||||
登记作品参与者(目录)
|
||||
正式 = 正式变更服务(应用测试库[用途.生产], 目录)
|
||||
服务 = 作品服务(应用测试库[用途.生产], 正式)
|
||||
|
||||
142
tests/集成/test_候选状态与并发.py
Normal file
142
tests/集成/test_候选状态与并发.py
Normal 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"]
|
||||
52
tests/集成/test_幂等与审阅版本.py
Normal file
52
tests/集成/test_幂等与审阅版本.py
Normal 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"
|
||||
97
tests/集成/test_正式提交原子性.py
Normal file
97
tests/集成/test_正式提交原子性.py
Normal 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
|
||||
156
tests/集成/test_章后失败与重建.py
Normal file
156
tests/集成/test_章后失败与重建.py
Normal 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, "重建不得重复事实提案"
|
||||
23
配置/流程模板/章后处理.yaml
Normal file
23
配置/流程模板/章后处理.yaml
Normal 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: [抽取提案]
|
||||
Loading…
x
Reference in New Issue
Block a user