W13 渐进探索、分层规划与近期细化:私人探索、明确选定、分层规划与近期细化;含竞态修复新用例。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
This commit is contained in:
parent
2f15c5b3bc
commit
224df00706
22
.agent/skills/操作/完善故事基础设定/SKILL.md
Normal file
22
.agent/skills/操作/完善故事基础设定/SKILL.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
id: develop-story-foundation
|
||||
name: 完善故事基础设定
|
||||
category: operation
|
||||
contract_version: 1
|
||||
commands: [list_explorations, read_exploration, save_exploration, check_exploration_handoff, select_exploration, read_exploration_selection]
|
||||
description: 通过新版作者入口保存私人探索原话与分歧,整理交付时检查格式;内容和方向由作者决定。
|
||||
---
|
||||
|
||||
# 完善故事基础设定
|
||||
|
||||
先读取作者已有素材和本轮点名的探索稿。素材、联系、中心、结构可以来回发展,不把九章完整度、候选数量或字数设为开始条件。作者反馈只作用于点名的内容,不把局部采纳扩大为正式规划确认。
|
||||
|
||||
使用明确配置的私人探索目录。`.venv/bin/python -m muse 探索 <作者配置> 列表`和`查看 <稿件ID>`读取已有稿。保存请求包含draft_id、title、content、expected_hash,以`保存 <私人请求JSON文件>`交给B01。新稿expected_hash为null,后续使用读回的draft_hash;内容保留原话,稿件名称与稳定身份分开。保存冲突先比较当前稿和自己的修改,再执行作者选择。
|
||||
|
||||
日常内容可以很短、不完整或互相矛盾,不运行最终格式检查。作者要求整理交付时,才按[交付结构](references/交付结构.json)整理已有内容:唯一作品标题,根设定、九章及待决与取舍依次出现;未探索部分明确说明,可选子标题按导航顺序展开。没有固定编号段或篇幅门。
|
||||
|
||||
整理稿保存后,执行`检查 <稿件ID> --预期哈希 <稿件哈希>`。结果只说明格式,失败返回非零状态;格式通过不证明根设定已获作者认可或内容自洽。
|
||||
|
||||
作者明确选定本稿并要求交给作品时,先用`查看选定 <作品ID>`读取当前选定版本,再用`选定 <私人请求JSON>`提交command_id和selection。selection包含work_id、draft_id、expected_hash和expected_revision。只使用作者实际点名的稿件版本及作品;不填confirmedByUser、confirmedAt或作者身份。S01将所选快照和回执一起保存,后续改稿不改变已选快照。回执未返回时保持原请求重试。选定方向只授权其成为规划来源,正式规划候选还需要独立审阅与确认。
|
||||
|
||||
权威职责见[B01作品规划](../../../../docs/系统架构/新版设计/模块设计/B01-作品规划.md)。作者资料不进入共享文档,未交接草稿不进入正文生成上下文。
|
||||
391
.agent/skills/操作/完善故事基础设定/references/交付结构.json
Normal file
391
.agent/skills/操作/完善故事基础设定/references/交付结构.json
Normal file
@ -0,0 +1,391 @@
|
||||
{
|
||||
"contractVersion": "完善故事基础设定/output-v3",
|
||||
"chapters": [
|
||||
{
|
||||
"heading": "一、作品定位与核心命题",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "1.1 作品形态与定位",
|
||||
"units": [
|
||||
"1.1.1 题材、载体与目标读者",
|
||||
"1.1.2 篇幅、连载方式与结构规模",
|
||||
"1.1.3 同类作品中的差异"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "1.2 故事承诺与一句话梗概",
|
||||
"units": [
|
||||
"1.2.1 一句话故事",
|
||||
"1.2.2 主角起点、目标与外部期限",
|
||||
"1.2.3 持续驱动力与核心看点"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "1.3 核心问题与主题命题",
|
||||
"units": [
|
||||
"1.3.1 故事真正要追问的问题",
|
||||
"1.3.2 人物、制度或社会提供的对立答案",
|
||||
"1.3.3 结局必须回答和可以保留的问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "1.4 阅读体验与情绪基调",
|
||||
"units": [
|
||||
"1.4.1 作品的情绪底色",
|
||||
"1.4.2 压力、满足与释放的来源",
|
||||
"1.4.3 作品区别于同类的质感"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "1.5 题材边界与创作禁区",
|
||||
"units": [
|
||||
"1.5.1 可以使用的题材元素",
|
||||
"1.5.2 不使用的套路和处理方式",
|
||||
"1.5.3 不可破坏的主题、人物与规则边界"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "二、故事世界与社会系统",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "2.1 稳定世界规则",
|
||||
"units": [
|
||||
"2.1.1 时空、基本存在与世界层级",
|
||||
"2.1.2 自然、技术与超常规则",
|
||||
"2.1.3 社会共同使用的尺度和分类",
|
||||
"2.1.4 世界真实状态与当前未知边界"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "2.2 空间与舞台",
|
||||
"units": [
|
||||
"2.2.1 主舞台卡",
|
||||
"2.2.2 地点层级与人物活动范围",
|
||||
"2.2.3 资源、交通与进入限制",
|
||||
"2.2.4 舞台扩展和变化条件"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "2.3 社会结构、组织与制度",
|
||||
"units": [
|
||||
"2.3.1 权力层级与治理方式",
|
||||
"2.3.2 组织的职责、资源与约束",
|
||||
"2.3.3 制度、资格、法律与责任",
|
||||
"2.3.4 组织之间的关系与利益冲突",
|
||||
"2.3.5 社会流动与阶层边界"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "2.4 经济、资源与职业",
|
||||
"units": [
|
||||
"2.4.1 货币、价值与基本生活",
|
||||
"2.4.2 资源的生产、流通与分配",
|
||||
"2.4.3 职业、资格、收入与上升路径",
|
||||
"2.4.4 稀缺、成本、风险与机会成本",
|
||||
"2.4.5 资源量级变化的条件"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "2.5 信息传播与公共评价",
|
||||
"units": [
|
||||
"2.5.1 信息渠道、传播速度与可信度",
|
||||
"2.5.2 公共评价、声誉与证明材料",
|
||||
"2.5.3 信息差、权限差与阶层差",
|
||||
"2.5.4 公开事件引发的社会反应"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "三、角色与关系",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "3.1 主角",
|
||||
"units": [
|
||||
"3.1.1 身份、起点与当前处境",
|
||||
"3.1.2 欲望、需求与核心缺口",
|
||||
"3.1.3 能力、资源与现实限制",
|
||||
"3.1.4 价值观、底线与秘密",
|
||||
"3.1.5 角色弧线与终局状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "3.2 核心角色群",
|
||||
"units": [
|
||||
"3.2.1 原生关系与生活支点",
|
||||
"3.2.2 同伴、盟友与协作者",
|
||||
"3.2.3 竞争者与镜像角色",
|
||||
"3.2.4 关键组织代理人和中介人物",
|
||||
"3.2.5 角色的进入、退出与位置转移"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "3.3 关系网络与关系弧线",
|
||||
"units": [
|
||||
"3.3.1 关系图与关系类型",
|
||||
"3.3.2 关系的初始状态",
|
||||
"3.3.3 关系变化的触发事件",
|
||||
"3.3.4 关系中的利益、信息与责任",
|
||||
"3.3.5 各阶段关系状态与最终走向"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "3.4 对手与阻力系统",
|
||||
"units": [
|
||||
"3.4.1 对手来源与阻力类型",
|
||||
"3.4.2 对手卡:目标、优势、盲区与代价",
|
||||
"3.4.3 对手的手段、边界与让步条件",
|
||||
"3.4.4 对手的适应、升级、转化与退出",
|
||||
"3.4.5 制度、环境、资源和时间阻力"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "四、故事机制与关键实体",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "4.1 核心机制的不变量",
|
||||
"units": [
|
||||
"4.1.1 机制的定义与存在形式",
|
||||
"4.1.2 触发条件与输入",
|
||||
"4.1.3 输出、反馈与可观察结果",
|
||||
"4.1.4 适用范围与明确不能做什么",
|
||||
"4.1.5 永久边界、异常情况与不可逆规则"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "4.2 成长、资源与代价",
|
||||
"units": [
|
||||
"4.2.1 成长路径与解锁条件",
|
||||
"4.2.2 资源来源、权属与转换",
|
||||
"4.2.3 成本、消耗、恢复与机会成本",
|
||||
"4.2.4 失败、损耗、补救与责任",
|
||||
"4.2.5 不同阶段的规模与可用范围"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "4.3 能力和力量尺度",
|
||||
"units": [
|
||||
"4.3.1 能力或力量的评价维度",
|
||||
"4.3.2 等级、层级与认证方式",
|
||||
"4.3.3 同级差异、越级边界与现场状态",
|
||||
"4.3.4 人、装备、环境与对手的比较口径",
|
||||
"4.3.5 能力提升的证明方式和记录"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "4.4 关键物件与资产生命周期",
|
||||
"units": [
|
||||
"4.4.1 物件或资产卡片字段",
|
||||
"4.4.2 来源、权属、取得与交接",
|
||||
"4.4.3 当前状态、维护、使用与损耗",
|
||||
"4.4.4 升级、替换、转让与损毁",
|
||||
"4.4.5 生命周期对剧情和人物的作用"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "4.5 谜团、风险与长期冲突",
|
||||
"units": [
|
||||
"4.5.1 核心谜团对象",
|
||||
"4.5.2 异常现象与风险机制",
|
||||
"4.5.3 威胁层级、表现与应对边界",
|
||||
"4.5.4 已证事实、人物假设与未知部分",
|
||||
"4.5.5 长期冲突与终局问题"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "五、阶段与状态变化",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "5.1 阶段划分与边界",
|
||||
"units": [
|
||||
"5.1.1 阶段的命名与划分依据",
|
||||
"5.1.2 阶段的核心问题与主要目标",
|
||||
"5.1.3 阶段的进入条件",
|
||||
"5.1.4 阶段的结束条件",
|
||||
"5.1.5 阶段内部的卷、篇或故事单元"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "5.2 阶段状态矩阵",
|
||||
"units": [
|
||||
"5.2.1 主角的身份、目标与责任",
|
||||
"5.2.2 舞台、组织与社会状态",
|
||||
"5.2.3 角色、关系与对手状态",
|
||||
"5.2.4 资源、能力与资产状态",
|
||||
"5.2.5 信息、公共评价与威胁状态",
|
||||
"5.2.6 阶段入口、出口与遗留问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "5.3 转段事件与遗留问题",
|
||||
"units": [
|
||||
"5.3.1 触发阶段变化的事件",
|
||||
"5.3.2 状态变化和实际代价",
|
||||
"5.3.3 带入下一阶段的资源与关系",
|
||||
"5.3.4 本阶段解决和未解决的问题",
|
||||
"5.3.5 转段结果的证据与核对条件"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "六、情节架构与全书大纲",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "6.1 主冲突与因果链",
|
||||
"units": [
|
||||
"6.1.1 主线目标与终局方向",
|
||||
"6.1.2 主线阻力与冲突机制",
|
||||
"6.1.3 关键事件之间的因果链",
|
||||
"6.1.4 转折、高潮与阶段里程碑",
|
||||
"6.1.5 主线与主题命题的对应关系"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "6.2 各阶段或各卷大纲",
|
||||
"units": [
|
||||
"6.2.1 阶段或各卷的目标",
|
||||
"6.2.2 故事单元的分解与顺序",
|
||||
"6.2.3 关键事件、结果与代价",
|
||||
"6.2.4 角色、关系和设定的调用",
|
||||
"6.2.5 阶段收束与下一阶段入口"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "6.3 当前阶段事件树",
|
||||
"units": [
|
||||
"6.3.1 事件节点的统一字段",
|
||||
"6.3.2 目标、阻力、行动与结果",
|
||||
"6.3.3 分支、失败路径与替代方案",
|
||||
"6.3.4 节奏、章节分配与章末状态",
|
||||
"6.3.5 事件完成后的状态更新"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "6.4 并行线与回收",
|
||||
"units": [
|
||||
"6.4.1 支线目标与独立价值",
|
||||
"6.4.2 支线与主线的交汇点",
|
||||
"6.4.3 支线角色、资源与关系",
|
||||
"6.4.4 伏笔回收与线索合流",
|
||||
"6.4.5 支线终止、转入主线与有意留白"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "七、主题与读者体验弧线",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "7.1 各阶段主题",
|
||||
"units": [
|
||||
"7.1.1 阶段核心命题",
|
||||
"7.1.2 主角在本阶段要回答的问题",
|
||||
"7.1.3 对立价值与冲突答案",
|
||||
"7.1.4 阶段结束时形成的暂时答案"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "7.2 情绪基调与阅读期待",
|
||||
"units": [
|
||||
"7.2.1 阶段情绪底色",
|
||||
"7.2.2 紧张、释放与安静段的安排",
|
||||
"7.2.3 读者在本阶段期待看到什么",
|
||||
"7.2.4 阶段切换时阅读感受如何变化"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "7.3 承诺、升级与兑现",
|
||||
"units": [
|
||||
"7.3.1 本阶段需要兑现的旧承诺",
|
||||
"7.3.2 本阶段新增的承诺与问题",
|
||||
"7.3.3 能力、舞台与责任的升级",
|
||||
"7.3.4 兑现证据与有意保留的未知"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "八、信息、悬念与伏笔管理",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "8.1 世界事实、角色知情、读者披露",
|
||||
"units": [
|
||||
"8.1.1 世界中客观存在的事实",
|
||||
"8.1.2 各角色当前知道什么",
|
||||
"8.1.3 读者当前已经知道什么",
|
||||
"8.1.4 三种状态允许存在的差异"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "8.2 信息披露节奏",
|
||||
"units": [
|
||||
"8.2.1 第一次露头",
|
||||
"8.2.2 局部确认",
|
||||
"8.2.3 官方或公共定性",
|
||||
"8.2.4 重大揭示与反转",
|
||||
"8.2.5 揭示后改变了什么选择"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "8.3 伏笔登记与回收",
|
||||
"units": [
|
||||
"8.3.1 埋设位置与当时含义",
|
||||
"8.3.2 提醒方式和再次出现",
|
||||
"8.3.3 回收条件",
|
||||
"8.3.4 回收后改变的目标、关系或判断",
|
||||
"8.3.5 故意留白与暂不回收"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "8.4 时间线与连续性核对",
|
||||
"units": [
|
||||
"8.4.1 客观时间线",
|
||||
"8.4.2 人物知情时间线",
|
||||
"8.4.3 事件、阶段与披露的交叉核对"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "九、叙事执行与一致性",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "9.1 视角、语言和场景",
|
||||
"units": [
|
||||
"9.1.1 视角、人称与叙述距离",
|
||||
"9.1.2 场景的进入、推进与退出",
|
||||
"9.1.3 设定如何通过动作进入正文",
|
||||
"9.1.4 人物声音与对白边界"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "9.2 节奏、章末和对话",
|
||||
"units": [
|
||||
"9.2.1 场景节奏与信息密度",
|
||||
"9.2.2 对话、动作与叙述的比例",
|
||||
"9.2.3 章内目标、转折与后果",
|
||||
"9.2.4 章末钩子与下一章压力",
|
||||
"9.2.5 安静段、恢复段与关系场景"
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "9.3 设定落地与一致性检查",
|
||||
"units": [
|
||||
"9.3.1 数字、单位与时间",
|
||||
"9.3.2 规则、能力与代价",
|
||||
"9.3.3 角色、关系与知情边界",
|
||||
"9.3.4 资产、权属与损耗",
|
||||
"9.3.5 阶段状态与伏笔更新"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.agent/skills/操作/完善故事基础设定/references/目录.md
Normal file
3
.agent/skills/操作/完善故事基础设定/references/目录.md
Normal file
@ -0,0 +1,3 @@
|
||||
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| 交付结构 | [交付结构.json](交付结构.json) | 最终整理稿的章节与展开导航 | 打包供B01格式校验 | 保留未知说明,不要求全部展开 |
|
||||
4
.agent/skills/操作/完善故事基础设定/目录.md
Normal file
4
.agent/skills/操作/完善故事基础设定/目录.md
Normal file
@ -0,0 +1,4 @@
|
||||
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| 操作说明 | [SKILL.md](SKILL.md) | 私人探索保存、读回及最终格式检查 | 使用新版探索入口 | 不自动进入正式规划 |
|
||||
| 交付资源 | [目录.md](references/目录.md) | 最终整理稿的格式资源 | W13探索交付检查 | 日常探索不按最终格式通关 |
|
||||
22
.agent/skills/操作/确认规划候选/SKILL.md
Normal file
22
.agent/skills/操作/确认规划候选/SKILL.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
id: confirm-plan-candidate
|
||||
name: 确认规划候选
|
||||
category: operation
|
||||
contract_version: 1
|
||||
commands: [read_plan_candidate, edit_plan_candidate, open_plan_review, decide_plan_candidate, read_plan, list_plans]
|
||||
description: 读取新版规划候选和固定版本,执行作者明确的采纳、拒绝或暂缓,并回查正式规划与事务回执。
|
||||
---
|
||||
|
||||
# 确认规划候选
|
||||
|
||||
先用`.venv/bin/python -m muse 规划 <作者配置> 查看候选 <候选ID>`读取内容。当前人工规划候选只做字段与版本检查,不表示模型或文学评审已经通过。
|
||||
|
||||
作者要修改候选时,使用`编辑候选 <私人请求JSON>`提交command_id、candidate(确切定位)与changes字段操作。服务端在同一身份下追加候选版本,原版保留。编辑后重新读取并打开审阅,旧审阅不能确认改后的内容。
|
||||
|
||||
向作者展示确切内容和保存后的影响。准备candidate_id、candidate_revision、candidate_hash、plan_id、expected_revision,执行`打开审阅 <私人定位JSON>`取得审阅记录。不能把打开审阅当作确认。
|
||||
|
||||
作者明确决定后,`决定 <私人请求JSON>`提交command_id、candidate(上述定位)、action、author_review_id、review_hash、approved_changes。action为adopt、reject或defer;采纳整份规划时approved_changes为`["plan"]`。使用实际审阅时的版本和哈希,不自动刷新成最新版本后重试采纳。
|
||||
|
||||
返回回执后,用`查看 <规划ID>`或`列表 <作品ID>`核对正式内容。网络结果未知时保持同一请求重试;参数改变使用新命令。陈旧候选需要重新比较,不能覆盖新的正式规划。规划确认只写B01,不代替正文或故事事实的独立确认。
|
||||
|
||||
职责和来源约束见[B01作品规划](../../../../docs/系统架构/新版设计/模块设计/B01-作品规划.md)与[命令与作者决策](../../../../docs/系统架构/新版设计/接口契约/命令与作者决策.md)。
|
||||
3
.agent/skills/操作/确认规划候选/目录.md
Normal file
3
.agent/skills/操作/确认规划候选/目录.md
Normal file
@ -0,0 +1,3 @@
|
||||
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| 操作说明 | [SKILL.md](SKILL.md) | 规划候选的确切版本审阅与作者决定 | 使用新版规划入口 | 不以打开审阅或字段通过替代作者确认 |
|
||||
245
src/muse/作品规划/分层规划.py
Normal file
245
src/muse/作品规划/分层规划.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""规划结构、目标章节及真实来源校验;动态内容不决定实例归属。"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from muse.作品规划.存储 import 作品存储, 规划存储
|
||||
from muse.作品规划.模型 import 作品错误, 档案结构, 规划保存
|
||||
from muse.作品规划.目录调整 import 读取作者目录
|
||||
from muse.元数据.接口 import (
|
||||
元数据服务,
|
||||
字段变更,
|
||||
投影值,
|
||||
投影字段,
|
||||
授权摘要,
|
||||
校验值,
|
||||
校验字段变更,
|
||||
选择实例维护者,
|
||||
)
|
||||
from muse.共享.错误 import Muse错误
|
||||
from muse.正式变更.接口 import 依赖引用, 固定哈希
|
||||
|
||||
|
||||
def 规划授权(作者: str, work_id: str):
|
||||
return 授权摘要(
|
||||
固定哈希({"author": 作者}), 固定哈希({"work": work_id, "owner": "B01.planning"})
|
||||
)
|
||||
|
||||
|
||||
def 读取规划结构(连, 作者: str, work_id: str, 选择: 档案结构, 用途名: str = "userEditable"):
|
||||
meta = 元数据服务(连)
|
||||
schema = meta.读取有效结构(
|
||||
选择.schema_id, 选择.base_version, work_id=work_id, extension_version=选择.extension_version
|
||||
)
|
||||
type_ = meta.读取类型(schema.基础.type_id)
|
||||
family = "scene_plan" if "scene_plan" in type_.实例族 else "planning"
|
||||
if 选择实例维护者(type_, family) != "B01":
|
||||
raise 作品错误("SCOPE_DENIED", "该结构不属于本书规划")
|
||||
projection = 投影字段(
|
||||
schema,
|
||||
meta.当前策略(type_.type_id),
|
||||
规划授权(作者, work_id),
|
||||
字段用途名=用途名,
|
||||
内容用途="planning",
|
||||
运行用途="production",
|
||||
)
|
||||
return schema, type_, projection
|
||||
|
||||
|
||||
def 规划身份(请求: 规划保存, 类型ID: str) -> str:
|
||||
return str(
|
||||
uuid5(
|
||||
NAMESPACE_URL,
|
||||
"muse:B01:plan:" + 固定哈希([请求.work_id, 请求.node_id or 请求.chapter_id, 类型ID]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def 规划目标(连, 请求: 规划保存, 类型, directory: dict) -> dict | None:
|
||||
chapter_id, kind = 请求.chapter_id, "chapter" if 请求.chapter_id else "work"
|
||||
if 请求.node_id is not None:
|
||||
tree = 作品存储(连).读取节点目录(请求.work_id)
|
||||
node = next((n for n in tree["nodes"] if n["node_id"] == 请求.node_id), None)
|
||||
if node is None:
|
||||
raise 作品错误("SCOPE_DENIED", "规划节点不属于本书目录")
|
||||
kind = node["kind"]
|
||||
chapter_id = (
|
||||
node["node_id"] if kind == "chapter" else node["parent_id"] if kind == "scene" else None
|
||||
)
|
||||
if 请求.chapter_id is not None and 请求.chapter_id != chapter_id:
|
||||
raise 作品错误("SCOPE_DENIED", "规划节点与指定章节不一致")
|
||||
grains = (类型.实例粒度,) if isinstance(类型.实例粒度, str) else 类型.实例粒度
|
||||
allowed = {
|
||||
"scene" if grain == "block" and "scene_plan" in 类型.实例族 else grain for grain in grains
|
||||
}
|
||||
if kind not in allowed:
|
||||
raise 作品错误("PLAN_SCOPE_INVALID", "规划结构不适用于该层目录节点")
|
||||
chapter = next((c for c in directory["chapters"] if c["chapter_id"] == chapter_id), None)
|
||||
if chapter_id is not None and chapter is None:
|
||||
raise 作品错误("SCOPE_DENIED", "规划章节不属于该作品")
|
||||
return chapter
|
||||
|
||||
|
||||
def 核验规划(
|
||||
连,
|
||||
作者: str,
|
||||
请求: 规划保存,
|
||||
*,
|
||||
检查提交: bool = True,
|
||||
规范内容: dict | None = None,
|
||||
祖先: frozenset[str] = frozenset(),
|
||||
检查来源: bool | None = None,
|
||||
引用解析=None,
|
||||
消费用途: str = "planning",
|
||||
):
|
||||
来源须有效 = 检查提交 if 检查来源 is None else 检查来源
|
||||
directory = 读取作者目录(连, 作者, 请求.work_id)
|
||||
schema, type_, projection = 读取规划结构(连, 作者, 请求.work_id, 请求.schema)
|
||||
chapter = 规划目标(连, 请求, type_, directory)
|
||||
id_ = 规划身份(请求, type_.type_id)
|
||||
if id_ in 祖先:
|
||||
raise 作品错误("PLAN_REFERENCE_INVALID", "规划依据不能形成当前版本依赖环")
|
||||
current = 规划存储(连).读取(id_)
|
||||
if 检查提交:
|
||||
if (current["current_revision"] if current else 0) != 请求.expected_revision:
|
||||
raise 作品错误("REVISION_CONFLICT", "规划正式版本已变化")
|
||||
if schema.effective_schema_hash != 请求.schema.effective_schema_hash:
|
||||
raise 作品错误("SCHEMA_STALE", "规划结构版本已变化")
|
||||
if projection.projection_version != 请求.schema.projection_version:
|
||||
raise 作品错误("PROJECTION_STALE", "规划字段用途已变化")
|
||||
values = (
|
||||
校验字段变更(
|
||||
schema,
|
||||
projection,
|
||||
current["content"] if current else {},
|
||||
(*tuple(字段变更((k,), "set", v) for k, v in 请求.content.items()), *请求.changes),
|
||||
)
|
||||
if 检查提交
|
||||
else 校验值(schema, 规范内容 if 规范内容 is not None else 请求.content)
|
||||
)
|
||||
if (
|
||||
检查提交
|
||||
and type_.type_id == "fine_outline"
|
||||
and (chapter is None or values.值["targetChapter"] != chapter["position"])
|
||||
):
|
||||
raise 作品错误("PLAN_SCOPE_INVALID", "细纲展示章号必须对应固定目录中的目标章节")
|
||||
deps = [依赖引用("B01.directory", "directory:" + 请求.work_id, str(directory["revision"]))]
|
||||
if 请求.selection_revision is not None:
|
||||
selected = 作品存储(连).读取探索选定(请求.work_id)
|
||||
actual = selected["revision"] if selected else 0
|
||||
if 来源须有效 and actual != 请求.selection_revision:
|
||||
raise 作品错误("SOURCE_STALE", "所选探索方向已改变")
|
||||
deps.append(
|
||||
依赖引用(
|
||||
"B01.exploration_selection", "exploration-selection:" + 请求.work_id, str(actual)
|
||||
)
|
||||
)
|
||||
for ref in values.引用需求:
|
||||
meta = 元数据服务(连)
|
||||
规划类型 = {
|
||||
t
|
||||
for t in ref.规则.目标类型
|
||||
if {"planning", "scene_plan"} & set(meta.读取类型(t).实例族)
|
||||
}
|
||||
target = ref.值.get("sourceId", ref.值.get("id")) if isinstance(ref.值, dict) else ref.值
|
||||
parent = 规划存储(连).读取(target) if 规划类型 and isinstance(target, str) else None
|
||||
if parent is None and set(ref.规则.目标类型) != 规划类型:
|
||||
if 引用解析 is None:
|
||||
raise 作品错误("PRECONDITION_PENDING", "该规划引用还需要对应业务来源解析")
|
||||
deps.extend(引用解析(连, 作者, 请求.work_id, ref, 必须有效=来源须有效))
|
||||
continue
|
||||
if (
|
||||
parent is None
|
||||
or parent["work_id"] != 请求.work_id
|
||||
or parent["type_id"] not in 规划类型
|
||||
or parent["plan_id"] == id_
|
||||
):
|
||||
raise 作品错误("PLAN_REFERENCE_INVALID", "规划来源缺失、类型不符或不属于本书")
|
||||
if ref.规则.关系用途 == "关联":
|
||||
# 目录式关联核对身份与所属,不把下层计划反向变成上层生成依据。
|
||||
continue
|
||||
if (
|
||||
来源须有效
|
||||
and isinstance(ref.值, dict)
|
||||
and "sourceVersion" in ref.值
|
||||
and str(parent["current_revision"]) != str(ref.值["sourceVersion"])
|
||||
):
|
||||
raise 作品错误("SOURCE_STALE", "规划引用不再是当前确认版本")
|
||||
deps.extend(
|
||||
核对可用规划(
|
||||
连,
|
||||
作者,
|
||||
parent,
|
||||
祖先 | {id_},
|
||||
严格=来源须有效,
|
||||
引用解析=引用解析,
|
||||
消费用途=消费用途,
|
||||
)
|
||||
)
|
||||
deps.append(
|
||||
依赖引用("B01.plan", "plan:" + parent["plan_id"], str(parent["current_revision"]))
|
||||
)
|
||||
return id_, values.值, schema, type_, projection, tuple(sorted(set(deps)))
|
||||
|
||||
|
||||
def 候选哈希(请求: 规划保存, 内容: dict, 来源: tuple) -> str:
|
||||
return 固定哈希([asdict(请求), 内容, [asdict(s) for s in 来源]])
|
||||
|
||||
|
||||
def 核对可用规划(
|
||||
连,
|
||||
作者: str,
|
||||
记录: dict,
|
||||
祖先: frozenset[str] = frozenset(),
|
||||
*,
|
||||
严格: bool = True,
|
||||
引用解析=None,
|
||||
消费用途: str = "planning",
|
||||
) -> tuple:
|
||||
id_ = 记录["plan_id"]
|
||||
if id_ in 祖先:
|
||||
raise 作品错误("SOURCE_STALE", "规划来源形成循环,需要重新确定依据")
|
||||
if 固定哈希(记录["content"]) != 记录["content_hash"]:
|
||||
raise 作品错误("INVALID_OUTPUT", "规划内容与保存哈希不一致")
|
||||
req = TypeAdapter(规划保存).validate_python(记录["request"])
|
||||
_, _, _, _, _, sources = 核验规划(
|
||||
连,
|
||||
作者,
|
||||
req,
|
||||
检查提交=False,
|
||||
规范内容=记录["content"],
|
||||
祖先=祖先,
|
||||
检查来源=严格,
|
||||
引用解析=引用解析,
|
||||
消费用途=消费用途,
|
||||
)
|
||||
if 严格 and [asdict(s) for s in sources] != 记录["source_dependencies"]:
|
||||
raise 作品错误("SOURCE_STALE", "规划所用的目录、选定方向或前置版本已变化")
|
||||
binding = 元数据服务(连).读取绑定("plan:" + id_)
|
||||
if 严格 and asdict(binding) != 记录["schema_binding"]:
|
||||
raise 作品错误("SCHEMA_STALE", "规划的当前结构绑定已变化")
|
||||
if 严格:
|
||||
meta = 元数据服务(连)
|
||||
schema = meta.读取有效结构(
|
||||
req.schema.schema_id,
|
||||
req.schema.base_version,
|
||||
work_id=req.work_id,
|
||||
extension_version=req.schema.extension_version,
|
||||
)
|
||||
projection = 投影字段(
|
||||
schema,
|
||||
meta.当前策略(schema.基础.type_id),
|
||||
规划授权(作者, req.work_id),
|
||||
字段用途名="aiContext",
|
||||
内容用途=消费用途,
|
||||
运行用途="production",
|
||||
)
|
||||
try:
|
||||
校验值(schema, 投影值(projection, 记录["content"]))
|
||||
except Muse错误 as exc:
|
||||
raise 作品错误(
|
||||
"PRECONDITION_PENDING", "规划的必需字段已停止写作用途,需要重新准备完整前置"
|
||||
) from exc
|
||||
return sources
|
||||
162
src/muse/作品规划/探索交接.py
Normal file
162
src/muse/作品规划/探索交接.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""最终格式与作者选定的持久交接;不调度探索子阶段或判断创作内容。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from itertools import pairwise
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from muse.作品规划.存储 import 作品存储
|
||||
from muse.作品规划.模型 import 作品错误, 探索选择
|
||||
from muse.作品规划.目录调整 import 读取作者目录
|
||||
from muse.正式变更.接口 import 依赖引用, 修改影响, 参与操作, 固定哈希, 审阅内容, 提交计划
|
||||
from muse.资源加载 import 打开资源
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from muse.作品规划.探索草稿 import 探索服务
|
||||
|
||||
_标题 = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", re.MULTILINE)
|
||||
_条目 = re.compile(r"^\s*[-*]\s+(?:\*\*)?S(\d{3,})(?=[^\d]|$)", re.MULTILINE)
|
||||
_占位 = re.compile(
|
||||
r"^\s*(?:[-*>]\s*)?(?:TODO|TBD|PLACEHOLDER|\[待填\])\s*$", re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def 校验交付格式(文本: str) -> dict:
|
||||
树 = json.loads(打开资源("规划/探索交付结构.json"))
|
||||
标题 = list(_标题.finditer(文本))
|
||||
问题 = []
|
||||
|
||||
def 记录(code: str, message: str) -> None:
|
||||
问题.append({"code": code, "message": message})
|
||||
|
||||
if sum(len(h[1]) == 1 for h in 标题) != 1 or not 标题 or len(标题[0][1]) != 1:
|
||||
记录("CANDIDATE_STRUCTURE", "整理稿须以唯一的作品一级标题开始")
|
||||
if any(len(h[1]) >= 4 for h in 标题):
|
||||
记录("CANDIDATE_STRUCTURE", "整理稿不使用四级及更深标题")
|
||||
if _占位.search(文本):
|
||||
记录("CANDIDATE_PLACEHOLDER", "请将裸占位符改为具体的待定或未探索说明")
|
||||
章 = {c["heading"]: c for c in 树["chapters"]}
|
||||
二级 = [h for h in 标题 if len(h[1]) == 2]
|
||||
章名 = [h[2] for h in 二级]
|
||||
if 章名 and 章名[0] == "目录":
|
||||
章名 = 章名[1:]
|
||||
if 章名 != ["根设定", *章, "待决与取舍"]:
|
||||
记录("TREE_MISMATCH", "整理稿须按导航顺序包含根设定、九章和待决与取舍")
|
||||
if any(len(h[1]) == 3 and (not 二级 or h.start() < 二级[0].start()) for h in 标题):
|
||||
记录("CANDIDATE_STRUCTURE", "展开标题须位于对应章节内")
|
||||
for i, h in enumerate(二级):
|
||||
end = 二级[i + 1].start() if i + 1 < len(二级) else len(文本)
|
||||
内容 = re.sub(r"<!--.*?-->", "", _标题.sub("", 文本[h.end() : end]), flags=re.DOTALL)
|
||||
if not re.sub(r"[\s>*_`-]", "", 内容):
|
||||
记录("SECTION_EMPTY", f"“{h[2]}”需要已有内容或具体的未探索说明")
|
||||
可用 = []
|
||||
for 节 in 章.get(h[2], {}).get("sections", []):
|
||||
可用.extend([节["heading"], *节["units"]])
|
||||
顺序 = {名: 位置 for 位置, 名 in enumerate(可用)}
|
||||
已展开 = [s[2] for s in 标题 if len(s[1]) == 3 and h.end() <= s.start() < end]
|
||||
if any(s not in 顺序 for s in 已展开) or any(
|
||||
顺序[后] <= 顺序[前] for 前, 后 in pairwise(已展开) if 前 in 顺序 and 后 in 顺序
|
||||
):
|
||||
记录("TREE_MISMATCH", f"“{h[2]}”的展开标题不属于导航或顺序有误")
|
||||
编号 = [int(v) for v in _条目.findall(文本)]
|
||||
if any(数 > 1 for 数 in Counter(编号).values()):
|
||||
记录("SETTING_ID_DUPLICATE", "可选S编号须在同一稿件内唯一")
|
||||
return {
|
||||
"valid": not 问题,
|
||||
"problems": 问题,
|
||||
"setting_count": len(编号),
|
||||
"contract_version": 树["contractVersion"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class 探索选定写入:
|
||||
作者: str
|
||||
请求: 探索选择
|
||||
快照: dict
|
||||
|
||||
|
||||
class 探索选定入口:
|
||||
身份 = "B01.select_exploration"
|
||||
请求类型 = 探索选择
|
||||
|
||||
def __init__(self, 草稿: 探索服务 | None):
|
||||
self.草稿 = 草稿
|
||||
|
||||
def _取得稿(self, 身份, 请求: 探索选择) -> dict:
|
||||
if self.草稿 is None:
|
||||
raise 作品错误("PRECONDITION_PENDING", "探索交接需要明确的私人草稿目录")
|
||||
稿 = self.草稿.读取(身份, 请求.draft_id)
|
||||
if 稿["draft_hash"] != 请求.expected_hash:
|
||||
raise 作品错误("REVISION_CONFLICT", "探索稿已经变化,请重新比较要选定的版本")
|
||||
if not 校验交付格式(稿["content"])["valid"]:
|
||||
raise 作品错误("EXPLORATION_FORMAT_INVALID", "请先整理最终交付稿的格式")
|
||||
return 稿
|
||||
|
||||
def 查看(self, 连, 身份, 命令):
|
||||
请求 = 命令.业务请求
|
||||
if 请求.target_ref != 命令.目标:
|
||||
raise 作品错误("SCOPE_DENIED", "探索选定与作品目标不一致")
|
||||
读取作者目录(连, 身份.作者, 请求.work_id)
|
||||
稿 = self._取得稿(身份, 请求)
|
||||
存储 = 作品存储(连)
|
||||
当前 = 存储.读取探索选定(请求.work_id)
|
||||
作品 = 存储.读取(请求.work_id)
|
||||
assert 作品 is not None
|
||||
版本 = 当前["revision"] if 当前 else 0
|
||||
return 审阅内容(
|
||||
请求.target_ref,
|
||||
版本,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
固定哈希([当前["snapshot"] if 当前 else None, 稿]),
|
||||
None,
|
||||
None,
|
||||
请求.expected_hash,
|
||||
("exploration_selection",),
|
||||
(
|
||||
依赖引用("B01.work", "work:" + 请求.work_id, str(作品["current_revision"])),
|
||||
依赖引用("B01.exploration_selection", 请求.target_ref, str(版本)),
|
||||
),
|
||||
)
|
||||
|
||||
def 准备(self, 连, 身份, 命令, 当前):
|
||||
请求 = 命令.业务请求
|
||||
return 提交计划(
|
||||
(
|
||||
参与操作(
|
||||
"B01.write_exploration_selection",
|
||||
探索选定写入(身份.作者, 请求, self._取得稿(身份, 请求)),
|
||||
),
|
||||
),
|
||||
(
|
||||
修改影响(
|
||||
请求.target_ref,
|
||||
"planning_direction_changed",
|
||||
str(当前.数据版本),
|
||||
str(当前.数据版本 + 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class 探索选定参与者:
|
||||
身份 = "B01.write_exploration_selection"
|
||||
请求类型 = 探索选定写入
|
||||
|
||||
def 提交(self, 连, 操作):
|
||||
return 作品存储(连).写探索选定(操作.作者, 操作.请求, 操作.快照)
|
||||
|
||||
|
||||
class 探索选定依赖:
|
||||
种类 = "B01.exploration_selection"
|
||||
|
||||
def 锁定当前(self, 连, 对象ID):
|
||||
if not 对象ID.startswith("exploration-selection:"):
|
||||
raise 作品错误("SCOPE_DENIED", "不是作品探索选定身份")
|
||||
return str(作品存储(连).锁探索选定(对象ID.removeprefix("exploration-selection:")))
|
||||
111
src/muse/作品规划/探索草稿.py
Normal file
111
src/muse/作品规划/探索草稿.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""私人探索稿的原子保存与内容比较;不生成PG记录或正式上下文。"""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from muse.作品规划.探索交接 import 校验交付格式
|
||||
from muse.作品规划.模型 import 作品错误
|
||||
|
||||
|
||||
def _稿件哈希(title: str, content: str) -> str:
|
||||
return hashlib.sha256(json.dumps([title, content], ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
class 探索服务:
|
||||
def __init__(self, 根: Path):
|
||||
self.根 = 根
|
||||
|
||||
def _目录(self, 身份) -> Path:
|
||||
if not 身份.允许写正式内容:
|
||||
raise 作品错误("SCOPE_DENIED", "私人探索需要已认证作者")
|
||||
# 作者身份参与物理分区;调用方不传相对路径或作品目录名。
|
||||
return self.根 / hashlib.sha256(身份.作者.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _核对ID(draft_id: str) -> None:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,80}", draft_id):
|
||||
raise 作品错误("EXPLORATION_INVALID", "探索稿需要稳定稿件身份")
|
||||
|
||||
def _读取(self, 文件: Path, draft_id: str) -> dict:
|
||||
try:
|
||||
稿 = json.loads(文件.read_text(encoding="utf-8"))
|
||||
if 稿["draft_id"] != draft_id or 稿["draft_hash"] != _稿件哈希(
|
||||
稿["title"], 稿["content"]
|
||||
):
|
||||
raise ValueError
|
||||
return 稿
|
||||
except FileNotFoundError:
|
||||
raise 作品错误("EXPLORATION_NOT_FOUND", "私人探索稿不存在") from None
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
raise 作品错误(
|
||||
"EXPLORATION_UNREADABLE", "私人探索稿无法读回或内容与保存哈希不符"
|
||||
) from None
|
||||
|
||||
def 读取(self, 身份, draft_id: str) -> dict:
|
||||
self._核对ID(draft_id)
|
||||
return self._读取(self._目录(身份) / (draft_id + ".json"), draft_id)
|
||||
|
||||
def 列出(self, 身份) -> list[dict]:
|
||||
结果 = []
|
||||
for 文件 in sorted(self._目录(身份).glob("*.json")):
|
||||
稿 = self._读取(文件, 文件.stem)
|
||||
结果.append({key: 稿[key] for key in ("draft_id", "title", "revision", "draft_hash")})
|
||||
return 结果
|
||||
|
||||
def 保存(
|
||||
self, 身份, draft_id: str, title: str, content: str, expected_hash: str | None
|
||||
) -> dict:
|
||||
self._核对ID(draft_id)
|
||||
if not isinstance(title, str) or not title.strip() or not isinstance(content, str):
|
||||
raise 作品错误("EXPLORATION_INVALID", "探索稿需要名称和原始文本")
|
||||
目录 = self._目录(身份)
|
||||
目录.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
文件 = 目录 / (draft_id + ".json")
|
||||
锁 = os.open(目录 / (draft_id + ".lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(锁, fcntl.LOCK_EX)
|
||||
旧 = self._读取(文件, draft_id) if 文件.exists() else None
|
||||
哈希 = _稿件哈希(title, content)
|
||||
if 旧 is not None and 旧["draft_hash"] == 哈希:
|
||||
return 旧
|
||||
if (旧["draft_hash"] if 旧 else None) != expected_hash:
|
||||
raise 作品错误("REVISION_CONFLICT", "探索稿已经改变,请比较当前稿后再保存")
|
||||
稿 = {
|
||||
"draft_id": draft_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"draft_hash": 哈希,
|
||||
"revision": 旧["revision"] + 1 if 旧 else 1,
|
||||
}
|
||||
临时 = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=目录, prefix=".保存-", delete=False
|
||||
) as out:
|
||||
临时 = Path(out.name)
|
||||
out.write(json.dumps(稿, ensure_ascii=False, indent=2) + "\n")
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(临时, 文件)
|
||||
目录描述符 = os.open(目录, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(目录描述符)
|
||||
finally:
|
||||
os.close(目录描述符)
|
||||
finally:
|
||||
if 临时 is not None:
|
||||
临时.unlink(missing_ok=True)
|
||||
return 稿
|
||||
finally:
|
||||
os.close(锁)
|
||||
|
||||
def 检查交付(self, 身份, draft_id: str, expected_hash: str) -> dict:
|
||||
稿 = self.读取(身份, draft_id)
|
||||
if 稿["draft_hash"] != expected_hash:
|
||||
raise 作品错误("REVISION_CONFLICT", "探索稿已改变,不能继续使用旧交付检查")
|
||||
return {"draft_id": draft_id, "draft_hash": 稿["draft_hash"], **校验交付格式(稿["content"])}
|
||||
475
src/muse/作品规划/规划版本.py
Normal file
475
src/muse/作品规划/规划版本.py
Normal file
@ -0,0 +1,475 @@
|
||||
"""人工规划候选与明确作者决定;正式值由B01参与S01事务写入。"""
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from muse.作品规划.作品档案 import 核对作者
|
||||
from muse.作品规划.分层规划 import 候选哈希, 核验规划, 规划授权, 规划目标, 规划身份, 读取规划结构
|
||||
from muse.作品规划.存储 import 规划存储, 解析规划载荷
|
||||
from muse.作品规划.模型 import 作品错误, 档案结构, 规划保存, 规划定位
|
||||
from muse.作品规划.目录调整 import 读取作者目录
|
||||
from muse.作品规划.近期细化 import 读取已确认细纲
|
||||
from muse.元数据.接口 import 元数据服务, 字段变更, 投影值, 校验字段变更, 生成结构合同
|
||||
from muse.正式变更.接口 import (
|
||||
作者动作,
|
||||
依赖引用,
|
||||
修改影响,
|
||||
参与操作,
|
||||
变更命令,
|
||||
固定哈希,
|
||||
审阅内容,
|
||||
提交计划,
|
||||
读取作者决定,
|
||||
)
|
||||
|
||||
|
||||
def 读候选(连, 作者, id_, 版本=None):
|
||||
item = 规划存储(连).读取候选(id_, 版本)
|
||||
if item is None:
|
||||
raise 作品错误("PLAN_CANDIDATE_NOT_FOUND", "规划候选不存在")
|
||||
读取作者目录(连, 作者, item["work_id"])
|
||||
req = TypeAdapter(规划保存).validate_python(item["request"])
|
||||
deps = tuple(依赖引用(**r) for r in item["source_dependencies"])
|
||||
if 候选哈希(req, item["content"], deps) != item["candidate_hash"]:
|
||||
raise 作品错误("INVALID_OUTPUT", "规划候选内容与哈希不一致")
|
||||
return item, req
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class 候选保存:
|
||||
candidate_id: str
|
||||
请求: 规划保存 | None = None
|
||||
前版: 规划定位 | None = None
|
||||
改动: tuple[字段变更, ...] = ()
|
||||
|
||||
|
||||
def 候选保存请求(连, 作者, 操作: 候选保存, 引用解析):
|
||||
if 操作.前版 is None:
|
||||
if 操作.请求 is None:
|
||||
raise 作品错误("INVALID_REQUEST", "新规划候选需要完整请求")
|
||||
return 操作.请求
|
||||
loc = 操作.前版
|
||||
item, req = 读候选(连, 作者, 操作.candidate_id)
|
||||
if (
|
||||
loc.candidate_id,
|
||||
loc.candidate_revision,
|
||||
loc.candidate_hash,
|
||||
loc.plan_id,
|
||||
loc.expected_revision,
|
||||
) != (
|
||||
操作.candidate_id,
|
||||
item["revision"],
|
||||
item["candidate_hash"],
|
||||
item["plan_id"],
|
||||
req.expected_revision,
|
||||
):
|
||||
raise 作品错误("REVISION_CONFLICT", "待编辑的规划候选已改变")
|
||||
_, original, schema, _, projection, sources = 核验规划(连, 作者, req, 引用解析=引用解析)
|
||||
if 候选哈希(req, original, sources) != item["candidate_hash"]:
|
||||
raise 作品错误("SOURCE_STALE", "候选来源已变化,请基于当前规划另拟候选")
|
||||
values = 校验字段变更(schema, projection, item["content"], 操作.改动).值
|
||||
current = 规划存储(连).读取(loc.plan_id)
|
||||
base = current["content"] if current else {}
|
||||
# 保存相对正式基线的完整变更,避免后续确认丢掉未再次编辑的候选字段。
|
||||
changes = tuple(
|
||||
字段变更((key,), "set", values[key]) if key in values else 字段变更((key,), "remove")
|
||||
for key in sorted(set(base) | set(values))
|
||||
if key not in base or key not in values or base[key] != values[key]
|
||||
)
|
||||
return replace(req, content={}, changes=changes)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class 规划写入:
|
||||
作者: str
|
||||
请求: 规划保存
|
||||
candidate_id: str | None = None
|
||||
candidate_revision: int = 0
|
||||
|
||||
|
||||
class 规划候选入口:
|
||||
身份 = "B01.propose_plan"
|
||||
请求类型 = 候选保存
|
||||
|
||||
def __init__(self, 引用解析=None):
|
||||
self.引用解析 = 引用解析
|
||||
|
||||
def 查看(self, 连, 身份, 命令):
|
||||
input_ = 命令.业务请求
|
||||
req = 候选保存请求(连, 身份.作者, input_, self.引用解析)
|
||||
id_, values, schema, _, projection, sources = 核验规划(
|
||||
连, 身份.作者, req, 引用解析=self.引用解析
|
||||
)
|
||||
current = 规划存储(连).读取候选(input_.candidate_id)
|
||||
revision = current["current_revision"] if current else 0
|
||||
return 审阅内容(
|
||||
input_.candidate_id,
|
||||
revision,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
候选哈希(req, values, sources),
|
||||
schema.effective_schema_hash,
|
||||
projection.projection_version,
|
||||
固定哈希([asdict(s) for s in sources]),
|
||||
("candidate",),
|
||||
(
|
||||
*sources,
|
||||
依赖引用("B01.plan", "plan:" + id_, str(req.expected_revision)),
|
||||
依赖引用("B01.plan_candidate", input_.candidate_id, str(revision)),
|
||||
),
|
||||
)
|
||||
|
||||
def 准备(self, 连, 身份, 命令, 当前):
|
||||
input_ = 命令.业务请求
|
||||
req = 候选保存请求(连, 身份.作者, input_, self.引用解析)
|
||||
return 提交计划(
|
||||
(
|
||||
参与操作(
|
||||
"B01.write_plan_candidate",
|
||||
规划写入(身份.作者, req, input_.candidate_id, 当前.数据版本),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class 规划候选参与者:
|
||||
身份 = "B01.write_plan_candidate"
|
||||
请求类型 = 规划写入
|
||||
|
||||
def __init__(self, 引用解析=None):
|
||||
self.引用解析 = 引用解析
|
||||
|
||||
def 提交(self, 连, 操作):
|
||||
id_, content, _, _, _, sources = 核验规划(连, 操作.作者, 操作.请求, 引用解析=self.引用解析)
|
||||
return 规划存储(连).写候选(
|
||||
操作.candidate_id,
|
||||
id_,
|
||||
操作.请求,
|
||||
content,
|
||||
sources,
|
||||
候选哈希(操作.请求, content, sources),
|
||||
操作.candidate_revision,
|
||||
)
|
||||
|
||||
|
||||
class 规划决定入口:
|
||||
身份 = "B01.decide_plan"
|
||||
请求类型 = 规划定位
|
||||
|
||||
def __init__(self, 引用解析=None):
|
||||
self.引用解析 = 引用解析
|
||||
|
||||
def 查看(self, 连, 身份, 命令):
|
||||
loc = 命令.业务请求
|
||||
item, req = 读候选(连, 身份.作者, loc.candidate_id)
|
||||
if (str(item["plan_id"]), item["revision"], item["candidate_hash"]) != (
|
||||
loc.plan_id,
|
||||
loc.candidate_revision,
|
||||
loc.candidate_hash,
|
||||
):
|
||||
raise 作品错误("REVISION_CONFLICT", "规划候选与被审版本不一致")
|
||||
id_, _, schema, _, projection, sources = 核验规划(
|
||||
连, 身份.作者, req, 检查提交=False, 规范内容=item["content"], 引用解析=self.引用解析
|
||||
)
|
||||
if id_ != item["plan_id"]:
|
||||
raise 作品错误("SCOPE_DENIED", "规划候选的所属目标与请求范围不一致")
|
||||
current = 规划存储(连).读取(id_)
|
||||
revision = current["current_revision"] if current else 0
|
||||
return 审阅内容(
|
||||
loc.target_ref,
|
||||
revision,
|
||||
loc.candidate_id,
|
||||
item["revision"],
|
||||
item["candidate_hash"],
|
||||
固定哈希([current["content"] if current else None, item["content"]]),
|
||||
schema.effective_schema_hash,
|
||||
projection.projection_version,
|
||||
固定哈希([asdict(s) for s in sources]),
|
||||
("plan",),
|
||||
(
|
||||
*sources,
|
||||
依赖引用("B01.plan", loc.target_ref, str(revision)),
|
||||
依赖引用("B01.plan_candidate", loc.candidate_id, str(item["revision"])),
|
||||
),
|
||||
)
|
||||
|
||||
def 准备(self, 连, 身份, 命令, 当前):
|
||||
if 命令.动作 in {作者动作.拒绝, 作者动作.暂缓}:
|
||||
return 提交计划(())
|
||||
if 命令.动作 is not 作者动作.采纳:
|
||||
raise 作品错误("INVALID_REQUEST", "规划候选需要明确作者决定")
|
||||
item, req = 读候选(连, 身份.作者, 命令.业务请求.candidate_id)
|
||||
_, content, _, _, _, sources = 核验规划(连, 身份.作者, req, 引用解析=self.引用解析)
|
||||
if 候选哈希(req, content, sources) != item["candidate_hash"]:
|
||||
raise 作品错误("SOURCE_STALE", "规划候选的内容或必要来源已变化")
|
||||
return 提交计划(
|
||||
(参与操作("B01.write_plan", 规划写入(身份.作者, req)),),
|
||||
(修改影响(当前.目标, "plan_changed", str(当前.数据版本), str(当前.数据版本 + 1)),),
|
||||
)
|
||||
|
||||
|
||||
class 规划参与者:
|
||||
身份 = "B01.write_plan"
|
||||
请求类型 = 规划写入
|
||||
|
||||
def __init__(self, 引用解析=None):
|
||||
self.引用解析 = 引用解析
|
||||
|
||||
def 提交(self, 连, 操作):
|
||||
req = 操作.请求
|
||||
id_, content, schema, type_, _, sources = 核验规划(
|
||||
连, 操作.作者, req, 引用解析=self.引用解析
|
||||
)
|
||||
meta = 元数据服务(连)
|
||||
target = "plan:" + id_
|
||||
binding = (
|
||||
meta.建立初始绑定(target, req.work_id, schema)
|
||||
if 规划存储(连).读取(id_) is None
|
||||
else meta.读取绑定(target)
|
||||
)
|
||||
meta.保护提交(
|
||||
target,
|
||||
req.schema.effective_schema_hash,
|
||||
req.schema.projection_version,
|
||||
规划授权(操作.作者, req.work_id),
|
||||
字段用途名="userEditable",
|
||||
内容用途="planning",
|
||||
运行用途="production",
|
||||
)
|
||||
chapter = 规划目标(连, req, type_, 读取作者目录(连, 操作.作者, req.work_id))
|
||||
return 规划存储(连).写规划(
|
||||
id_,
|
||||
req,
|
||||
type_.type_id,
|
||||
content,
|
||||
binding,
|
||||
sources,
|
||||
操作.作者,
|
||||
固定哈希(content),
|
||||
chapter["chapter_id"] if chapter else None,
|
||||
)
|
||||
|
||||
|
||||
class 规划依赖:
|
||||
种类 = "B01.plan"
|
||||
|
||||
def 锁定当前(self, 连, 对象ID):
|
||||
if not 对象ID.startswith("plan:"):
|
||||
raise 作品错误("SCOPE_DENIED", "不是规划身份")
|
||||
return str(规划存储(连).锁规划(对象ID.removeprefix("plan:")))
|
||||
|
||||
|
||||
class 规划候选依赖:
|
||||
种类 = "B01.plan_candidate"
|
||||
|
||||
def 锁定当前(self, 连, 对象ID):
|
||||
return str(规划存储(连).锁候选(对象ID))
|
||||
|
||||
|
||||
def 登记规划参与者(目录, 引用解析=None):
|
||||
目录.登记入口(规划候选入口(引用解析))
|
||||
目录.登记入口(规划决定入口(引用解析))
|
||||
目录.登记参与者(规划候选参与者(引用解析))
|
||||
目录.登记参与者(规划参与者(引用解析))
|
||||
目录.登记依赖(规划依赖())
|
||||
目录.登记依赖(规划候选依赖())
|
||||
|
||||
|
||||
class 规划服务:
|
||||
def __init__(self, 数据库, 正式, 引用解析=None):
|
||||
self.引用解析 = 引用解析
|
||||
self.数据库, self.正式 = 数据库, 正式
|
||||
|
||||
def 读取表单(
|
||||
self,
|
||||
身份,
|
||||
work_id: str,
|
||||
schema_id: str,
|
||||
版本: int,
|
||||
*,
|
||||
chapter_id: str | None = None,
|
||||
node_id: str | None = None,
|
||||
):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
directory = 读取作者目录(连, 身份.作者, work_id)
|
||||
choice = 档案结构(schema_id, 版本, "", "")
|
||||
_, type_, _ = 读取规划结构(连, 身份.作者, work_id, choice)
|
||||
target = 规划保存(work_id, 0, choice, {}, chapter_id=chapter_id, node_id=node_id)
|
||||
chapter = 规划目标(连, target, type_, directory)
|
||||
id_ = 规划身份(target, type_.type_id)
|
||||
current = 规划存储(连).读取(id_)
|
||||
if current:
|
||||
saved = current["schema_binding"]
|
||||
choice = 档案结构(
|
||||
saved["schema_id"],
|
||||
saved["base_version"],
|
||||
saved["effective_schema_hash"],
|
||||
"",
|
||||
saved["extension_version"],
|
||||
)
|
||||
schema, _, editable = 读取规划结构(连, 身份.作者, work_id, choice)
|
||||
_, _, visible = 读取规划结构(连, 身份.作者, work_id, choice, "uiVisible")
|
||||
return {
|
||||
"work_id": work_id,
|
||||
"chapter_id": chapter["chapter_id"] if chapter else None,
|
||||
"node_id": node_id or chapter_id,
|
||||
"plan_id": id_,
|
||||
"revision": current["revision"] if current else 0,
|
||||
"schema_binding": asdict(
|
||||
档案结构(
|
||||
choice.schema_id,
|
||||
choice.base_version,
|
||||
schema.effective_schema_hash,
|
||||
editable.projection_version,
|
||||
choice.extension_version,
|
||||
)
|
||||
),
|
||||
"content": 投影值(visible, current["content"] if current else {}),
|
||||
"visible_schema": 生成结构合同(visible),
|
||||
"editable_schema": 生成结构合同(editable),
|
||||
}
|
||||
|
||||
def 列出候选(self, 身份, work_id: str):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
读取作者目录(连, 身份.作者, work_id)
|
||||
ids = 规划存储(连).列出候选(work_id)
|
||||
return [self.读取候选(身份, id_) for id_ in ids]
|
||||
|
||||
def 新规划结构(self, 身份, work_id, schema_id, 版本, *, 扩展版本=None):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
读取作者目录(连, 身份.作者, work_id)
|
||||
schema, _, projection = 读取规划结构(
|
||||
连, 身份.作者, work_id, 档案结构(schema_id, 版本, "", "", 扩展版本)
|
||||
)
|
||||
return 档案结构(
|
||||
schema_id,
|
||||
版本,
|
||||
schema.effective_schema_hash,
|
||||
projection.projection_version,
|
||||
扩展版本,
|
||||
)
|
||||
|
||||
def 保存人工候选(self, 身份, 命令ID, 请求):
|
||||
id_ = str(uuid5(NAMESPACE_URL, "muse:B01:plan-candidate:" + 固定哈希([身份.作者, 命令ID])))
|
||||
return self.正式.提交变更(
|
||||
身份,
|
||||
变更命令(命令ID, "B01.propose_plan", id_, 作者动作.人工保存, 0, 候选保存(id_, 请求)),
|
||||
)
|
||||
|
||||
def 编辑候选(self, 身份, 命令ID, 定位: 规划定位, 改动: tuple[字段变更, ...]):
|
||||
return self.正式.提交变更(
|
||||
身份,
|
||||
变更命令(
|
||||
命令ID,
|
||||
"B01.propose_plan",
|
||||
定位.candidate_id,
|
||||
作者动作.人工保存,
|
||||
定位.candidate_revision,
|
||||
候选保存(定位.candidate_id, 前版=定位, 改动=改动),
|
||||
),
|
||||
)
|
||||
|
||||
def 读取候选(self, 身份, candidate_id, *, 版本: int | None = None):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
item, req = 读候选(连, 身份.作者, candidate_id, 版本)
|
||||
_, _, projection = 读取规划结构(连, 身份.作者, req.work_id, req.schema, "uiVisible")
|
||||
_, _, editable = 读取规划结构(连, 身份.作者, req.work_id, req.schema)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"plan_id": item["plan_id"],
|
||||
"revision": item["revision"],
|
||||
"candidate_hash": item["candidate_hash"],
|
||||
"work_id": req.work_id,
|
||||
"chapter_id": req.chapter_id,
|
||||
"node_id": req.node_id,
|
||||
"expected_revision": req.expected_revision,
|
||||
"schema_binding": asdict(req.schema),
|
||||
"content": 投影值(projection, item["content"]),
|
||||
"decision": 读取作者决定(连, candidate_id, item["revision"]),
|
||||
"visible_schema": 生成结构合同(projection),
|
||||
"editable_schema": 生成结构合同(editable),
|
||||
}
|
||||
|
||||
def 打开审阅(self, 身份, 定位):
|
||||
review = self.正式.打开审阅(
|
||||
身份,
|
||||
变更命令(
|
||||
"review",
|
||||
"B01.decide_plan",
|
||||
定位.target_ref,
|
||||
作者动作.采纳,
|
||||
定位.expected_revision,
|
||||
定位,
|
||||
),
|
||||
)
|
||||
base = review["snapshot"]["数据版本"]
|
||||
return {
|
||||
**review,
|
||||
"candidate": self.读取候选(
|
||||
身份, 定位.candidate_id, 版本=review["snapshot"]["候选版本"]
|
||||
),
|
||||
"before": self.读取规划(身份, 定位.plan_id, 版本=base) if base else None,
|
||||
}
|
||||
|
||||
def 决定(self, 身份, 命令ID, 定位, 动作, 审阅ID, 审阅哈希, 批准):
|
||||
return self.正式.提交变更(
|
||||
身份,
|
||||
变更命令(
|
||||
命令ID,
|
||||
"B01.decide_plan",
|
||||
定位.target_ref,
|
||||
动作,
|
||||
定位.expected_revision,
|
||||
定位,
|
||||
审阅ID,
|
||||
审阅哈希,
|
||||
批准,
|
||||
),
|
||||
)
|
||||
|
||||
def 读取规划(self, 身份, plan_id, *, 版本: int | None = None):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
item = 规划存储(连).读取(plan_id, 版本)
|
||||
if item is None:
|
||||
raise 作品错误("PLAN_NOT_FOUND", "正式规划不存在")
|
||||
读取作者目录(连, 身份.作者, item["work_id"])
|
||||
return self._呈现规划(连, 身份, item)
|
||||
|
||||
def _呈现规划(self, 连, 身份, item):
|
||||
content = 解析规划载荷(item["content"])
|
||||
if 固定哈希(content) != item["content_hash"]:
|
||||
raise 作品错误("INVALID_OUTPUT", "规划内容与保存哈希不一致")
|
||||
req = TypeAdapter(规划保存).validate_python(item["request"])
|
||||
_, _, projection = 读取规划结构(连, 身份.作者, req.work_id, req.schema, "uiVisible")
|
||||
return {
|
||||
"plan_id": item["plan_id"],
|
||||
"work_id": item["work_id"],
|
||||
"chapter_id": item["chapter_id"],
|
||||
"node_id": item["node_id"],
|
||||
"type_id": item["type_id"],
|
||||
"revision": item["revision"],
|
||||
"content": 投影值(projection, content),
|
||||
"schema_binding": item["schema_binding"],
|
||||
"source_dependencies": item["source_dependencies"],
|
||||
}
|
||||
|
||||
def 读取可用细纲(self, 身份, work_id: str, chapter_id: str):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
item = 读取已确认细纲(连, 身份.作者, work_id, chapter_id, self.引用解析)
|
||||
return self._呈现规划(连, 身份, item)
|
||||
|
||||
def 列出规划(self, 身份, work_id):
|
||||
核对作者(身份)
|
||||
with self.数据库.连接(只读=True) as 连:
|
||||
读取作者目录(连, 身份.作者, work_id)
|
||||
ids = 规划存储(连).列出(work_id)
|
||||
return [self.读取规划(身份, id_) for id_ in ids]
|
||||
17
src/muse/作品规划/近期细化.py
Normal file
17
src/muse/作品规划/近期细化.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""近期细纲只复用当前有效的已确认规划;新细化由持久任务执行。"""
|
||||
|
||||
from muse.作品规划.分层规划 import 核对可用规划
|
||||
from muse.作品规划.存储 import 规划存储
|
||||
from muse.作品规划.模型 import 作品错误
|
||||
from muse.作品规划.目录调整 import 读取作者目录
|
||||
|
||||
|
||||
def 读取已确认细纲(连, 作者: str, work_id: str, chapter_id: str, 引用解析=None) -> dict:
|
||||
目录 = 读取作者目录(连, 作者, work_id)
|
||||
if chapter_id not in {c["chapter_id"] for c in 目录["chapters"]}:
|
||||
raise 作品错误("SCOPE_DENIED", "目标章节不属于本书")
|
||||
item = 规划存储(连).查章规划(work_id, chapter_id, "fine_outline")
|
||||
if item is None:
|
||||
raise 作品错误("PRECONDITION_PENDING", "目标章节还没有已确认细纲")
|
||||
核对可用规划(连, 作者, item, 引用解析=引用解析, 消费用途="generation")
|
||||
return item
|
||||
516
tests/单元/test_探索交接与规划.py
Normal file
516
tests/单元/test_探索交接与规划.py
Normal file
@ -0,0 +1,516 @@
|
||||
"""私人探索与正式交接分开;局部草稿不受最终目录约束。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from muse.作品规划.接口 import 作品错误, 探索服务, 校验交付格式
|
||||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||||
|
||||
|
||||
def 作者(id_="author"):
|
||||
return 调用身份(id_, None, 用途.生产, 内容用途.规划)
|
||||
|
||||
|
||||
def test_私人稿保存原话且并发冲突不覆盖__ad1001(tmp_path):
|
||||
服务 = 探索服务(tmp_path / "私人探索")
|
||||
首次 = 服务.保存(作者(), "umbrella", "雨伞", "哥哥没回来。\n妹妹望着门口的伞。", None)
|
||||
assert 首次["revision"] == 1
|
||||
assert 服务.读取(作者(), "umbrella")["content"] == "哥哥没回来。\n妹妹望着门口的伞。"
|
||||
assert 服务.列出(作者("second")) == []
|
||||
with pytest.raises(作品错误):
|
||||
服务.读取(作者("second"), "umbrella")
|
||||
|
||||
def 修改(正文):
|
||||
try:
|
||||
return 服务.保存(作者(), "umbrella", "雨伞", 正文, 首次["draft_hash"])
|
||||
except 作品错误 as exc:
|
||||
return exc.错误码
|
||||
|
||||
with ThreadPoolExecutor(2) as pool:
|
||||
结果 = list(pool.map(修改, ["她把伞撑开。", "她把伞收起。"]))
|
||||
assert sum(isinstance(x, dict) for x in 结果) == 1
|
||||
assert "REVISION_CONFLICT" in 结果
|
||||
当前 = 服务.读取(作者(), "umbrella")
|
||||
assert 当前["revision"] == 2
|
||||
# 同一内容的未知返回可以重试,不额外产生版本。
|
||||
assert 服务.保存(作者(), "umbrella", 当前["title"], 当前["content"], 首次["draft_hash"]) == 当前
|
||||
|
||||
|
||||
def test_探索不先查最终格式且检查不产生正式内容__ad1002(tmp_path):
|
||||
服务 = 探索服务(tmp_path / "私人探索")
|
||||
稿 = 服务.保存(作者(), "first-fragment", "只有一个动作", "有人敲门。", None)
|
||||
检查 = 服务.检查交付(作者(), "first-fragment", 稿["draft_hash"])
|
||||
assert not 检查["valid"] and 检查["problems"]
|
||||
assert 服务.读取(作者(), "first-fragment") == 稿
|
||||
assert [v["draft_id"] for v in 服务.列出(作者())] == ["first-fragment"]
|
||||
|
||||
|
||||
def test_局部展开的最终稿不设编号和篇幅门__61c0a6():
|
||||
assert 校验交付格式(原探索稿)["valid"]
|
||||
|
||||
|
||||
def test_不同探索稿可展开不同子集__8eed6e():
|
||||
展开 = 原探索稿.replace(
|
||||
"待定:先从人物行动中寻找中心。",
|
||||
"### 1.1 作品形态与定位\n\n### 1.1.1 题材、载体与目标读者\n\n暂定现实题材。",
|
||||
)
|
||||
assert all(校验交付格式(s)["valid"] for s in (原探索稿, 展开))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("场景", ["unknown", "missing", "reordered"])
|
||||
def test_最终导航标题未知缺失或倒序拒绝__c6ad42(场景):
|
||||
输入 = {
|
||||
"unknown": 原探索稿.replace("## 三、角色与关系", "## 三、人物"),
|
||||
"missing": 原探索稿.replace("## 三、角色与关系\n", ""),
|
||||
"reordered": 原探索稿.replace(
|
||||
"待定:先从人物行动中寻找中心。",
|
||||
"### 1.2 故事承诺与一句话梗概\n\n设想。\n\n### 1.1 作品形态与定位\n\n设想。",
|
||||
),
|
||||
}[场景]
|
||||
assert "TREE_MISMATCH" in {v["code"] for v in 校验交付格式(输入)["problems"]}
|
||||
|
||||
|
||||
def test_空节和裸占位拒绝而明确未知允许__64d9e4():
|
||||
for 原, 替换, 错误码 in [
|
||||
("未探索:尚未讨论社会制度。", "<!-- 留空 -->", "SECTION_EMPTY"),
|
||||
("待定:哥哥为何留着伞。", "TODO", "CANDIDATE_PLACEHOLDER"),
|
||||
]:
|
||||
assert 错误码 in {v["code"] for v in 校验交付格式(原探索稿.replace(原, 替换))["problems"]}
|
||||
assert 校验交付格式(原探索稿)["valid"]
|
||||
|
||||
|
||||
def test_可选编号无冻结号段但必须唯一__ec3fc3():
|
||||
编号稿 = 原探索稿.replace("哥哥每天", "- **S090|哥哥:** 哥哥每天").replace(
|
||||
"先从动作里表现关系。", "- **S003|表达:** 先从动作里表现关系。"
|
||||
)
|
||||
assert 校验交付格式(编号稿)["valid"]
|
||||
assert "SETTING_ID_DUPLICATE" in {
|
||||
v["code"] for v in 校验交付格式(编号稿.replace("S003", "S090"))["problems"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("场景", ["no-title", "deep-heading"])
|
||||
def test_最终稿缺标题或标题过深拒绝__1d35b4(场景):
|
||||
内容 = (
|
||||
原探索稿.replace("# 雨伞|作品探索\n", "")
|
||||
if 场景 == "no-title"
|
||||
else 原探索稿 + "\n#### 额外层级\n说明。"
|
||||
)
|
||||
assert "CANDIDATE_STRUCTURE" in {v["code"] for v in 校验交付格式(内容)["problems"]}
|
||||
|
||||
|
||||
def test_缺失私人稿不会得到交付成功__3a6d20(tmp_path):
|
||||
with pytest.raises(作品错误) as 错:
|
||||
探索服务(tmp_path / "私人探索").读取(作者(), "missing")
|
||||
assert 错.value.错误码 == "EXPLORATION_NOT_FOUND"
|
||||
|
||||
|
||||
def test_HTTP与CLI保存探索不访问数据库__ad1003(tmp_path):
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
from muse.配置 import 读取配置
|
||||
|
||||
secret = tmp_path / "口令.txt"
|
||||
secret.write_text("synthetic-exploration-password")
|
||||
secret.chmod(0o600)
|
||||
cfg = tmp_path / "作者.toml"
|
||||
cfg.write_text(
|
||||
'["数据库"]\n"取值方式"="受控存储"\n"位置"="missing-database.txt"\n["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n"公开地址"="http://testserver"\n"允许来源"=["http://testserver"]\n["文件"]\n"探索草稿"="私人探索"\n'
|
||||
)
|
||||
app = 创建应用(读取配置(cfg))
|
||||
with TestClient(app, headers={"origin": "http://testserver"}) as client:
|
||||
assert client.get("/api/v1/explorations").status_code == 401
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/session", json={"password": "synthetic-exploration-password"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
req = {
|
||||
"draft_id": "http-draft",
|
||||
"title": "原话",
|
||||
"content": "只是一个念头。",
|
||||
"expected_hash": None,
|
||||
}
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/explorations/http-draft", json={**req, "author_id": "forged"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
saved = client.post("/api/v1/explorations/http-draft", json=req)
|
||||
assert saved.status_code == 200, saved.text
|
||||
assert client.get("/api/v1/explorations").json()[0]["title"] == "原话"
|
||||
checked = client.post(
|
||||
"/api/v1/explorations/http-draft/check",
|
||||
json={"expected_hash": saved.json()["draft_hash"]},
|
||||
)
|
||||
assert checked.status_code == 200 and not checked.json()["valid"]
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "探索", str(cfg), "查看", "http-draft"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == saved.json()
|
||||
invalid = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"muse",
|
||||
"探索",
|
||||
str(cfg),
|
||||
"检查",
|
||||
"http-draft",
|
||||
"--预期哈希",
|
||||
saved.json()["draft_hash"],
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert invalid.returncode == 1 and not json.loads(invalid.stdout)["valid"]
|
||||
|
||||
|
||||
# 保留LC原始探索输入,不借旧测试的导入和运行顺序。
|
||||
原探索稿 = """# 雨伞|作品探索
|
||||
|
||||
## 根设定
|
||||
作者希望保留雨天的兄妹关系。
|
||||
|
||||
## 一、作品定位与核心命题
|
||||
待定:先从人物行动中寻找中心。
|
||||
|
||||
## 二、故事世界与社会系统
|
||||
未探索:尚未讨论社会制度。
|
||||
|
||||
## 三、角色与关系
|
||||
哥哥每天擦一把从不用的伞,妹妹故意把它拿走。
|
||||
|
||||
## 四、故事机制与关键实体
|
||||
未探索:尚未决定是否有超自然力量。
|
||||
|
||||
## 五、阶段与状态变化
|
||||
未探索:只有当前这一幕。
|
||||
|
||||
## 六、情节架构与全书大纲
|
||||
未探索:兄妹争执后的事情还没想好。
|
||||
|
||||
## 七、主题与读者体验弧线
|
||||
暂想保留亲近中带着疑问的感觉。
|
||||
|
||||
## 八、信息、悬念与伏笔管理
|
||||
待定:哥哥为何留着伞。
|
||||
|
||||
## 九、叙事执行与一致性
|
||||
先从动作里表现关系。
|
||||
|
||||
## 待决与取舍
|
||||
保留争执这一幕,暂缓决定雨伞的来历。
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"场景,输入",
|
||||
[
|
||||
("missing", None),
|
||||
("unconfirmed", False),
|
||||
("string-false", "false"),
|
||||
("string-true", "true"),
|
||||
("user-string", "user"),
|
||||
("integer-true", 1),
|
||||
("list-true", ["true"]),
|
||||
("empty-id", ""),
|
||||
("list-id", ["x"]),
|
||||
("blank-time", " "),
|
||||
],
|
||||
ids=[
|
||||
"missing",
|
||||
"unconfirmed",
|
||||
"string-false",
|
||||
"string-true",
|
||||
"user-string",
|
||||
"integer-true",
|
||||
"list-true",
|
||||
"empty-id",
|
||||
"list-id",
|
||||
"blank-time",
|
||||
],
|
||||
)
|
||||
def test_选定输入不能自报确认时间或缺失稿件__ad1004(场景, 输入):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from muse.接入.创作操作 import 探索选定请求
|
||||
|
||||
selection = {
|
||||
"work_id": "work",
|
||||
"draft_id": "x",
|
||||
"expected_hash": "source-hash",
|
||||
"expected_revision": 0,
|
||||
}
|
||||
if 场景 == "missing":
|
||||
selection = {}
|
||||
elif 场景 in {"empty-id", "list-id"}:
|
||||
selection["draft_id"] = 输入
|
||||
elif 场景 == "blank-time":
|
||||
selection["confirmedAt"] = 输入
|
||||
else:
|
||||
selection["confirmedByUser"] = 输入
|
||||
with pytest.raises((ValidationError, 作品错误)):
|
||||
探索选定请求.model_validate({"command_id": "select-direction", "selection": selection})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# W13 承接:作者旅程、细纲合同与失败边界(原 AuthorInstruction/FineOutlineContract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
作者根 = Path(__file__).resolve().parents[2] / ".agent" / "作者"
|
||||
仓库根 = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_自然语言旅程到达作者决定__aab5b6():
|
||||
"""路由表命中六大场景;最小旅程按序推进,最终决定交给新版候选决定技能。"""
|
||||
import re
|
||||
|
||||
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
|
||||
路由 = re.search(
|
||||
r"^## 自然语言路由\s*$\n(?P<body>.*?)(?=^## )",
|
||||
入口,
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
).group("body")
|
||||
for 说法, 场景 in (
|
||||
("我只有一个点子", "定故事"),
|
||||
("帮我排一下整本书", "排故事"),
|
||||
("这个人物还站不住", "塑人物"),
|
||||
("接着写下一章", "写下一章"),
|
||||
("把这段改顺", "改正文"),
|
||||
("看看这部作品哪里有问题", "查作品"),
|
||||
):
|
||||
assert re.search(rf"\|\s*{re.escape(说法)}\s*\|\s*\[{re.escape(场景)}\]", 路由)
|
||||
|
||||
旅程 = re.search(
|
||||
r"^## 最小作者旅程\s*$\n(?P<body>.*?)(?=^## |\Z)",
|
||||
入口,
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
).group("body")
|
||||
顺序 = re.findall(r"\[([^\]]+)\]\(场景/[^)]+\)", 旅程)
|
||||
assert 顺序 == ["定故事", "排故事", "写下一章", "查作品", "改正文"]
|
||||
决定 = re.search(r"\[决定正文候选去留\]\(([^)]+)\)", 旅程)
|
||||
assert 决定 and "skills/操作/决定正文候选去留/SKILL.md" in 决定.group(1)
|
||||
for 动作 in ("采用", "丢弃", "修改"):
|
||||
assert f"`{动作}`" in 旅程
|
||||
|
||||
|
||||
def _细纲结构():
|
||||
from muse.元数据.接口 import 合成结构, 读取内置结构
|
||||
|
||||
return 合成结构(读取内置结构("fine_outline"))
|
||||
|
||||
|
||||
def _完整细纲值():
|
||||
return {
|
||||
"targetChapter": 3,
|
||||
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
|
||||
"chapterGoal": "本章戏剧任务",
|
||||
"hardConstraints": ["衔接上一章结尾"],
|
||||
"keyEvents": [
|
||||
{
|
||||
"id": "e1",
|
||||
"order": 1,
|
||||
"event": "事件",
|
||||
"participants": ["林深"],
|
||||
"trigger": "触发",
|
||||
"resultDirection": "结果方向",
|
||||
}
|
||||
],
|
||||
"adjustableBeats": ["次序可调整"],
|
||||
"declaredNewFacts": [
|
||||
{
|
||||
"factId": "f1",
|
||||
"text": "新事实",
|
||||
"sourceRef": {"sourceId": "outline:v1", "sourceVersion": 1},
|
||||
}
|
||||
],
|
||||
"mustAppearEntities": ["林深"],
|
||||
"chapterEndHook": "章末钩子",
|
||||
}
|
||||
|
||||
|
||||
def test_细纲技能引用结构为字段权威__aaae60():
|
||||
"""字段权威是S04结构:合同由结构生成并带字段身份;操作技能不再自持字段清单。"""
|
||||
from muse.元数据.接口 import 生成结构合同
|
||||
|
||||
结构 = _细纲结构()
|
||||
合同 = 生成结构合同(结构)
|
||||
assert 合同["required"], "细纲合同必须声明必填字段"
|
||||
for 字段 in ("targetChapter", "keyEvents", "chapterEndHook"):
|
||||
属性 = 合同["properties"][字段]
|
||||
assert 属性["x-field-id"].startswith("fine_outline."), 字段
|
||||
assert 属性["title"], f"{字段} 缺中文名"
|
||||
技能 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for 字段名 in ("targetChapter", "chapterGoal", "hardConstraints"):
|
||||
assert 字段名 not in 技能, "操作技能不得自持细纲字段清单,字段权威在结构"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"缺失字段",
|
||||
[
|
||||
"targetChapter",
|
||||
"sourceRef",
|
||||
"chapterGoal",
|
||||
"hardConstraints",
|
||||
"keyEvents",
|
||||
"adjustableBeats",
|
||||
"declaredNewFacts",
|
||||
"mustAppearEntities",
|
||||
"chapterEndHook",
|
||||
],
|
||||
)
|
||||
def test_细纲必填字段齐全且缺一拒绝__759215(缺失字段):
|
||||
from muse.元数据.接口 import 校验值, 生成结构合同
|
||||
from muse.元数据.模型 import 元数据错误
|
||||
|
||||
结构 = _细纲结构()
|
||||
合同 = 生成结构合同(结构)
|
||||
assert set(合同["required"]) == {
|
||||
"targetChapter",
|
||||
"sourceRef",
|
||||
"chapterGoal",
|
||||
"hardConstraints",
|
||||
"keyEvents",
|
||||
"adjustableBeats",
|
||||
"declaredNewFacts",
|
||||
"mustAppearEntities",
|
||||
"chapterEndHook",
|
||||
}
|
||||
值 = {k: v for k, v in _完整细纲值().items() if k != 缺失字段}
|
||||
with pytest.raises(元数据错误) as 错:
|
||||
校验值(结构, 值)
|
||||
assert 错.value.错误码 == "FIELD_INVALID"
|
||||
assert 缺失字段 in 错.value.呈现()["details"]["字段"]
|
||||
|
||||
|
||||
def test_细纲推荐字段缺失不拦类型错误拒__0766da():
|
||||
from muse.元数据.接口 import 校验值
|
||||
from muse.元数据.模型 import 元数据错误
|
||||
|
||||
结构 = _细纲结构()
|
||||
结果 = 校验值(结构, _完整细纲值())
|
||||
assert 结果.值["targetChapter"] == 3 # 推荐字段全部缺失仍通过
|
||||
with pytest.raises(元数据错误) as 错:
|
||||
校验值(结构, {**_完整细纲值(), "unknowns": "不是列表"})
|
||||
assert 错.value.错误码 == "FIELD_INVALID"
|
||||
|
||||
|
||||
def test_阴影边界与红线明确__a5ee39():
|
||||
"""候选保持Shadow;正文与规划决定入口分离;决定必须引用被审版本。"""
|
||||
决定正文 = (仓库根 / ".agent" / "skills" / "操作" / "决定正文候选去留" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "不能自行填补或改用最新版本" in 决定正文
|
||||
assert "必须重新展示再决定" in 决定正文
|
||||
assert "不代替正文或故事事实的独立确认" in 确认规划
|
||||
assert "不自动刷新成最新版本后重试采纳" in 确认规划
|
||||
# 作者层红线:候选未经作者明确确认不进入正式内容。
|
||||
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
|
||||
assert "不进入 Canonical" in 入口 and "主会话不代替角色" in 入口
|
||||
|
||||
|
||||
def test_冻结与来源两行规则明确__9bb829():
|
||||
"""审阅绑定确切版本与哈希;检索呈现内容与来源版本两份信息。"""
|
||||
from muse.元数据.接口 import 投影字段, 授权摘要, 策略快照
|
||||
|
||||
确认规划 = (仓库根 / ".agent" / "skills" / "操作" / "确认规划候选" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for 锚 in ("candidate_hash", "review_hash", "expected_revision"):
|
||||
assert 锚 in 确认规划, f"确认规划缺少冻结锚点 {锚}"
|
||||
结构 = _细纲结构()
|
||||
投影 = 投影字段(
|
||||
结构,
|
||||
策略快照("fine_outline", 1),
|
||||
授权摘要("a1", "s1"),
|
||||
字段用途名="aiContext",
|
||||
内容用途="generation",
|
||||
运行用途="production",
|
||||
)
|
||||
# 检索规则:细纲进入模型上下文的字段带来源引用,正文不在细纲内自造。
|
||||
键 = {f.key for f in 投影.允许字段}
|
||||
assert {"targetChapter", "chapterGoal", "keyEvents", "sourceRef"} <= 键
|
||||
assert not any("正文" in k or k == "content" for k in 键), "细纲检索不得携带正文字段"
|
||||
|
||||
|
||||
def test_CLI失败可识别且不转为成功__1f5c8c(tmp_path):
|
||||
"""版本冲突与非法输入经CLI返回非零与稳定错误码,不静默成功。"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
cfg = tmp_path / "作者.toml"
|
||||
cfg.write_text(
|
||||
'["数据库"]\n"取值方式"="受控存储"\n"位置"="missing-database.txt"\n'
|
||||
'["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n'
|
||||
'"公开地址"="http://localhost"\n"允许来源"=["http://localhost"]\n'
|
||||
'["文件"]\n"探索草稿"="私人探索"\n'
|
||||
)
|
||||
(tmp_path / "口令.txt").write_text("synthetic")
|
||||
请求 = tmp_path / "保存.json"
|
||||
首次 = {
|
||||
"draft_id": "cli-draft",
|
||||
"title": "伞",
|
||||
"content": "第一稿。",
|
||||
"expected_hash": None,
|
||||
}
|
||||
请求.write_text(json.dumps(首次, ensure_ascii=False))
|
||||
保存 = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert 保存.returncode == 0, 保存.stderr
|
||||
冲突 = {**首次, "content": "第二稿。", "expected_hash": "stale-hash"}
|
||||
请求.write_text(json.dumps(冲突, ensure_ascii=False))
|
||||
失败 = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "探索", str(cfg), "保存", str(请求)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert 失败.returncode == 1, (失败.stdout, 失败.stderr)
|
||||
呈现 = json.loads(失败.stderr)
|
||||
assert 呈现["code"] == "REVISION_CONFLICT"
|
||||
缺稿 = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "探索", str(cfg), "查看", "missing-draft"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert 缺稿.returncode == 1
|
||||
assert json.loads(缺稿.stderr)["code"] == "EXPLORATION_NOT_FOUND"
|
||||
|
||||
|
||||
def test_细纲合同绑定既有规划槽位__d55c2d():
|
||||
"""细纲归属B01规划族并由规划员角色承担;不再靠旧技能文档字符串绑定。"""
|
||||
import json as _json
|
||||
|
||||
from muse.元数据.接口 import 读取内置种子, 选择实例维护者
|
||||
|
||||
类型 = next(s.类型 for s in 读取内置种子() if s.类型.type_id == "fine_outline")
|
||||
assert "planning" in 类型.实例族
|
||||
assert 选择实例维护者(类型, "planning") == "B01"
|
||||
登记 = _json.loads((仓库根 / "工具" / "资源登记.json").read_text(encoding="utf-8"))
|
||||
角色文件 = 仓库根 / ".agent" / "角色" / "规划员.md"
|
||||
assert ".agent/角色/规划员.md" in 登记["能力文件"]
|
||||
头部 = 角色文件.read_text(encoding="utf-8").split("---")[1]
|
||||
assert "id: planner" in 头部
|
||||
正文 = 角色文件.read_text(encoding="utf-8")
|
||||
assert "规划候选" in 正文 and "不替正文" in 正文
|
||||
176
tests/集成/test_探索选定与规划.py
Normal file
176
tests/集成/test_探索选定与规划.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""探索方向的真实作者动作与PG回执;选择方向不确认正式规划。"""
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from muse.作品规划.接口 import 作品错误, 探索选择, 档案保存
|
||||
from muse.元数据.接口 import 元数据服务, 导入内置结构
|
||||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||||
from muse.启动 import 构建
|
||||
from muse.资源加载 import 打开资源
|
||||
from muse.配置 import 应用配置
|
||||
|
||||
pytestmark = pytest.mark.数据库
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def 交接环境(应用测试库, tmp_path):
|
||||
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
|
||||
导入内置结构(元数据服务(连))
|
||||
app = 构建(
|
||||
应用配置(应用测试库[用途.生产].引用, "synthetic", 探索草稿=str(tmp_path / "私人探索"))
|
||||
)
|
||||
actor = 调用身份("author", None, 用途.生产, 内容用途.规划)
|
||||
profile = app.要求作品().新档案结构(actor, "selection-work", "work_core", 1)
|
||||
app.要求作品().保存档案(
|
||||
actor, "create-work", 档案保存("selection-work", 0, {"名称": "合成作品"}, profile)
|
||||
)
|
||||
tree = json.loads(打开资源("规划/探索交付结构.json"))
|
||||
draft = "# 浮岛|作品探索\n\n" + "\n\n".join(
|
||||
"## " + h + "\n未探索:先保留空中城镇停电的画面。"
|
||||
for h in ["根设定", *[c["heading"] for c in tree["chapters"]], "待决与取舍"]
|
||||
)
|
||||
saved = app.要求探索().保存(actor, "island", "浮岛", draft, None)
|
||||
return app, actor, saved, 应用测试库
|
||||
|
||||
|
||||
def test_选定稿冻结来源且重复请求回原回执__ad1201(交接环境):
|
||||
app, actor, draft, _ = 交接环境
|
||||
works = app.要求作品()
|
||||
assert works.读取探索选定(actor, "selection-work")["revision"] == 0
|
||||
req = 探索选择("selection-work", "island", draft["draft_hash"], 0)
|
||||
receipt = works.选定探索稿(actor, "select-direction", req)
|
||||
chosen = works.读取探索选定(actor, "selection-work")
|
||||
assert chosen["revision"] == 1 and chosen["selection"]["snapshot"] == draft
|
||||
assert chosen["selection"]["selected_by"] == "author"
|
||||
app.要求探索().保存(
|
||||
actor, "island", "浮岛", draft["content"] + "\n新的分歧,暂不选。", draft["draft_hash"]
|
||||
)
|
||||
assert works.读取探索选定(actor, "selection-work") == chosen
|
||||
assert works.选定探索稿(actor, "select-direction", req) == receipt
|
||||
with pytest.raises(作品错误) as 错:
|
||||
works.选定探索稿(actor, "select-stale", replace(req, expected_revision=1))
|
||||
assert 错.value.错误码 == "REVISION_CONFLICT"
|
||||
|
||||
|
||||
def test_选定失败完整回滚且私人稿保留__ad1202(交接环境):
|
||||
import psycopg
|
||||
|
||||
app, actor, draft, db = 交接环境
|
||||
with db[用途.维护].连接() as 连, 连.transaction():
|
||||
连.execute(
|
||||
"CREATE FUNCTION reject_selection_receipt() RETURNS trigger LANGUAGE plpgsql AS $$ "
|
||||
"BEGIN RAISE EXCEPTION 'synthetic receipt failure'; END $$"
|
||||
)
|
||||
连.execute(
|
||||
"CREATE TRIGGER reject_selection BEFORE UPDATE OF receipt ON muse_change_command "
|
||||
"FOR EACH ROW EXECUTE FUNCTION reject_selection_receipt()"
|
||||
)
|
||||
request = 探索选择("selection-work", "island", draft["draft_hash"], 0)
|
||||
with pytest.raises(psycopg.Error):
|
||||
app.要求作品().选定探索稿(actor, "retry-selection", request)
|
||||
assert app.要求作品().读取探索选定(actor, "selection-work")["revision"] == 0
|
||||
assert app.要求探索().读取(actor, "island") == draft
|
||||
with db[用途.维护].连接() as 连, 连.transaction():
|
||||
连.execute("DROP TRIGGER reject_selection ON muse_change_command")
|
||||
assert (
|
||||
app.要求作品().选定探索稿(actor, "retry-selection", request)["results"][0]["revision"] == 1
|
||||
)
|
||||
|
||||
|
||||
def test_作者实际选定经HTTP与CLI回查来源__bfcbc2(交接环境, tmp_path):
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
from muse.配置 import 读取配置
|
||||
|
||||
app, actor, draft, db = 交接环境
|
||||
password = tmp_path / "口令.txt"
|
||||
password.write_text("synthetic-selection-password")
|
||||
password.chmod(0o600)
|
||||
cfg = tmp_path / "选定.toml"
|
||||
cfg.write_text(
|
||||
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
||||
+ json.dumps(db[用途.生产].引用.位置)
|
||||
+ '\n["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n"公开地址"="http://testserver"\n"允许来源"=["http://testserver"]\n["文件"]\n"探索草稿"='
|
||||
+ json.dumps(app.配置.探索草稿)
|
||||
+ "\n"
|
||||
)
|
||||
path = "/api/v1/works/selection-work/exploration-selection"
|
||||
with TestClient(创建应用(读取配置(cfg)), headers={"origin": "http://testserver"}) as client:
|
||||
assert client.get(path).status_code == 401
|
||||
client.post("/api/v1/session", json={"password": "synthetic-selection-password"})
|
||||
request = {
|
||||
"command_id": "http-select",
|
||||
"selection": {
|
||||
"work_id": "selection-work",
|
||||
"draft_id": "island",
|
||||
"expected_hash": draft["draft_hash"],
|
||||
"expected_revision": 0,
|
||||
},
|
||||
}
|
||||
assert (
|
||||
client.post(
|
||||
path,
|
||||
json={
|
||||
**request,
|
||||
"confirmedByUser": True,
|
||||
"confirmedAt": "2026-08-20T12:00:00+08:00",
|
||||
},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
result = client.post(path, json=request)
|
||||
assert result.status_code == 200, result.text
|
||||
assert client.post(path, json=request).json() == result.json()
|
||||
selected = client.get(path).json()
|
||||
assert selected["selection"]["snapshot"] == draft
|
||||
assert selected["selection"]["selected_by"] == "author"
|
||||
assert datetime.fromisoformat(selected["selection"]["selected_at"]).tzinfo is not None
|
||||
probe = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "探索", str(cfg), "查看选定", "selection-work"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert probe.returncode == 0, probe.stderr
|
||||
assert json.loads(probe.stdout) == selected
|
||||
with pytest.raises(作品错误) as 错:
|
||||
app.要求作品().读取探索选定(replace(actor, 作者="another-author"), "selection-work")
|
||||
assert 错.value.错误码 == "SCOPE_DENIED"
|
||||
|
||||
|
||||
def test_不同方向同时交接只推进一个选定版本__ad1203(交接环境):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, one, _ = 交接环境
|
||||
two = app.要求探索().保存(
|
||||
actor, "village", "山村", one["content"].replace("空中城镇停电", "山村渡口停航"), None
|
||||
)
|
||||
start = Barrier(2)
|
||||
|
||||
def choose(draft):
|
||||
start.wait(3)
|
||||
try:
|
||||
return app.要求作品().选定探索稿(
|
||||
actor,
|
||||
"choose-" + draft["draft_id"],
|
||||
探索选择("selection-work", draft["draft_id"], draft["draft_hash"], 0),
|
||||
)
|
||||
except Muse错误 as exc:
|
||||
return exc.错误码
|
||||
|
||||
with ThreadPoolExecutor(2) as pool:
|
||||
results = list(pool.map(choose, [one, two]))
|
||||
assert sum(isinstance(r, dict) for r in results) == 1
|
||||
# 预读时已变化为REVISION_CONFLICT;等待S01依赖锁后变化为SOURCE_STALE。
|
||||
assert any(r in {"REVISION_CONFLICT", "SOURCE_STALE"} for r in results if isinstance(r, str))
|
||||
assert app.要求作品().读取探索选定(actor, "selection-work")["revision"] == 1
|
||||
696
tests/集成/test_规划候选与确认.py
Normal file
696
tests/集成/test_规划候选与确认.py
Normal file
@ -0,0 +1,696 @@
|
||||
"""规划字段由S04描述;候选和正式版本通过真实S01事务区分。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from muse.作品规划.接口 import 档案保存, 规划保存, 规划定位
|
||||
from muse.元数据.接口 import 元数据服务, 导入内置结构
|
||||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||||
from muse.启动 import 构建
|
||||
from muse.正式变更.接口 import 作者动作
|
||||
from muse.配置 import 应用配置
|
||||
|
||||
pytestmark = pytest.mark.数据库
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", ["雪中的信", "浮岛航线"], ids=["letter", "floating-island"])
|
||||
def test_同一目录承接卷章场景且移动保持身份__ad1310(规划环境, title):
|
||||
from dataclasses import replace
|
||||
|
||||
from muse.作品规划.接口 import 目录节点, 节点目录保存
|
||||
from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
works = app.要求作品()
|
||||
nodes = (
|
||||
目录节点("volume-a", "volume", title + "·卷一"),
|
||||
目录节点("chapter-a", "chapter", "出发", "volume-a"),
|
||||
目录节点("scene-a", "scene", "有人敲门", "chapter-a"),
|
||||
目录节点("chapter-b", "chapter", "另一条路"),
|
||||
)
|
||||
request = 节点目录保存("planned-work", 0, nodes)
|
||||
receipt = works.保存节点目录(actor, "create-nodes", request)
|
||||
assert receipt == works.保存节点目录(actor, "create-nodes", request)
|
||||
current = works.读取节点目录(actor, "planned-work")
|
||||
assert current["revision"] == 1 and len(current["nodes"]) == 4
|
||||
assert [c["chapter_id"] for c in works.读取目录(actor, "planned-work")["chapters"]] == [
|
||||
"chapter-a",
|
||||
"chapter-b",
|
||||
]
|
||||
prose = app.要求正文()
|
||||
prose.保存人工(
|
||||
actor,
|
||||
"write-before-move",
|
||||
"chapter-a",
|
||||
0,
|
||||
正文草稿((段落("paragraph-a", (文本节点(title + ",有人来了。"),)),)),
|
||||
)
|
||||
before = prose.读取正文(actor, "chapter-a")
|
||||
moved = (nodes[3], nodes[0], nodes[1], replace(nodes[2], parent_id="chapter-b"))
|
||||
works.保存节点目录(actor, "move-nodes", replace(request, expected_revision=1, nodes=moved))
|
||||
latest = works.读取节点目录(actor, "planned-work")
|
||||
assert latest["revision"] == 2
|
||||
assert {n["node_id"] for n in latest["nodes"]} == {n.node_id for n in nodes}
|
||||
assert next(n for n in latest["nodes"] if n["node_id"] == "scene-a")["parent_id"] == "chapter-b"
|
||||
assert [c["chapter_id"] for c in works.读取目录(actor, "planned-work")["chapters"]] == [
|
||||
"chapter-b",
|
||||
"chapter-a",
|
||||
]
|
||||
assert works.读取节点目录(actor, "planned-work", 版本=1) == current
|
||||
assert prose.读取正文(actor, "chapter-a") == before
|
||||
|
||||
|
||||
def test_规划候选编辑追加版本且旧审阅失效__ad1306(规划环境):
|
||||
from muse.元数据.接口 import 字段变更
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
schema = service.新规划结构(actor, "planned-work", "outline", 1)
|
||||
reply = service.保存人工候选(
|
||||
actor,
|
||||
"editable-plan",
|
||||
规划保存("planned-work", 0, schema, {"名称": "未寄出的信", "主线一句话": "寻找收信人"}),
|
||||
)
|
||||
id_ = reply["results"][0]["candidate_id"]
|
||||
old = service.读取候选(actor, id_)
|
||||
loc = 规划定位(id_, 1, old["candidate_hash"], old["plan_id"], 0)
|
||||
review = service.打开审阅(actor, loc)
|
||||
changes = (字段变更(("主线一句话",), "set", "收信人寻找寄信者"),)
|
||||
edited = service.编辑候选(actor, "edit-plan-once", loc, changes)
|
||||
assert edited == service.编辑候选(actor, "edit-plan-once", loc, changes)
|
||||
current = service.读取候选(actor, id_)
|
||||
assert current["revision"] == 2 and current["candidate_hash"] != old["candidate_hash"]
|
||||
assert current["content"] == {"名称": "未寄出的信", "主线一句话": "收信人寻找寄信者"}
|
||||
assert service.读取候选(actor, id_, 版本=1) == old
|
||||
assert service.列出规划(actor, "planned-work") == []
|
||||
with pytest.raises(Muse错误):
|
||||
service.决定(
|
||||
actor,
|
||||
"old-plan-review",
|
||||
loc,
|
||||
作者动作.采纳,
|
||||
review["review_id"],
|
||||
review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
with pytest.raises(Muse错误):
|
||||
service.编辑候选(actor, "stale-edit-plan", loc, changes)
|
||||
new_loc = 规划定位(id_, 2, current["candidate_hash"], current["plan_id"], 0)
|
||||
new_review = service.打开审阅(actor, new_loc)
|
||||
service.决定(
|
||||
actor,
|
||||
"accept-edited-plan",
|
||||
new_loc,
|
||||
作者动作.采纳,
|
||||
new_review["review_id"],
|
||||
new_review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
assert service.读取规划(actor, current["plan_id"])["content"] == current["content"]
|
||||
|
||||
|
||||
def test_卷章场景采用动态结构且章入口共用身份__ad1311(规划环境):
|
||||
from dataclasses import replace
|
||||
|
||||
from muse.作品规划.接口 import 目录节点, 节点目录保存
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
app.要求作品().保存节点目录(
|
||||
actor,
|
||||
"plan-targets",
|
||||
节点目录保存(
|
||||
"planned-work",
|
||||
0,
|
||||
(
|
||||
目录节点("volume", "volume", "卷一"),
|
||||
目录节点("chapter", "chapter", "开门", "volume"),
|
||||
目录节点("scene", "scene", "门外的访客", "chapter"),
|
||||
),
|
||||
),
|
||||
)
|
||||
service = app.要求规划()
|
||||
outline = service.新规划结构(actor, "planned-work", "outline", 1)
|
||||
volume = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
规划保存("planned-work", 0, outline, {"名称": "卷一大纲"}, node_id="volume"),
|
||||
"volume-plan",
|
||||
)
|
||||
scene = service.新规划结构(actor, "planned-work", "scene", 1)
|
||||
saved_scene = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
规划保存(
|
||||
"planned-work",
|
||||
0,
|
||||
scene,
|
||||
{"目标推进收束": "来客想借一盏灯,邮差让他进屋。"},
|
||||
node_id="scene",
|
||||
),
|
||||
"scene-plan",
|
||||
)
|
||||
assert saved_scene["chapter_id"] == "chapter" and saved_scene["node_id"] == "scene"
|
||||
fine = service.新规划结构(actor, "planned-work", "fine_outline", 1)
|
||||
req = 规划保存(
|
||||
"planned-work",
|
||||
0,
|
||||
fine,
|
||||
{**细纲内容(volume["plan_id"]), "targetChapter": 1},
|
||||
node_id="chapter",
|
||||
)
|
||||
first = 保存并确认(service, actor, req, "chapter-node-plan")
|
||||
assert first["chapter_id"] == "chapter"
|
||||
second = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
replace(req, node_id=None, chapter_id="chapter", expected_revision=1),
|
||||
"chapter-legacy-plan",
|
||||
)
|
||||
assert first["plan_id"] == second["plan_id"] and second["revision"] == 2
|
||||
assert service.读取可用细纲(actor, "planned-work", "chapter")["revision"] == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def 规划环境(应用测试库):
|
||||
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
|
||||
导入内置结构(元数据服务(连))
|
||||
app = 构建(应用配置(应用测试库[用途.生产].引用, "synthetic"))
|
||||
actor = 调用身份("author", None, 用途.生产, 内容用途.规划)
|
||||
schema = app.要求作品().新档案结构(actor, "planned-work", "work_core", 1)
|
||||
app.要求作品().保存档案(
|
||||
actor, "create-planned-work", 档案保存("planned-work", 0, {"名称": "雪中的信"}, schema)
|
||||
)
|
||||
return app, actor, 应用测试库
|
||||
|
||||
|
||||
def test_人工规划候选经明确审阅后形成正式版本__ad1301(规划环境):
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
schema = service.新规划结构(actor, "planned-work", "outline", 1)
|
||||
request = 规划保存(
|
||||
"planned-work", 0, schema, {"名称": "雪中的信", "主线一句话": "邮差寻找一封无人认领的信。"}
|
||||
)
|
||||
receipt = service.保存人工候选(actor, "outline-draft", request)
|
||||
candidate = service.读取候选(actor, receipt["results"][0]["candidate_id"])
|
||||
assert service.列出规划(actor, "planned-work") == []
|
||||
location = 规划定位(
|
||||
candidate["candidate_id"],
|
||||
candidate["revision"],
|
||||
candidate["candidate_hash"],
|
||||
candidate["plan_id"],
|
||||
0,
|
||||
)
|
||||
review = service.打开审阅(actor, location)
|
||||
result = service.决定(
|
||||
actor,
|
||||
"confirm-outline",
|
||||
location,
|
||||
作者动作.采纳,
|
||||
review["review_id"],
|
||||
review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
saved = service.读取规划(actor, candidate["plan_id"])
|
||||
assert saved["revision"] == 1 and saved["content"] == request.content
|
||||
assert (
|
||||
service.决定(
|
||||
actor,
|
||||
"confirm-outline",
|
||||
location,
|
||||
作者动作.采纳,
|
||||
review["review_id"],
|
||||
review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
== result
|
||||
)
|
||||
|
||||
|
||||
def test_规划HTTP候选与CLI决定共享版本回执__ad1302(规划环境, tmp_path):
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
from muse.配置 import 读取配置
|
||||
|
||||
_, _, db = 规划环境
|
||||
password = tmp_path / "口令.txt"
|
||||
password.write_text("synthetic-plan-password")
|
||||
password.chmod(0o600)
|
||||
cfg = tmp_path / "规划.toml"
|
||||
cfg.write_text(
|
||||
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
||||
+ json.dumps(db[用途.生产].引用.位置)
|
||||
+ '\n["资源"]\n"发布身份"="synthetic"\n[HTTP]\n"口令文件"="口令.txt"\n"作者ID"="author"\n"公开地址"="http://testserver"\n"允许来源"=["http://testserver"]\n'
|
||||
)
|
||||
with TestClient(创建应用(读取配置(cfg)), headers={"origin": "http://testserver"}) as client:
|
||||
assert (
|
||||
client.post("/api/v1/session", json={"password": "synthetic-plan-password"}).status_code
|
||||
== 200
|
||||
)
|
||||
nodes_request = {
|
||||
"command_id": "http-plan-nodes",
|
||||
"directory": {
|
||||
"work_id": "planned-work",
|
||||
"expected_revision": 0,
|
||||
"nodes": [
|
||||
{"node_id": "vol-http", "kind": "volume", "title": "卷一", "parent_id": None}
|
||||
],
|
||||
},
|
||||
}
|
||||
nodes_response = client.post("/api/v1/works/planned-work/plan-nodes", json=nodes_request)
|
||||
assert nodes_response.status_code == 200, nodes_response.text
|
||||
node_cli = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "作品", str(cfg), "节点目录", "planned-work"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert node_cli.returncode == 0, node_cli.stderr
|
||||
assert (
|
||||
json.loads(node_cli.stdout)
|
||||
== client.get("/api/v1/works/planned-work/plan-nodes").json()
|
||||
)
|
||||
node_path = tmp_path / "节点.json"
|
||||
node_path.write_text(json.dumps(nodes_request, ensure_ascii=False))
|
||||
node_replay = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "作品", str(cfg), "保存节点", str(node_path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert node_replay.returncode == 0, node_replay.stderr
|
||||
assert json.loads(node_replay.stdout) == nodes_response.json()
|
||||
schema = client.get(
|
||||
"/api/v1/works/planned-work/plans/schema",
|
||||
params={"schema_id": "outline", "base_version": 1},
|
||||
).json()
|
||||
request = {
|
||||
"command_id": "http-plan",
|
||||
"request": {
|
||||
"work_id": "planned-work",
|
||||
"expected_revision": 0,
|
||||
"schema": schema,
|
||||
"content": {"名称": "下雪以前"},
|
||||
},
|
||||
}
|
||||
created = client.post("/api/v1/works/planned-work/plans/candidates", json=request)
|
||||
assert created.status_code == 200, created.text
|
||||
id_ = created.json()["results"][0]["candidate_id"]
|
||||
candidate = client.get(f"/api/v1/plan-candidates/{id_}").json()
|
||||
location = {
|
||||
"candidate_id": id_,
|
||||
"candidate_revision": 1,
|
||||
"candidate_hash": candidate["candidate_hash"],
|
||||
"plan_id": candidate["plan_id"],
|
||||
"expected_revision": 0,
|
||||
}
|
||||
edit = {
|
||||
"command_id": "edit-plan-cli",
|
||||
"candidate": location,
|
||||
"changes": [{"路径": ["名称"], "动作": "set", "值": "下雪后的信"}],
|
||||
}
|
||||
edit_path = tmp_path / "编辑.json"
|
||||
edit_path.write_text(json.dumps(edit, ensure_ascii=False))
|
||||
edit_result = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "规划", str(cfg), "编辑候选", str(edit_path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert edit_result.returncode == 0, edit_result.stderr
|
||||
assert client.post(
|
||||
f"/api/v1/plan-candidates/{id_}/versions", json=edit
|
||||
).json() == json.loads(edit_result.stdout)
|
||||
assert client.get(f"/api/v1/plan-candidates/{id_}", params={"version": 1}).json()[
|
||||
"content"
|
||||
] == {"名称": "下雪以前"}
|
||||
candidate = client.get(f"/api/v1/plan-candidates/{id_}").json()
|
||||
location.update(candidate_revision=2, candidate_hash=candidate["candidate_hash"])
|
||||
review = client.post(f"/api/v1/plan-candidates/{id_}/reviews", json=location)
|
||||
assert review.status_code == 200, review.text
|
||||
decision = {
|
||||
"command_id": "cli-accept-plan",
|
||||
"candidate": location,
|
||||
"action": "adopt",
|
||||
"author_review_id": review.json()["review_id"],
|
||||
"review_hash": review.json()["review_hash"],
|
||||
"approved_changes": ["plan"],
|
||||
}
|
||||
path = tmp_path / "决定.json"
|
||||
path.write_text(json.dumps(decision, ensure_ascii=False))
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "muse", "规划", str(cfg), "决定", str(path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
readback = client.get(f"/api/v1/plans/{candidate['plan_id']}")
|
||||
assert readback.status_code == 200 and readback.json()["content"] == {"名称": "下雪后的信"}
|
||||
assert client.post(
|
||||
f"/api/v1/plan-candidates/{id_}/decisions", json=decision
|
||||
).json() == json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_陈旧规划审阅不能覆盖新版本但可重新拒绝__ad1303(规划环境):
|
||||
from dataclasses import replace
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
schema = service.新规划结构(actor, "planned-work", "outline", 1)
|
||||
locations = []
|
||||
for name in ("一封信", "一封旧信"):
|
||||
reply = service.保存人工候选(
|
||||
actor, name, 规划保存("planned-work", 0, schema, {"名称": name})
|
||||
)
|
||||
item = service.读取候选(actor, reply["results"][0]["candidate_id"])
|
||||
locations.append(
|
||||
规划定位(item["candidate_id"], 1, item["candidate_hash"], item["plan_id"], 0)
|
||||
)
|
||||
old_review = service.打开审阅(actor, locations[0])
|
||||
new_review = service.打开审阅(actor, locations[1])
|
||||
service.决定(
|
||||
actor,
|
||||
"approve-new",
|
||||
locations[1],
|
||||
作者动作.采纳,
|
||||
new_review["review_id"],
|
||||
new_review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
with pytest.raises(Muse错误):
|
||||
service.决定(
|
||||
actor,
|
||||
"approve-stale",
|
||||
locations[0],
|
||||
作者动作.采纳,
|
||||
old_review["review_id"],
|
||||
old_review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
current = service.读取规划(actor, locations[1].plan_id)
|
||||
assert current["revision"] == 1 and current["content"]["名称"] == "一封旧信"
|
||||
rejected = replace(locations[0], expected_revision=1)
|
||||
review = service.打开审阅(actor, rejected)
|
||||
result = service.决定(
|
||||
actor, "reject-old", rejected, 作者动作.拒绝, review["review_id"], review["review_hash"], ()
|
||||
)
|
||||
assert result["action"] == "reject"
|
||||
assert service.读取规划(actor, locations[1].plan_id) == current
|
||||
|
||||
|
||||
def 保存并确认(service, actor, request, command):
|
||||
reply = service.保存人工候选(actor, command + "-draft", request)
|
||||
item = service.读取候选(actor, reply["results"][0]["candidate_id"])
|
||||
location = 规划定位(
|
||||
item["candidate_id"],
|
||||
item["revision"],
|
||||
item["candidate_hash"],
|
||||
item["plan_id"],
|
||||
request.expected_revision,
|
||||
)
|
||||
review = service.打开审阅(actor, location)
|
||||
service.决定(
|
||||
actor,
|
||||
command + "-accept",
|
||||
location,
|
||||
作者动作.采纳,
|
||||
review["review_id"],
|
||||
review["review_hash"],
|
||||
("plan",),
|
||||
)
|
||||
return service.读取规划(actor, item["plan_id"])
|
||||
|
||||
|
||||
def test_已确认细纲返回字典并检验当前来源__e1f9cb(规划环境):
|
||||
from dataclasses import replace
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
for i in range(1, 4):
|
||||
app.要求作品().添加章节(
|
||||
actor, f"chapter-{i}", "planned-work", f"chapter-{i}", f"第{i}章", 预期目录版本=i - 1
|
||||
)
|
||||
outline_schema = service.新规划结构(actor, "planned-work", "outline", 1)
|
||||
outline = 规划保存("planned-work", 0, outline_schema, {"名称": "原大纲"})
|
||||
parent = 保存并确认(service, actor, outline, "parent")
|
||||
with pytest.raises(Muse错误) as missing:
|
||||
service.读取可用细纲(actor, "planned-work", "chapter-3")
|
||||
assert missing.value.错误码 == "PRECONDITION_PENDING"
|
||||
schema = service.新规划结构(actor, "planned-work", "fine_outline", 1)
|
||||
payload = 细纲内容(parent["plan_id"])
|
||||
req = 规划保存("planned-work", 0, schema, payload, chapter_id="chapter-3")
|
||||
first = 保存并确认(service, actor, req, "fine")
|
||||
loaded = service.读取可用细纲(actor, "planned-work", "chapter-3")
|
||||
assert isinstance(loaded["content"], dict) and loaded["content"]["targetChapter"] == 3
|
||||
assert loaded["content"]["chapterGoal"] == "x" and loaded["revision"] == 1
|
||||
second = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
replace(req, expected_revision=1, content={**payload, "chapterGoal": "x2"}),
|
||||
"fine-v2",
|
||||
)
|
||||
assert first["plan_id"] == second["plan_id"]
|
||||
assert (
|
||||
service.读取可用细纲(actor, "planned-work", "chapter-3")["content"]["chapterGoal"] == "x2"
|
||||
)
|
||||
保存并确认(
|
||||
service,
|
||||
actor,
|
||||
replace(outline, expected_revision=1, content={"名称": "新的大纲"}),
|
||||
"parent-v2",
|
||||
)
|
||||
with pytest.raises(Muse错误) as stale:
|
||||
service.读取可用细纲(actor, "planned-work", "chapter-3")
|
||||
assert stale.value.错误码 == "SOURCE_STALE"
|
||||
|
||||
|
||||
def test_规划动态引用由故事世界核实并纳入提交依赖__ad1304(规划环境):
|
||||
from muse.元数据.接口 import 作品扩展, 值规则, 字段定义, 引用规则
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, db = 规划环境
|
||||
建立人物(app, actor)
|
||||
with db[用途.维护].连接() as 连, 连.transaction():
|
||||
extension = 作品扩展(
|
||||
"planned-work",
|
||||
"outline",
|
||||
1,
|
||||
1,
|
||||
(
|
||||
字段定义(
|
||||
"outline.concern",
|
||||
"牵挂的人",
|
||||
"牵挂的人",
|
||||
值规则("reference", 引用=引用规则(("character",))),
|
||||
),
|
||||
),
|
||||
)
|
||||
元数据服务(连).发布扩展(extension, extension.内容哈希)
|
||||
service = app.要求规划()
|
||||
schema = service.新规划结构(actor, "planned-work", "outline", 1, 扩展版本=1)
|
||||
bad = 规划保存("planned-work", 0, schema, {"名称": "找信", "牵挂的人": "missing-person"})
|
||||
with pytest.raises(Muse错误) as missing:
|
||||
service.保存人工候选(actor, "missing-reference", bad)
|
||||
assert missing.value.错误码 == "FACT_REFERENCE_INVALID"
|
||||
saved = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
规划保存("planned-work", 0, schema, {"名称": "找信", "牵挂的人": "postman"}),
|
||||
"linked-plan",
|
||||
)
|
||||
assert saved["content"]["牵挂的人"] == "postman"
|
||||
assert {"种类": "B02.object", "对象ID": "world:postman", "版本": "1"} in saved[
|
||||
"source_dependencies"
|
||||
]
|
||||
|
||||
|
||||
def 细纲内容(parent_id):
|
||||
return {
|
||||
"targetChapter": 3,
|
||||
"sourceRef": {"sourceId": parent_id, "sourceVersion": 1},
|
||||
"chapterGoal": "x",
|
||||
"hardConstraints": [],
|
||||
"keyEvents": [
|
||||
{
|
||||
"id": "letter",
|
||||
"order": 1,
|
||||
"event": "邮差走到门口",
|
||||
"participants": [],
|
||||
"trigger": "收到退回的信",
|
||||
"resultDirection": "确认这座房子还住着人",
|
||||
}
|
||||
],
|
||||
"adjustableBeats": [],
|
||||
"declaredNewFacts": [],
|
||||
"mustAppearEntities": [],
|
||||
"chapterEndHook": "门内传来了脚步声。",
|
||||
}
|
||||
|
||||
|
||||
def 准备可用细纲(规划环境):
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
for i in range(1, 4):
|
||||
app.要求作品().添加章节(
|
||||
actor, f"add-{i}", "planned-work", f"chapter-{i}", f"第{i}章", 预期目录版本=i - 1
|
||||
)
|
||||
parent_req = 规划保存(
|
||||
"planned-work",
|
||||
0,
|
||||
service.新规划结构(actor, "planned-work", "outline", 1),
|
||||
{"名称": "寄信者"},
|
||||
)
|
||||
parent = 保存并确认(service, actor, parent_req, "parent")
|
||||
child_req = 规划保存(
|
||||
"planned-work",
|
||||
0,
|
||||
service.新规划结构(actor, "planned-work", "fine_outline", 1),
|
||||
细纲内容(parent["plan_id"]),
|
||||
chapter_id="chapter-3",
|
||||
)
|
||||
child = 保存并确认(service, actor, child_req, "child")
|
||||
return parent_req, parent, child_req, child
|
||||
|
||||
|
||||
def test_规划关联按类型归属解析且不构成依据环__ad1307(规划环境):
|
||||
from dataclasses import replace
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
service = app.要求规划()
|
||||
parent_req, parent, child_req, child = 准备可用细纲(规划环境)
|
||||
linked = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
replace(
|
||||
parent_req,
|
||||
expected_revision=1,
|
||||
content={"名称": "寄信者", "近三章细纲": [child["plan_id"]]},
|
||||
),
|
||||
"link-child",
|
||||
)
|
||||
assert linked["content"]["近三章细纲"] == [child["plan_id"]]
|
||||
assert not any(d["对象ID"] == "plan:" + child["plan_id"] for d in linked["source_dependencies"])
|
||||
refreshed = replace(
|
||||
child_req,
|
||||
expected_revision=1,
|
||||
content={
|
||||
**child_req.content,
|
||||
"sourceRef": {"sourceId": parent["plan_id"], "sourceVersion": 2},
|
||||
},
|
||||
)
|
||||
保存并确认(service, actor, refreshed, "refresh-child")
|
||||
assert service.读取可用细纲(actor, "planned-work", "chapter-3")["revision"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"policy", ["deprecated", "writing-revoked"], ids=["deprecated", "writing-revoked"]
|
||||
)
|
||||
def test_细纲必需字段撤回用途后不能用于写作__ad1308(规划环境, policy):
|
||||
from dataclasses import asdict
|
||||
|
||||
from muse.元数据.接口 import 启用命令, 字段限制, 定义哈希, 策略快照
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, db = 规划环境
|
||||
_, _, _, child = 准备可用细纲(规划环境)
|
||||
with db[用途.维护].连接() as 连, 连.transaction():
|
||||
rule = (
|
||||
字段限制("fine_outline.chapterGoal", (), True)
|
||||
if policy == "deprecated"
|
||||
else 字段限制("fine_outline.chapterGoal", ("aiContext:generation",))
|
||||
)
|
||||
version = 策略快照("fine_outline", 1, (rule,))
|
||||
元数据服务(连).更新策略(
|
||||
version,
|
||||
启用命令("restrict-plan", 定义哈希(asdict(version)), 0, "author", "type:fine_outline"),
|
||||
)
|
||||
# 历史仍可查看;严格可用读取不把裁剪后缺失的章目标当作完整细纲。
|
||||
assert app.要求规划().读取规划(actor, child["plan_id"])["revision"] == 1
|
||||
with pytest.raises(Muse错误) as rejected:
|
||||
app.要求规划().读取可用细纲(actor, "planned-work", "chapter-3")
|
||||
assert rejected.value.错误码 == "PRECONDITION_PENDING"
|
||||
|
||||
|
||||
def 建立人物(app, actor):
|
||||
from muse.故事世界.接口 import 事实定位, 事实提案请求
|
||||
|
||||
world = app.要求故事世界()
|
||||
structure = world.新事实结构(actor, "planned-work", "postman", "character", 1)
|
||||
result = world.提出事实(
|
||||
actor,
|
||||
"postman-fact",
|
||||
事实提案请求(
|
||||
"planned-work", "postman", 0, structure, {"名称": "邮差"}, (), None, independent=True
|
||||
),
|
||||
)
|
||||
item = world.读取提案(actor, result["results"][0]["proposal_id"])
|
||||
location = 事实定位(item["proposal_id"], 1, item["proposal_hash"], "world:postman", 0)
|
||||
review = world.打开事实审阅(actor, location)
|
||||
world.决定事实(
|
||||
actor,
|
||||
"confirm-postman",
|
||||
location,
|
||||
作者动作.采纳,
|
||||
review["review_id"],
|
||||
review["review_hash"],
|
||||
("object",),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("形态", ["string", "object"])
|
||||
def test_细纲两种实体形式都核对真实本书引用__ad1305(规划环境, 形态):
|
||||
from dataclasses import replace
|
||||
|
||||
from muse.共享.错误 import Muse错误
|
||||
|
||||
app, actor, _ = 规划环境
|
||||
建立人物(app, actor)
|
||||
for i in range(1, 4):
|
||||
app.要求作品().添加章节(
|
||||
actor,
|
||||
f"ref-chapter-{i}",
|
||||
"planned-work",
|
||||
f"chapter-{i}",
|
||||
f"第{i}章",
|
||||
预期目录版本=i - 1,
|
||||
)
|
||||
service = app.要求规划()
|
||||
parent = 保存并确认(
|
||||
service,
|
||||
actor,
|
||||
规划保存(
|
||||
"planned-work",
|
||||
0,
|
||||
service.新规划结构(actor, "planned-work", "outline", 1),
|
||||
{"名称": "找信"},
|
||||
),
|
||||
"reference-parent",
|
||||
)
|
||||
content = 细纲内容(parent["plan_id"])
|
||||
value = (
|
||||
"postman" if 形态 == "string" else {"id": "postman", "type": "character", "name": "邮差"}
|
||||
)
|
||||
content["mustAppearEntities"] = [value]
|
||||
schema = service.新规划结构(actor, "planned-work", "fine_outline", 1)
|
||||
req = 规划保存("planned-work", 0, schema, content, chapter_id="chapter-3")
|
||||
missing = (
|
||||
"missing" if 形态 == "string" else {"id": "missing", "type": "character", "name": "邮差"}
|
||||
)
|
||||
with pytest.raises(Muse错误) as error:
|
||||
service.保存人工候选(
|
||||
actor,
|
||||
"invalid-entity",
|
||||
replace(req, content={**content, "mustAppearEntities": [missing]}),
|
||||
)
|
||||
assert error.value.错误码 == "FACT_REFERENCE_INVALID"
|
||||
保存并确认(service, actor, req, "referenced-fine")
|
||||
loaded = service.读取可用细纲(actor, "planned-work", "chapter-3")
|
||||
assert loaded["content"]["mustAppearEntities"] == [value]
|
||||
assert {"种类": "B02.object", "对象ID": "world:postman", "版本": "1"} in loaded[
|
||||
"source_dependencies"
|
||||
]
|
||||
315
web/src/功能/作品规划/探索工作区.tsx
Normal file
315
web/src/功能/作品规划/探索工作区.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useMuse作者会话 } from "../../应用/作者会话";
|
||||
import { 接口错误 } from "../../接口/传输";
|
||||
import { Ui反馈状态 } from "../../界面/反馈状态";
|
||||
import { Ui探索交接 } from "./规划工作区";
|
||||
import {
|
||||
列出探索,
|
||||
读取探索,
|
||||
保存探索,
|
||||
检查探索,
|
||||
type 探索稿,
|
||||
type 探索保存,
|
||||
} from "./数据";
|
||||
|
||||
export function Ui探索工作区() {
|
||||
const author = useMuse作者会话();
|
||||
const { draft_id } = useParams();
|
||||
if (!author.data) return <Ui反馈状态 message="正在读取作者会话…" />;
|
||||
return draft_id ? (
|
||||
<Ui探索读取
|
||||
key={`${author.data.author_id}:${draft_id}`}
|
||||
author={author.data.author_id}
|
||||
id={draft_id}
|
||||
/>
|
||||
) : (
|
||||
<Ui探索列表 author={author.data.author_id} />
|
||||
);
|
||||
}
|
||||
|
||||
function Ui探索列表({ author }: { author: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ["探索列表", author],
|
||||
queryFn: ({ signal }) => 列出探索(signal),
|
||||
});
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">私人笔记</p>
|
||||
<h1>作品探索</h1>
|
||||
<p className="muted">
|
||||
先保留一个念头,也可以留下暂时想不明白的地方。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className="profile-link"
|
||||
to={`/explorations/${crypto.randomUUID()}?new=1`}
|
||||
>
|
||||
记下新想法
|
||||
</Link>
|
||||
</header>
|
||||
<Link to="/works">返回我的作品</Link>
|
||||
{query.error && <Ui反馈状态 error message={query.error.message} />}
|
||||
{query.isPending && <Ui反馈状态 message="正在读取私人稿…" />}
|
||||
{query.data?.length === 0 && (
|
||||
<p className="empty-state">
|
||||
还没有探索稿。从一个人物、一件物品或一句话开始。
|
||||
</p>
|
||||
)}
|
||||
<div className="work-grid">
|
||||
{query.data?.map((draft) => (
|
||||
<Link
|
||||
key={draft.draft_id}
|
||||
className="work-card"
|
||||
to={`/explorations/${draft.draft_id}`}
|
||||
>
|
||||
<div>
|
||||
<h2>{draft.title}</h2>
|
||||
<p className="muted">继续整理想法 →</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Ui探索读取({ author, id }: { author: string; id: string }) {
|
||||
const [params] = useSearchParams();
|
||||
const isNew = params.get("new") === "1";
|
||||
const query = useQuery({
|
||||
queryKey: ["探索稿", author, id],
|
||||
queryFn: ({ signal }) => 读取探索(id, signal),
|
||||
enabled: !isNew,
|
||||
});
|
||||
if (!isNew && query.isPending)
|
||||
return <Ui反馈状态 message="正在读取探索稿…" />;
|
||||
if (query.error) return <Ui反馈状态 error message={query.error.message} />;
|
||||
return (
|
||||
<Ui探索编辑
|
||||
author={author}
|
||||
id={id}
|
||||
initial={isNew ? undefined : query.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type 缓冲 = {
|
||||
title: string;
|
||||
content: string;
|
||||
base_hash: string | null;
|
||||
dirty: boolean;
|
||||
pending?: 探索保存;
|
||||
};
|
||||
function 读缓冲(key: string, initial?: 探索稿): 缓冲 {
|
||||
const empty = {
|
||||
title: initial?.title ?? "未命名的想法",
|
||||
content: initial?.content ?? "",
|
||||
base_hash: initial?.draft_hash ?? null,
|
||||
dirty: false,
|
||||
};
|
||||
try {
|
||||
const data = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
||||
if (
|
||||
data &&
|
||||
typeof data.title === "string" &&
|
||||
typeof data.content === "string" &&
|
||||
typeof data.dirty === "boolean" &&
|
||||
(data.base_hash === null || typeof data.base_hash === "string")
|
||||
) {
|
||||
if (initial && !data.dirty && !data.pending) return empty;
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
/* 缓冲损坏时保留服务器稿。 */
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
function Ui探索编辑({
|
||||
author,
|
||||
id,
|
||||
initial,
|
||||
}: {
|
||||
author: string;
|
||||
id: string;
|
||||
initial?: 探索稿;
|
||||
}) {
|
||||
const key = `muse:exploration:${author}:${id}`;
|
||||
const navigate = useNavigate();
|
||||
const cache = useQueryClient();
|
||||
const [buffer, setBuffer] = useState(() => 读缓冲(key, initial));
|
||||
const [busy, setBusy] = useState(false),
|
||||
[message, setMessage] = useState("想法只保存在私人探索稿中。");
|
||||
const [error, setError] = useState("");
|
||||
const [current, setCurrent] = useState<探索稿>();
|
||||
const [check, setCheck] = useState<Awaited<ReturnType<typeof 检查探索>>>();
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(buffer));
|
||||
} catch {
|
||||
setError("浏览器暂时无法保留输入缓冲,请及时保存私人稿。");
|
||||
}
|
||||
}, [key, buffer]);
|
||||
function edit(change: Partial<缓冲>) {
|
||||
setBuffer((b) => ({ ...b, ...change, dirty: true }));
|
||||
setCheck(undefined);
|
||||
setMessage("有未保存的想法。");
|
||||
}
|
||||
async function save() {
|
||||
const request = buffer.pending ?? {
|
||||
draft_id: id,
|
||||
title: buffer.title,
|
||||
content: buffer.content,
|
||||
expected_hash: buffer.base_hash,
|
||||
};
|
||||
setBuffer((b) => ({ ...b, pending: request }));
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setCheck(undefined);
|
||||
try {
|
||||
const saved = await 保存探索(request);
|
||||
cache.setQueryData(["探索稿", author, id], saved);
|
||||
void cache.invalidateQueries({ queryKey: ["探索列表", author] });
|
||||
setBuffer({
|
||||
title: saved.title,
|
||||
content: saved.content,
|
||||
base_hash: saved.draft_hash,
|
||||
dirty: false,
|
||||
});
|
||||
setCurrent(undefined);
|
||||
setMessage("私人稿已保存。");
|
||||
navigate(`/explorations/${id}`, { replace: true });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存未完成。");
|
||||
if (e instanceof 接口错误 && e.status > 0) {
|
||||
setBuffer((b) => ({ ...b, pending: undefined }));
|
||||
if (e.code === "REVISION_CONFLICT") {
|
||||
try {
|
||||
setCurrent(await 读取探索(id));
|
||||
} catch {
|
||||
setError("当前稿暂时无法读回,请稍后重试保存。");
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function inspect() {
|
||||
if (!buffer.base_hash) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setCheck(await 检查探索(id, buffer.base_hash));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "检查未完成。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const dirty = buffer.dirty || !buffer.base_hash;
|
||||
return (
|
||||
<main className="content exploration-workspace">
|
||||
<header className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">私人笔记</p>
|
||||
<h1>慢慢把故事想清楚</h1>
|
||||
</div>
|
||||
<Link className="profile-link" to="/explorations">
|
||||
所有探索稿
|
||||
</Link>
|
||||
</header>
|
||||
<p className="muted">
|
||||
可以记片段、比较不同方向,也可以原样留下问题。整理交付时再检查格式。
|
||||
</p>
|
||||
<section className="paper-panel exploration-sheet">
|
||||
<label>
|
||||
稿件名称
|
||||
<input
|
||||
aria-label="稿件名称"
|
||||
value={buffer.title}
|
||||
disabled={busy || !!buffer.pending}
|
||||
onChange={(e) => edit({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
我的想法
|
||||
<textarea
|
||||
aria-label="我的想法"
|
||||
value={buffer.content}
|
||||
disabled={busy || !!buffer.pending}
|
||||
onChange={(e) => edit({ content: e.target.value })}
|
||||
placeholder="今天反复想到的,是……"
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={save}
|
||||
disabled={busy || !!current}
|
||||
>
|
||||
{buffer.pending ? "重试保存同一稿件" : "保存私人稿"}
|
||||
</button>
|
||||
<button
|
||||
onClick={inspect}
|
||||
disabled={busy || dirty || !!buffer.pending}
|
||||
>
|
||||
检查整理稿格式
|
||||
</button>
|
||||
</div>
|
||||
<p role="status">{message}</p>
|
||||
{buffer.pending && !busy && (
|
||||
<p>保存结果暂未确认,重试会使用刚才的同一份内容。</p>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{current && (
|
||||
<aside className="draft-conflict">
|
||||
<h2>另一处保存了新内容</h2>
|
||||
<p>先比较服务器稿,再决定如何保留自己的修改。</p>
|
||||
<pre>{current.title + "\n\n" + current.content}</pre>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBuffer((b) => ({ ...b, base_hash: current.draft_hash }));
|
||||
setCurrent(undefined);
|
||||
}}
|
||||
>
|
||||
保留我的改稿,基于当前稿保存
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
{check && (
|
||||
<section className="exploration-check">
|
||||
<h2>{check.valid ? "整理稿格式完整" : "这些地方还需要整理"}</h2>
|
||||
{check.valid ? (
|
||||
<p>格式检查通过。故事方向和正式规划仍由你决定。</p>
|
||||
) : (
|
||||
<ul>
|
||||
{check.problems.map((p, i) => (
|
||||
<li key={i}>{p.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
{check?.valid && !dirty && buffer.base_hash && (
|
||||
<Ui探索交接
|
||||
key={buffer.base_hash}
|
||||
author={author}
|
||||
draft={id}
|
||||
hash={buffer.base_hash}
|
||||
onLock={setBusy}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
271
web/src/功能/作品规划/节点规划.tsx
Normal file
271
web/src/功能/作品规划/节点规划.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { 接口错误 } from "../../接口/传输";
|
||||
import {
|
||||
读取节点目录,
|
||||
保存节点目录,
|
||||
type 目录节点,
|
||||
type 节点目录,
|
||||
type 节点保存,
|
||||
} from "./数据";
|
||||
|
||||
const 类型名 = { volume: "卷", chapter: "章", scene: "场景" };
|
||||
const 基础节点 = (n: 目录节点): 目录节点 => ({
|
||||
node_id: n.node_id,
|
||||
kind: n.kind,
|
||||
title: n.title,
|
||||
parent_id: n.parent_id,
|
||||
});
|
||||
|
||||
export function Ui节点规划({ work, author }: { work: string; author: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ["分层目录", author, work],
|
||||
queryFn: ({ signal }) => 读取节点目录(work, signal),
|
||||
});
|
||||
return (
|
||||
<details className="paper-panel profile-panel">
|
||||
<summary>卷、章与场景</summary>
|
||||
{query.error && <p role="alert">{query.error.message}</p>}
|
||||
{query.data && (
|
||||
<Ui节点编辑
|
||||
initial={query.data}
|
||||
refresh={async () => {
|
||||
const result = await query.refetch();
|
||||
if (!result.data) throw new Error("最新目录读取失败。");
|
||||
return result.data;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Ui节点编辑({
|
||||
initial,
|
||||
refresh,
|
||||
}: {
|
||||
initial: 节点目录;
|
||||
refresh: () => Promise<节点目录>;
|
||||
}) {
|
||||
const [base, setBase] = useState(initial);
|
||||
const [nodes, setNodes] = useState<目录节点[]>(() =>
|
||||
initial.nodes.map(基础节点),
|
||||
);
|
||||
const [kind, setKind] = useState<目录节点["kind"]>("volume"),
|
||||
[parent, setParent] = useState(""),
|
||||
[title, setTitle] = useState("");
|
||||
const [busy, setBusy] = useState(false),
|
||||
[conflict, setConflict] = useState<节点目录>(),
|
||||
[message, setMessage] = useState("");
|
||||
const pending = useRef<节点保存>();
|
||||
const locked = busy || !!pending.current;
|
||||
function parents(type: 目录节点["kind"]) {
|
||||
return nodes.filter((n) =>
|
||||
type === "chapter"
|
||||
? n.kind === "volume"
|
||||
: type === "scene" && n.kind === "chapter",
|
||||
);
|
||||
}
|
||||
function change(id: string, patch: Partial<目录节点>) {
|
||||
setNodes((old) =>
|
||||
old.map((n) => (n.node_id === id ? { ...n, ...patch } : n)),
|
||||
);
|
||||
setMessage("");
|
||||
}
|
||||
function move(n: 目录节点, step: number) {
|
||||
const siblings = nodes.filter((x) => x.parent_id === n.parent_id),
|
||||
to = siblings[siblings.indexOf(n) + step];
|
||||
if (!to) return;
|
||||
const copy = [...nodes],
|
||||
a = copy.indexOf(n),
|
||||
b = copy.indexOf(to);
|
||||
[copy[a], copy[b]] = [copy[b], copy[a]];
|
||||
setNodes(copy);
|
||||
setMessage("");
|
||||
}
|
||||
async function save() {
|
||||
if (conflict) return;
|
||||
pending.current ??= {
|
||||
command_id: crypto.randomUUID(),
|
||||
directory: {
|
||||
work_id: base.work_id,
|
||||
expected_revision: base.revision,
|
||||
nodes,
|
||||
},
|
||||
};
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await 保存节点目录(pending.current);
|
||||
pending.current = undefined;
|
||||
const latest = await refresh();
|
||||
setBase(latest);
|
||||
setNodes(latest.nodes.map(基础节点));
|
||||
setMessage("卷章与场景目录已保存。");
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : "目录保存未完成。");
|
||||
if (e instanceof 接口错误 && e.status > 0) {
|
||||
pending.current = undefined;
|
||||
if (e.status === 409) setConflict(await refresh());
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section aria-label="卷章与场景目录">
|
||||
<p className="muted">
|
||||
目录调整保留已有章和场景身份,规划内容仍需分别审阅确认。
|
||||
</p>
|
||||
<fieldset disabled={locked} className="form-fields">
|
||||
{nodes.map((n) => (
|
||||
<div className="profile-field" key={n.node_id}>
|
||||
<label>
|
||||
{类型名[n.kind]}名称
|
||||
<input
|
||||
aria-label={`${类型名[n.kind]}名称 ${n.title}`}
|
||||
value={n.title}
|
||||
onChange={(e) => change(n.node_id, { title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{n.kind !== "volume" && (
|
||||
<label>
|
||||
所属{n.kind === "chapter" ? "卷" : "章"}
|
||||
<select
|
||||
aria-label={`归属 ${n.title}`}
|
||||
value={n.parent_id ?? ""}
|
||||
onChange={(e) =>
|
||||
change(n.node_id, { parent_id: e.target.value || null })
|
||||
}
|
||||
>
|
||||
{n.kind === "chapter" && <option value="">作品直属</option>}
|
||||
{parents(n.kind).map((p) => (
|
||||
<option key={p.node_id} value={p.node_id}>
|
||||
{p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="button-row">
|
||||
<button onClick={() => move(n, -1)}>上移{n.title}</button>
|
||||
<button onClick={() => move(n, 1)}>下移{n.title}</button>
|
||||
{initial.nodes.some((x) => x.node_id === n.node_id) && (
|
||||
<Link
|
||||
className="profile-link"
|
||||
to={`/works/${encodeURIComponent(base.work_id)}/plans?node=${encodeURIComponent(n.node_id)}&schema=${n.kind === "volume" ? "outline" : n.kind === "chapter" ? "fine_outline" : "scene"}`}
|
||||
>
|
||||
填写{n.title}的规划
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="profile-field">
|
||||
<label>
|
||||
新增层级
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as 目录节点["kind"]);
|
||||
setParent("");
|
||||
}}
|
||||
>
|
||||
{Object.entries(类型名).map(([v, n]) => (
|
||||
<option key={v} value={v}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
新节点名称
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</label>
|
||||
{kind !== "volume" && (
|
||||
<label>
|
||||
新节点归属
|
||||
<select
|
||||
value={parent}
|
||||
onChange={(e) => setParent(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{kind === "chapter" ? "作品直属" : "选择所属章"}
|
||||
</option>
|
||||
{parents(kind).map((p) => (
|
||||
<option key={p.node_id} value={p.node_id}>
|
||||
{p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
disabled={!title.trim() || (kind === "scene" && !parent)}
|
||||
onClick={() => {
|
||||
setNodes((old) => [
|
||||
...old,
|
||||
{
|
||||
node_id: crypto.randomUUID(),
|
||||
kind,
|
||||
title,
|
||||
parent_id: parent || null,
|
||||
},
|
||||
]);
|
||||
setTitle("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
加入目录
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{conflict && (
|
||||
<aside>
|
||||
<p role="alert">目录已有新版本,你的调整仍保留在上方。</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const old = new Map(base.nodes.map((n) => [n.node_id, n]));
|
||||
const latest = new Map(
|
||||
conflict.nodes.map((n) => [n.node_id, 基础节点(n)]),
|
||||
);
|
||||
for (const n of nodes) {
|
||||
const before = old.get(n.node_id),
|
||||
now = latest.get(n.node_id);
|
||||
if (!before || !now) latest.set(n.node_id, n);
|
||||
else
|
||||
latest.set(n.node_id, {
|
||||
...now,
|
||||
title: n.title !== before.title ? n.title : now.title,
|
||||
parent_id:
|
||||
n.parent_id !== before.parent_id
|
||||
? n.parent_id
|
||||
: now.parent_id,
|
||||
});
|
||||
}
|
||||
const order = [
|
||||
...nodes.map((n) => n.node_id),
|
||||
...conflict.nodes.map((n) => n.node_id),
|
||||
];
|
||||
setNodes([...new Set(order)].map((id) => latest.get(id)!));
|
||||
setBase(conflict);
|
||||
setConflict(undefined);
|
||||
setMessage("请检查合并后的目录,再保存。");
|
||||
}}
|
||||
>
|
||||
保留我的调整,按当前目录重看
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
<button className="primary" disabled={busy || !!conflict} onClick={save}>
|
||||
{busy
|
||||
? "正在保存…"
|
||||
: pending.current
|
||||
? "重试同一次目录保存"
|
||||
: "保存节点目录"}
|
||||
</button>
|
||||
{message && <p role="status">{message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
533
web/src/功能/作品规划/规划工作区.tsx
Normal file
533
web/src/功能/作品规划/规划工作区.tsx
Normal file
@ -0,0 +1,533 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Ui节点规划 } from "./节点规划";
|
||||
import { useMuse作者会话 } from "../../应用/作者会话";
|
||||
import { Ui结构表单 } from "../../界面/结构表单/结构表单";
|
||||
import { Ui反馈状态 } from "../../界面/反馈状态";
|
||||
import { 接口错误 } from "../../接口/传输";
|
||||
import { 列出作品, 读取探索选定, 选定探索, type 探索交接请求 } from "./数据";
|
||||
import {
|
||||
读取规划表单,
|
||||
列出规划候选,
|
||||
建立规划候选,
|
||||
编辑规划候选,
|
||||
打开规划审阅,
|
||||
决定规划,
|
||||
规划条目摘要,
|
||||
type 规划候选,
|
||||
type 规划审阅,
|
||||
type 规划决定,
|
||||
type 规划表单,
|
||||
} from "./数据";
|
||||
|
||||
/** 私人稿的明确交接;后续规划候选仍使用单独的作者审阅。 */
|
||||
export function Ui探索交接({
|
||||
author,
|
||||
draft,
|
||||
hash,
|
||||
onLock,
|
||||
}: {
|
||||
author: string;
|
||||
draft: string;
|
||||
hash: string;
|
||||
onLock: (value: boolean) => void;
|
||||
}) {
|
||||
const works = useQuery({
|
||||
queryKey: ["作品列表", author],
|
||||
queryFn: ({ signal }) => 列出作品(signal),
|
||||
});
|
||||
const [work, setWork] = useState("");
|
||||
const current = useQuery({
|
||||
queryKey: ["探索选定", author, work],
|
||||
queryFn: ({ signal }) => 读取探索选定(work, signal),
|
||||
enabled: !!work,
|
||||
});
|
||||
const [busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[notice, setNotice] = useState("");
|
||||
const pending = useRef<探索交接请求>();
|
||||
async function select() {
|
||||
if (!current.data) return;
|
||||
pending.current ??= {
|
||||
command_id: crypto.randomUUID(),
|
||||
selection: {
|
||||
work_id: work,
|
||||
draft_id: draft,
|
||||
expected_hash: hash,
|
||||
expected_revision: current.data.revision,
|
||||
},
|
||||
};
|
||||
setBusy(true);
|
||||
onLock(true);
|
||||
setError("");
|
||||
try {
|
||||
await 选定探索(pending.current);
|
||||
pending.current = undefined;
|
||||
await current.refetch();
|
||||
setNotice("已选定为作品规划依据。正式规划仍需单独确认。");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "交接未完成。");
|
||||
if (e instanceof 接口错误 && e.status > 0) {
|
||||
pending.current = undefined;
|
||||
await current.refetch();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (!pending.current) onLock(false);
|
||||
}
|
||||
}
|
||||
const selected = current.data?.selection?.snapshot.draft_hash === hash;
|
||||
return (
|
||||
<section className="exploration-selection">
|
||||
<h2>把这份稿交给作品</h2>
|
||||
<p>
|
||||
确认采用当前整理的方向后,保存这一版本作为规划依据。后续修改私人稿不会改动已选定的版本。
|
||||
</p>
|
||||
{works.error && <p role="alert">{works.error.message}</p>}
|
||||
{works.data?.length === 0 ? (
|
||||
<p>
|
||||
先到
|
||||
<Link className="profile-link" to="/works">
|
||||
我的作品
|
||||
</Link>
|
||||
建立作品档案,再回来交接。
|
||||
</p>
|
||||
) : (
|
||||
<label>
|
||||
交给哪部作品
|
||||
<select
|
||||
aria-label="交给哪部作品"
|
||||
value={work}
|
||||
disabled={busy || !!pending.current}
|
||||
onChange={(e) => {
|
||||
setWork(e.target.value);
|
||||
setNotice("");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<option value="">选择作品</option>
|
||||
{works.data?.map((w) => (
|
||||
<option key={w.work_id} value={w.work_id}>
|
||||
{typeof w.content.名称 === "string"
|
||||
? w.content.名称
|
||||
: "未命名作品"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{current.data?.selection && !selected && (
|
||||
<p className="muted">
|
||||
作品此前采用“{current.data.selection.snapshot.title}
|
||||
”。本次选择会留下新的版本,旧记录保留。
|
||||
</p>
|
||||
)}
|
||||
{current.error && <p role="alert">{current.error.message}</p>}
|
||||
<button
|
||||
className="primary"
|
||||
onClick={select}
|
||||
disabled={busy || !current.data || selected}
|
||||
>
|
||||
{pending.current
|
||||
? "重试同一次交接"
|
||||
: selected
|
||||
? "已选定本版本"
|
||||
: "选定本稿作为规划依据"}
|
||||
</button>
|
||||
{pending.current && !busy && (
|
||||
<p>交接结果尚未确认,重试使用同一次请求。</p>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
{selected && (
|
||||
<Link
|
||||
className="profile-link"
|
||||
to={`/works/${encodeURIComponent(work)}/plans`}
|
||||
>
|
||||
继续整理作品规划 →
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Ui规划工作区() {
|
||||
const { work_id = "" } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const node = params.get("node") ?? undefined,
|
||||
schema = params.get("schema") ?? "outline";
|
||||
const author = useMuse作者会话();
|
||||
if (!author.data) return <Ui反馈状态 message="正在读取作者会话…" />;
|
||||
return (
|
||||
<Ui大纲工作面
|
||||
key={`${author.data.author_id}:${work_id}:${node ?? ""}:${schema}`}
|
||||
work={work_id}
|
||||
author={author.data.author_id}
|
||||
node={node}
|
||||
schema={schema}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Ui大纲工作面({
|
||||
work,
|
||||
author,
|
||||
node,
|
||||
schema,
|
||||
}: {
|
||||
work: string;
|
||||
author: string;
|
||||
node?: string;
|
||||
schema: string;
|
||||
}) {
|
||||
const data = useQuery({
|
||||
queryKey: ["规划工作面", author, work, node, schema],
|
||||
queryFn: async ({ signal }) => {
|
||||
const [form, candidates, selection] = await Promise.all([
|
||||
读取规划表单(work, signal, node, schema),
|
||||
列出规划候选(work, signal),
|
||||
读取探索选定(work, signal),
|
||||
]);
|
||||
return { form, candidates, selection };
|
||||
},
|
||||
});
|
||||
const [editing, setEditing] = useState<{
|
||||
form: 规划表单;
|
||||
candidate?: 规划候选;
|
||||
selection: number | null;
|
||||
}>(),
|
||||
[busy, setBusy] = useState(false);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [review, setReview] = useState<规划审阅>(),
|
||||
[error, setError] = useState(""),
|
||||
[notice, setNotice] = useState("");
|
||||
const saving = useRef<{ hash: string; id: string }>();
|
||||
const pending = useRef<规划决定>();
|
||||
async function open(candidate: 规划候选) {
|
||||
if (!data.data) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setReview(await 打开规划审阅(candidate, data.data.form.revision));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "审阅读取失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function decide(action: 规划决定["action"]) {
|
||||
if (!review) return;
|
||||
const candidate = review.candidate;
|
||||
pending.current ??= {
|
||||
command_id: crypto.randomUUID(),
|
||||
candidate: {
|
||||
candidate_id: candidate.candidate_id,
|
||||
candidate_revision: candidate.revision,
|
||||
candidate_hash: candidate.candidate_hash,
|
||||
plan_id: candidate.plan_id,
|
||||
expected_revision: Number(review.snapshot["数据版本"]),
|
||||
},
|
||||
action,
|
||||
author_review_id: review.review_id,
|
||||
review_hash: review.review_hash,
|
||||
approved_changes: action === "adopt" ? ["plan"] : [],
|
||||
};
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await 决定规划(pending.current);
|
||||
pending.current = undefined;
|
||||
setReview(undefined);
|
||||
setNotice(
|
||||
action === "adopt"
|
||||
? "这份规划已确认。"
|
||||
: action === "reject"
|
||||
? "已拒绝这份候选。"
|
||||
: "已暂缓这份候选。",
|
||||
);
|
||||
await data.refetch();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "决定未完成。");
|
||||
if (e instanceof 接口错误 && e.status > 0) {
|
||||
pending.current = undefined;
|
||||
setReview(undefined);
|
||||
await data.refetch();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (data.isPending) return <Ui反馈状态 message="正在读取规划…" />;
|
||||
if (data.error || !data.data)
|
||||
return (
|
||||
<Ui反馈状态 error message={data.error?.message ?? "规划读取失败。"} />
|
||||
);
|
||||
const { form, candidates, selection } = data.data;
|
||||
const relevant = candidates.filter((c) => c.plan_id === form.plan_id);
|
||||
const labels: Record<string, string> = {
|
||||
undecided: "待决定",
|
||||
adopt: "已确认",
|
||||
reject: "已拒绝",
|
||||
defer: "已暂缓",
|
||||
};
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">把故事排出来</p>
|
||||
<h1>{node ? "节点规划" : "作品规划"}</h1>
|
||||
</div>
|
||||
<Link className="profile-link" to={`/works/${work}/write`}>
|
||||
返回作品
|
||||
</Link>
|
||||
</header>
|
||||
<Ui节点规划 work={work} author={author} />
|
||||
{node && (
|
||||
<Link
|
||||
className="profile-link"
|
||||
to={`/works/${encodeURIComponent(work)}/plans`}
|
||||
>
|
||||
回到全书规划
|
||||
</Link>
|
||||
)}
|
||||
{selection.selection && (
|
||||
<p className="muted">
|
||||
当前方向来自“{selection.selection.snapshot.title}”。
|
||||
</p>
|
||||
)}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{editing ? (
|
||||
<section className="paper-panel profile-panel">
|
||||
<h2>
|
||||
{editing.candidate
|
||||
? "编辑规划候选"
|
||||
: node
|
||||
? "另拟节点计划"
|
||||
: "另拟大纲"}
|
||||
</h2>
|
||||
<p>保存为候选,审阅后再确认。</p>
|
||||
{conflict && (
|
||||
<aside>
|
||||
<p role="alert">
|
||||
保存基线已变化。你的输入仍在下方,请先比较当前内容。
|
||||
</p>
|
||||
<Ui规划内容 value={form.content} />
|
||||
<button
|
||||
disabled={data.isFetching}
|
||||
onClick={async () => {
|
||||
// 冲突提示立即可见,但最新基线可能仍在迹;重看必须等待新数据,不用渲染时序。
|
||||
const fresh = (await data.refetch()).data;
|
||||
if (!fresh) return;
|
||||
const next = fresh.candidates.find(
|
||||
(c) =>
|
||||
c.candidate_id === editing.candidate?.candidate_id &&
|
||||
c.expected_revision === fresh.form.revision,
|
||||
);
|
||||
setEditing({
|
||||
form: fresh.form,
|
||||
candidate: next,
|
||||
selection: fresh.selection.revision || null,
|
||||
});
|
||||
saving.current = undefined;
|
||||
setConflict(false);
|
||||
}}
|
||||
>
|
||||
保留我的改动,基于当前版本重看
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
<Ui结构表单
|
||||
name={node ? "节点计划" : "作品大纲"}
|
||||
submitLabel="保存规划候选"
|
||||
visible={
|
||||
editing.candidate?.visible_schema ?? editing.form.visible_schema
|
||||
}
|
||||
editable={
|
||||
editing.candidate?.editable_schema ?? editing.form.editable_schema
|
||||
}
|
||||
initial={editing.candidate?.content ?? editing.form.content}
|
||||
onSubmit={async (changes) => {
|
||||
if (conflict)
|
||||
throw new Error("请先比较当前版本,输入会继续保留。");
|
||||
const hash = JSON.stringify([editing, changes]);
|
||||
if (saving.current?.hash !== hash)
|
||||
saving.current = { hash, id: crypto.randomUUID() };
|
||||
try {
|
||||
if (editing.candidate)
|
||||
await 编辑规划候选(
|
||||
editing.candidate,
|
||||
changes,
|
||||
saving.current!.id,
|
||||
);
|
||||
else
|
||||
await 建立规划候选(
|
||||
editing.form,
|
||||
changes,
|
||||
saving.current!.id,
|
||||
editing.selection,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof 接口错误 && e.status === 409) {
|
||||
setConflict(true);
|
||||
await data.refetch();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
saving.current = undefined;
|
||||
setEditing(undefined);
|
||||
setNotice("规划候选已保存,请打开审阅。");
|
||||
await data.refetch();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<section className="paper-panel profile-panel">
|
||||
<h2>
|
||||
{node
|
||||
? form.revision
|
||||
? "当前确认的节点计划"
|
||||
: "还没有确认的节点计划"
|
||||
: form.revision
|
||||
? "当前确认的大纲"
|
||||
: "还没有确认的大纲"}
|
||||
</h2>
|
||||
{form.revision > 0 && <Ui规划内容 value={form.content} />}
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy || !!pending.current}
|
||||
onClick={() => {
|
||||
setEditing({ form, selection: selection.revision || null });
|
||||
setNotice("");
|
||||
setConflict(false);
|
||||
setReview(undefined);
|
||||
}}
|
||||
>
|
||||
{node ? "另拟节点计划" : "另拟大纲"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<section className="paper-panel profile-panel">
|
||||
<h2>规划候选</h2>
|
||||
{relevant.length === 0 && (
|
||||
<p className="muted">保存一份候选后,可以在这里比较和决定。</p>
|
||||
)}
|
||||
<ul className="plan-candidate-list">
|
||||
{relevant.map((c, i) => {
|
||||
const { 标题, 摘要 } = 规划条目摘要(c.content);
|
||||
return (
|
||||
<li key={c.candidate_id}>
|
||||
<button
|
||||
disabled={
|
||||
busy ||
|
||||
!!pending.current ||
|
||||
c.decision === "adopt" ||
|
||||
c.decision === "reject"
|
||||
}
|
||||
onClick={() => open(c)}
|
||||
>
|
||||
候选 {i + 1} · {labels[c.decision]}
|
||||
</button>
|
||||
<span>
|
||||
{标题}
|
||||
{摘要 && <span className="muted">{摘要}</span>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{review && (
|
||||
<section className="paper-panel profile-panel">
|
||||
<h2>审阅这份规划</h2>
|
||||
<div className="plan-comparison">
|
||||
<div>
|
||||
<h3>原规划</h3>
|
||||
{review.before ? (
|
||||
<Ui规划内容 value={review.before.content} />
|
||||
) : (
|
||||
<p className="muted">尚未确认规划。</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3>候选内容</h3>
|
||||
<Ui规划内容 value={review.candidate.content} />
|
||||
</div>
|
||||
</div>
|
||||
{review.candidate.expected_revision !==
|
||||
Number(review.snapshot["数据版本"]) && (
|
||||
<p>已有更新的正式规划;这份候选可以暂缓或拒绝。</p>
|
||||
)}
|
||||
<div className="button-row">
|
||||
{pending.current ? (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => decide(pending.current!.action)}
|
||||
>
|
||||
重试同一次决定
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={
|
||||
busy ||
|
||||
review.candidate.expected_revision !==
|
||||
Number(review.snapshot["数据版本"])
|
||||
}
|
||||
onClick={() => decide("adopt")}
|
||||
>
|
||||
确认这份规划
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => decide("defer")}>
|
||||
暂缓
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => decide("reject")}>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
disabled={
|
||||
busy || review.candidate.expected_revision !== form.revision
|
||||
}
|
||||
onClick={() => {
|
||||
setEditing({
|
||||
form,
|
||||
candidate: review.candidate,
|
||||
selection: selection.revision || null,
|
||||
});
|
||||
setNotice("");
|
||||
setConflict(false);
|
||||
setReview(undefined);
|
||||
}}
|
||||
>
|
||||
继续修改这份候选
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Ui规划内容({ value }: { value: Record<string, unknown> }) {
|
||||
return (
|
||||
<dl className="plan-content">
|
||||
{Object.entries(value).map(([name, value]) => (
|
||||
<div key={name}>
|
||||
<dt>{name}</dt>
|
||||
<dd>
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: Array.isArray(value) &&
|
||||
value.every((x) => typeof x === "string")
|
||||
? value.join("\n")
|
||||
: JSON.stringify(value, null, 2)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
89
web/tests/交互/规划重看基线.test.tsx
Normal file
89
web/tests/交互/规划重看基线.test.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
/** 冲突后的重看必须等待最新基线,不能靠渲染时序取到旧版本。 */
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
const 空模式 = { type: "object", properties: {}, required: [] };
|
||||
|
||||
it("NC-plan-rebase-await-fresh:冲突后立即重看并保存,携带最新正式版本", async () => {
|
||||
const 保存请求: unknown[] = [];
|
||||
let 表单次数 = 0;
|
||||
const 延迟 = (毫秒: number) => new Promise((r) => setTimeout(r, 毫秒));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (request: Request) => {
|
||||
const 地址 = new URL(request.url).pathname;
|
||||
const 应答 = (体: unknown, 状态 = 200) =>
|
||||
new Response(JSON.stringify(体), {
|
||||
status: 状态,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (地址.startsWith("/api/v1/session")) return 应答({});
|
||||
if (地址.includes("/plan-nodes"))
|
||||
return 应答({ work_id: "work", revision: 1, nodes: [] });
|
||||
if (地址.includes("/plans/form")) {
|
||||
表单次数 += 1;
|
||||
if (表单次数 === 1) return 应答({ work_id: "work", chapter_id: null, node_id: null, plan_id: "plan", revision: 1, schema_binding: {}, content: {}, visible_schema: 空模式, editable_schema: 空模式 });
|
||||
// 冲突后的刷新故意延迟,放大“重看取到旧基线”的竞态窗口。
|
||||
await 延迟(250);
|
||||
return 应答({ work_id: "work", chapter_id: null, node_id: null, plan_id: "plan", revision: 2, schema_binding: {}, content: {}, visible_schema: 空模式, editable_schema: 空模式 });
|
||||
}
|
||||
if (地址.includes("/plans/candidates") && request.method === "GET")
|
||||
return 应答([]);
|
||||
if (地址.includes("/plans/candidates")) {
|
||||
保存请求.push(await request.clone().json());
|
||||
if (保存请求.length === 1)
|
||||
return 应答(
|
||||
{ code: "REVISION_CONFLICT", message: "规划正式版本已变化" },
|
||||
409,
|
||||
);
|
||||
return 应答({ receipt_id: "r2", action: "manual_save", results: [] });
|
||||
}
|
||||
if (地址.includes("/explorations/selection"))
|
||||
return new Response("404", { status: 404 });
|
||||
return 应答({});
|
||||
}),
|
||||
);
|
||||
const { Ui规划工作区 } = await import("../../src/功能/作品规划/规划工作区");
|
||||
const 客户端 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={客户端}>
|
||||
<MemoryRouter initialEntries={["/works/work/plans"]}>
|
||||
<Ui规划工作区 />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "另拟大纲" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存规划候选" }));
|
||||
expect(
|
||||
await screen.findByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。"),
|
||||
).toBeTruthy();
|
||||
// 冲突提示出现时最新表单仍在途;重看按钮等待新数据后才可点击。
|
||||
const 重看 = screen.getByRole("button", { name: "保留我的改动,基于当前版本重看" });
|
||||
await waitFor(() => expect((重看 as HTMLButtonElement).disabled).toBe(false));
|
||||
fireEvent.click(重看);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。"),
|
||||
).toBeNull(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存规划候选" }));
|
||||
expect(
|
||||
await screen.findByText("规划候选已保存,请打开审阅。"),
|
||||
).toBeTruthy();
|
||||
const 第二次 = 保存请求.at(-1) as { request: { expected_revision: number } };
|
||||
expect(第二次.request.expected_revision).toBe(2);
|
||||
});
|
||||
352
web/tests/旅程/探索草稿.spec.ts
Normal file
352
web/tests/旅程/探索草稿.spec.ts
Normal file
@ -0,0 +1,352 @@
|
||||
/** 实际浏览器和安装包;私人探索保存不产生正式作品或模型调用。 */
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ trace: "off" });
|
||||
test("NC-browser-private-exploration:中文缓冲恢复冲突与独立私人保存", async ({
|
||||
page,
|
||||
context,
|
||||
}, info) => {
|
||||
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
|
||||
throw new Error("需要明确的隔离后端和作者口令文件。");
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByLabel("访问口令")
|
||||
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
|
||||
await page.getByRole("button", { name: "进入工作台" }).click();
|
||||
const before = await (await page.request.get("/api/v1/works")).json();
|
||||
await page.getByRole("link", { name: "先记下一个想法 →" }).click();
|
||||
await page.getByRole("link", { name: "记下新想法" }).click();
|
||||
await page.getByLabel("稿件名称").fill("雨夜的伞(合成探索)");
|
||||
await page
|
||||
.getByLabel("我的想法")
|
||||
.fill(" 妹妹握着一把湿伞。\n“哥哥还没回来。”e\u0301🧑🏽");
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("我的想法")).toHaveValue(
|
||||
" 妹妹握着一把湿伞。\n“哥哥还没回来。”e\u0301🧑🏽",
|
||||
);
|
||||
await page.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect(page.getByText("私人稿已保存。", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "检查整理稿格式" }),
|
||||
).toBeEnabled();
|
||||
await page.getByRole("button", { name: "检查整理稿格式" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "这些地方还需要整理" }),
|
||||
).toBeVisible();
|
||||
const id = new URL(page.url()).pathname.split("/").at(-1)!;
|
||||
const api = `/api/v1/explorations/${id}`;
|
||||
const original = await (await page.request.get(api)).json();
|
||||
const other = await context.newPage();
|
||||
await other.goto(page.url());
|
||||
await expect(other.getByLabel("我的想法")).toHaveValue(original.content);
|
||||
await page.getByLabel("我的想法").fill("妹妹撑开了伞。");
|
||||
await page.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect
|
||||
.poll(async () => (await (await page.request.get(api)).json()).revision)
|
||||
.toBe(2);
|
||||
await other.getByLabel("我的想法").fill("妹妹把伞收了起来。");
|
||||
await other.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect(
|
||||
other.getByRole("heading", { name: "另一处保存了新内容" }),
|
||||
).toBeVisible();
|
||||
await expect(other.getByLabel("我的想法")).toHaveValue("妹妹把伞收了起来。");
|
||||
expect((await (await page.request.get(api)).json()).content).toBe(
|
||||
"妹妹撑开了伞。",
|
||||
);
|
||||
await other
|
||||
.getByRole("button", { name: "保留我的改稿,基于当前稿保存" })
|
||||
.click();
|
||||
await other.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect
|
||||
.poll(async () => (await (await page.request.get(api)).json()).revision)
|
||||
.toBe(3);
|
||||
await other.screenshot({
|
||||
path: info.outputPath("私人探索与保存.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await other.close();
|
||||
// 未修改的旧缓存不会盖过另一页已经保存的新稿。
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("我的想法")).toHaveValue("妹妹把伞收了起来。");
|
||||
expect(await (await page.request.get("/api/v1/works")).json()).toEqual(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
test("NC-browser-exploration-selection:明确选定后保留不可变来源", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
|
||||
throw new Error("需要作者口令引用。");
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByLabel("访问口令")
|
||||
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
|
||||
await page.getByRole("button", { name: "进入工作台" }).click();
|
||||
await page.getByRole("button", { name: "新建作品" }).click();
|
||||
await page.getByLabel("名称", { exact: true }).fill("浮岛规划(合成验收)");
|
||||
const workId = new URL(page.url()).pathname.split("/")[2];
|
||||
await page.getByRole("button", { name: "保存档案" }).click();
|
||||
await expect(page.getByLabel("新章节标题")).toBeVisible();
|
||||
await page.goto("/explorations");
|
||||
await page.getByRole("link", { name: "记下新想法" }).click();
|
||||
const headings = [
|
||||
"根设定",
|
||||
"一、作品定位与核心命题",
|
||||
"二、故事世界与社会系统",
|
||||
"三、角色与关系",
|
||||
"四、故事机制与关键实体",
|
||||
"五、阶段与状态变化",
|
||||
"六、情节架构与全书大纲",
|
||||
"七、主题与读者体验弧线",
|
||||
"八、信息、悬念与伏笔管理",
|
||||
"九、叙事执行与一致性",
|
||||
"待决与取舍",
|
||||
];
|
||||
const source =
|
||||
"# 浮岛|作品探索\n\n" +
|
||||
headings.map((h) => `## ${h}\n未探索:先保留浮岛停电的画面。`).join("\n\n");
|
||||
await page.getByLabel("稿件名称").fill("浮岛的夜晚");
|
||||
await page.getByLabel("我的想法").fill(source);
|
||||
await page.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "检查整理稿格式" }),
|
||||
).toBeEnabled();
|
||||
await page.getByRole("button", { name: "检查整理稿格式" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "整理稿格式完整" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("交给哪部作品").selectOption(workId);
|
||||
const button = page.getByRole("button", { name: "选定本稿作为规划依据" });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(
|
||||
page.getByText("已选定为作品规划依据。正式规划仍需单独确认。", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
const path = `/api/v1/works/${workId}/exploration-selection`;
|
||||
const chosen = await (await page.request.get(path)).json();
|
||||
expect(chosen.revision).toBe(1);
|
||||
expect(chosen.selection.snapshot.content).toBe(source);
|
||||
await page.screenshot({
|
||||
path: info.outputPath("探索方向交接.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByLabel("我的想法").fill(source + "\n尚未选定的新分歧。");
|
||||
await page.getByRole("button", { name: "保存私人稿" }).click();
|
||||
await expect(page.getByText("私人稿已保存。", { exact: true })).toBeVisible();
|
||||
expect(await (await page.request.get(path)).json()).toEqual(chosen);
|
||||
expect(
|
||||
(await (await page.request.get(`/api/v1/works/${workId}/chapters`)).json())
|
||||
.chapters,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("NC-browser-plan-approval:动态大纲候选确认与双页冲突", async ({
|
||||
page,
|
||||
context,
|
||||
}, info) => {
|
||||
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
|
||||
throw new Error("需要作者口令引用。");
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByLabel("访问口令")
|
||||
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
|
||||
await page.getByRole("button", { name: "进入工作台" }).click();
|
||||
await page.getByRole("button", { name: "新建作品" }).click();
|
||||
await page.getByLabel("名称", { exact: true }).fill("雪中的信(规划验收)");
|
||||
const work = new URL(page.url()).pathname.split("/")[2];
|
||||
await page.getByRole("button", { name: "保存档案" }).click();
|
||||
await page.getByRole("link", { name: "查看作品规划" }).click();
|
||||
await page.getByRole("button", { name: "另拟大纲" }).click();
|
||||
await page.getByLabel("名称", { exact: true }).fill("雪中的信");
|
||||
await page
|
||||
.getByLabel("主线一句话", { exact: true })
|
||||
.fill("邮差寻找一封无人认领的信。");
|
||||
await page.getByRole("button", { name: "保存规划候选" }).click();
|
||||
const plans = `/api/v1/works/${work}/plans`;
|
||||
await expect(
|
||||
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(await (await page.request.get(plans)).json()).toEqual([]);
|
||||
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
await page.getByRole("button", { name: "继续修改这份候选" }).click();
|
||||
await page.getByLabel("名称", { exact: true }).fill("雪后的信");
|
||||
await page.getByRole("button", { name: "保存规划候选" }).click();
|
||||
await expect(
|
||||
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await (await page.request.get(`${plans}/candidates`)).json())[0]
|
||||
.revision,
|
||||
)
|
||||
.toBe(2);
|
||||
const revised = (
|
||||
await (await page.request.get(`${plans}/candidates`)).json()
|
||||
)[0];
|
||||
expect(revised.revision).toBe(2);
|
||||
const original = await (
|
||||
await page.request.get(
|
||||
`/api/v1/plan-candidates/${revised.candidate_id}?version=1`,
|
||||
)
|
||||
).json();
|
||||
expect(original.content["名称"]).toBe("雪中的信");
|
||||
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
await page.getByRole("button", { name: "确认这份规划" }).click();
|
||||
await expect(
|
||||
page.getByText("这份规划已确认。", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect((await (await page.request.get(plans)).json())[0].revision).toBe(1);
|
||||
await page.getByRole("button", { name: "另拟大纲" }).click();
|
||||
await page
|
||||
.getByLabel("主线一句话", { exact: true })
|
||||
.fill("邮差发现寄信人始终住在隔壁。");
|
||||
await page.getByRole("button", { name: "保存规划候选" }).click();
|
||||
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
const other = await context.newPage();
|
||||
await other.goto(page.url());
|
||||
await other.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
await expect(
|
||||
other.getByRole("button", { name: "确认这份规划" }),
|
||||
).toBeVisible();
|
||||
const drafting = await context.newPage();
|
||||
await drafting.goto(page.url());
|
||||
await drafting.getByRole("button", { name: "另拟大纲" }).click();
|
||||
await drafting
|
||||
.getByLabel("主线一句话", { exact: true })
|
||||
.fill("邮差决定亲手递出这封信。");
|
||||
await page.screenshot({
|
||||
path: info.outputPath("大纲版本审阅.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "确认这份规划" }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await (await page.request.get(plans)).json())[0].revision,
|
||||
)
|
||||
.toBe(2);
|
||||
await other.getByRole("button", { name: "确认这份规划" }).click();
|
||||
await expect(other.getByRole("alert")).toBeVisible();
|
||||
expect((await (await page.request.get(plans)).json())[0].revision).toBe(2);
|
||||
await drafting.getByRole("button", { name: "保存规划候选" }).click();
|
||||
await expect(
|
||||
drafting.getByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(drafting.getByLabel("主线一句话", { exact: true })).toHaveValue(
|
||||
"邮差决定亲手递出这封信。",
|
||||
);
|
||||
await drafting
|
||||
.getByRole("button", { name: "保留我的改动,基于当前版本重看" })
|
||||
.click();
|
||||
await expect(drafting.getByLabel("主线一句话", { exact: true })).toHaveValue(
|
||||
"邮差决定亲手递出这封信。",
|
||||
);
|
||||
// 重看会等待最新正式版本;冲突面板消失后才重建了基线。
|
||||
await expect(
|
||||
drafting.getByText("保存基线已变化。你的输入仍在下方,请先比较当前内容。", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await drafting.getByRole("button", { name: "保存规划候选" }).click();
|
||||
await expect(
|
||||
drafting.getByText("规划候选已保存,请打开审阅。", { exact: true }),
|
||||
).toBeVisible();
|
||||
await drafting.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
await drafting.getByRole("button", { name: "确认这份规划" }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await (await page.request.get(plans)).json())[0].revision,
|
||||
)
|
||||
.toBe(3);
|
||||
expect(
|
||||
(await (await page.request.get(plans)).json())[0].content["主线一句话"],
|
||||
).toBe("邮差决定亲手递出这封信。");
|
||||
await drafting.screenshot({
|
||||
path: info.outputPath("规划冲突恢复.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await drafting.close();
|
||||
await other.close();
|
||||
});
|
||||
|
||||
test("NC-browser-plan-nodes:卷章场景目录接通动态场景规划", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
if (!process.env.MUSE_AUTHOR_PASSWORD_FILE)
|
||||
throw new Error("需要作者口令引用。");
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByLabel("访问口令")
|
||||
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
|
||||
await page.getByRole("button", { name: "进入工作台" }).click();
|
||||
await page.getByRole("button", { name: "新建作品" }).click();
|
||||
await page.getByLabel("名称", { exact: true }).fill("浮岛航线(节点验收)");
|
||||
const work = new URL(page.url()).pathname.split("/")[2];
|
||||
await page.getByRole("button", { name: "保存档案" }).click();
|
||||
await page.getByRole("link", { name: "查看作品规划" }).click();
|
||||
await page.getByText("卷、章与场景", { exact: true }).click();
|
||||
await page.getByLabel("新节点名称", { exact: true }).fill("第一卷");
|
||||
await page.getByRole("button", { name: "加入目录" }).click();
|
||||
await page.getByLabel("新增层级").selectOption("chapter");
|
||||
await page.getByLabel("新节点名称", { exact: true }).fill("起航");
|
||||
await page
|
||||
.getByRole("combobox", { name: "新节点归属", exact: true })
|
||||
.selectOption({ label: "第一卷" });
|
||||
await page.getByRole("button", { name: "加入目录" }).click();
|
||||
await page.getByLabel("新增层级").selectOption("scene");
|
||||
await page.getByLabel("新节点名称", { exact: true }).fill("门外访客");
|
||||
await page
|
||||
.getByRole("combobox", { name: "新节点归属", exact: true })
|
||||
.selectOption({ label: "起航" });
|
||||
await page.getByRole("button", { name: "加入目录" }).click();
|
||||
await page.getByRole("button", { name: "保存节点目录" }).click();
|
||||
await expect(
|
||||
page.getByText("卷章与场景目录已保存。", { exact: true }),
|
||||
).toBeVisible();
|
||||
const directory = await (
|
||||
await page.request.get(`/api/v1/works/${work}/plan-nodes`)
|
||||
).json();
|
||||
const chapter = directory.nodes.find(
|
||||
(n: { kind: string }) => n.kind === "chapter",
|
||||
),
|
||||
scene = directory.nodes.find((n: { kind: string }) => n.kind === "scene");
|
||||
expect(directory.revision).toBe(1);
|
||||
expect(scene.parent_id).toBe(chapter.node_id);
|
||||
await page.getByRole("link", { name: "填写门外访客的规划" }).click();
|
||||
await page.getByRole("button", { name: "另拟节点计划" }).click();
|
||||
await page
|
||||
.getByLabel("目标推进收束", { exact: true })
|
||||
.fill("访客想借一盏灯,船长让他进屋。");
|
||||
await page.getByRole("button", { name: "保存规划候选" }).click();
|
||||
await expect(
|
||||
page.getByText("规划候选已保存,请打开审阅。", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /候选 \d+ · 待决定/ }).click();
|
||||
await page.getByRole("button", { name: "确认这份规划" }).click();
|
||||
await expect(
|
||||
page.getByText("这份规划已确认。", { exact: true }),
|
||||
).toBeVisible();
|
||||
const plans = await (
|
||||
await page.request.get(`/api/v1/works/${work}/plans`)
|
||||
).json();
|
||||
expect(plans).toHaveLength(1);
|
||||
expect(plans[0]).toMatchObject({
|
||||
node_id: scene.node_id,
|
||||
chapter_id: chapter.node_id,
|
||||
type_id: "scene",
|
||||
revision: 1,
|
||||
});
|
||||
expect(plans[0].content["目标推进收束"]).toBe(
|
||||
"访客想借一盏灯,船长让他进屋。",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: info.outputPath("场景节点规划.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user