diff --git a/.agent/skills/操作/拆解书稿/SKILL.md b/.agent/skills/操作/拆解书稿/SKILL.md new file mode 100644 index 0000000..4dd1744 --- /dev/null +++ b/.agent/skills/操作/拆解书稿/SKILL.md @@ -0,0 +1,26 @@ +--- +name: 拆解书稿 +description: 发起参考作品分窗拆解并跟踪进度;同源互斥、失败可恢复。研究一部作品的章节、实体与写法时使用。 +--- + +# 拆解书稿 + +把参考作品来源按窗口拆解为版本化分析成果;同源不可并发,失败停在可解释断点。 + +## 何时使用 + +- 需要研究参考作品的章节节拍、叙事阶段、实体与写法时。 +- 来源更新到新版本,需要重新拆解时。 + +## 操作步骤 + +1. 在「拆书工作区」选定来源,写明选书理由(写入拆书依据),或经 `python -m muse 研究 拆解 <请求JSON>` 发起。 +2. 台账显示每次拆解的状态、完成窗口、失败窗与歧义数;版本身份由来源版本与窗计划哈希确定。 +3. 失败任务点「恢复重试」或 `POST /api/v1/analyses/{task_id}/resume`;已完成窗口不重跑。 +4. 任务完成后在「原文与分析对照」逐窗查看原文与分析结果。 + +## 边界 + +- 同一来源同时只有一个拆解任务;接管由作用域租约强制。 +- 输入漂移的窗口拒绝写入旧分析版本;失败不伪称窗口完成。 +- 分析成果是参考作品观察,不自动成为本书设定。 diff --git a/.agent/skills/操作/拆解书稿/目录.md b/.agent/skills/操作/拆解书稿/目录.md new file mode 100644 index 0000000..1ec943b --- /dev/null +++ b/.agent/skills/操作/拆解书稿/目录.md @@ -0,0 +1,3 @@ +| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 | +|------|----------|----------|----------|----------| +| 技能说明 | SKILL.md | 参考作品分窗拆解的发起与进度跟踪 | 研究参考作品章节、实体与写法时 | 同源互斥;失败可恢复;分析不是本书事实 | diff --git a/.agent/skills/操作/检查与维护拆书结果/SKILL.md b/.agent/skills/操作/检查与维护拆书结果/SKILL.md new file mode 100644 index 0000000..c6f744e --- /dev/null +++ b/.agent/skills/操作/检查与维护拆书结果/SKILL.md @@ -0,0 +1,26 @@ +--- +name: 检查与维护拆书结果 +description: 检查拆书台账、歧义与失败窗口,按窗口重算或以新版本重建;旧结论保留可回查。维护分析质量时使用。 +--- + +# 检查与维护拆书结果 + +对拆书成果做检查与维护:歧义逐项处理、失败窗口重算、版本对照与选用。 + +## 何时使用 + +- 台账出现失败窗或歧义时。 +- 来源前进到新版本,需要对照或重建分析时。 + +## 操作步骤 + +1. `python -m muse 研究 进度 <来源ID>` 或「拆书工作区」台账查看状态。 +2. `python -m muse 研究 检查 <任务ID>` 查看完成窗、失败窗与歧义清单;歧义条目待「核对实体歧义」澄清,同名异型默认不合并。 +3. 失败窗重算:经恢复入口带 `retry_windows` 指定窗口;已完成窗不重跑,成本包络含重试余量。 +4. 来源新版本重建:直接重新发起拆解;旧版本分析保留可回查,版本身份不冲突。 + +## 边界 + +- 只撤销本任务可证明归属的中间产物;不触碰外部确认与来源版本。 +- 榜单名次与拆解成果都是观察记录,不作效果归因。 +- 重建不删除参考原文,不暗改本书事实。 diff --git a/.agent/skills/操作/检查与维护拆书结果/目录.md b/.agent/skills/操作/检查与维护拆书结果/目录.md new file mode 100644 index 0000000..1cd5b1c --- /dev/null +++ b/.agent/skills/操作/检查与维护拆书结果/目录.md @@ -0,0 +1,3 @@ +| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 | +|------|----------|----------|----------|----------| +| 技能说明 | SKILL.md | 拆书成果的检查、重算与版本维护 | 处理失败窗、歧义与版本对照时 | 歧义不误并;旧结论保留;只撤可证明归属 | diff --git a/.agent/skills/操作/目录.md b/.agent/skills/操作/目录.md index 646bea9..315c6e1 100644 --- a/.agent/skills/操作/目录.md +++ b/.agent/skills/操作/目录.md @@ -11,3 +11,5 @@ | 确认事实增量 | [确认事实增量/SKILL.md](确认事实增量/SKILL.md) | 章后派生事实提案的作者独立确认 | 采纳或保存正文后处理候选事实提案时 | 只确认已审提案;不代作者决定 | | 导入资料 | [导入资料/SKILL.md](导入资料/SKILL.md) | 参考书、网页、笔记与灵感的来源导入 | 建立来源、版本与原文对照时 | 授权用途决定消费范围;外部资料不是事实授权 | | 采集资料与榜单 | [采集资料与榜单/SKILL.md](采集资料与榜单/SKILL.md) | 具名榜单与网页的时点采集 | 榜单观察与网页资料留存时 | 失败如实保留;不补造数据、不作效果归因 | +| 拆解书稿 | [拆解书稿/SKILL.md](拆解书稿/SKILL.md) | 参考作品分窗拆解的发起与进度 | 研究参考作品章节、实体与写法时 | 同源互斥;失败可恢复;分析不是本书事实 | +| 检查与维护拆书结果 | [检查与维护拆书结果/SKILL.md](检查与维护拆书结果/SKILL.md) | 拆书成果的检查、重算与版本维护 | 处理失败窗、歧义与版本对照时 | 歧义不误并;旧结论保留 | diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index 8eb56e8..00b411d 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -2496,7 +2496,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/muse______http____________________8" + "$ref": "#/components/schemas/muse______http____________________9" } } } @@ -2555,7 +2555,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/muse______http____________________9" + "$ref": "#/components/schemas/muse______http____________________10" } } } @@ -3033,6 +3033,146 @@ } } }, + "/api/v1/sources/{source_id}/analyses": { + "post": { + "tags": [ + "资料研究" + ], + "summary": "发起拆书", + "operationId": "start_source_analysis", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/muse______http____________________8" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Start Source Analysis" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "资料研究" + ], + "summary": "列出拆书", + "operationId": "list_source_analyses", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response List Source Analyses" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/analyses/{task_id}/resume": { + "post": { + "tags": [ + "资料研究" + ], + "summary": "恢复拆书", + "operationId": "resume_source_analysis", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Resume Source Analysis" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/chapters/{chapter_id}/writing-candidates": { "get": { "tags": [ @@ -7907,6 +8047,47 @@ ], "title": "会话授权请求" }, + "muse______http____________________10": { + "properties": { + "report_id": { + "type": "string", + "title": "Report Id" + }, + "candidate_id": { + "type": "string", + "title": "Candidate Id" + }, + "candidate_revision": { + "type": "integer", + "title": "Candidate Revision" + }, + "snapshot_id": { + "type": "string", + "title": "Snapshot Id" + }, + "basic": { + "$ref": "#/components/schemas/muse______http__________________3" + }, + "continuity": { + "$ref": "#/components/schemas/muse______http___________________5" + }, + "verdict": { + "type": "string", + "title": "Verdict" + } + }, + "type": "object", + "required": [ + "report_id", + "candidate_id", + "candidate_revision", + "snapshot_id", + "basic", + "continuity", + "verdict" + ], + "title": "审校报告呈现" + }, "muse______http____________________2": { "properties": { "command_id": { @@ -8165,6 +8346,68 @@ "title": "登记榜单请求" }, "muse______http____________________8": { + "properties": { + "command_id": { + "type": "string", + "minLength": 1, + "title": "Command Id" + }, + "config_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Config Id" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "retry_windows": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Retry Windows" + }, + "window_chapters": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Window Chapters" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "command_id" + ], + "title": "发起拆书请求" + }, + "muse______http____________________9": { "properties": { "task_id": { "type": "string", @@ -8181,47 +8424,6 @@ "writing_task_id" ], "title": "发起生成回执" - }, - "muse______http____________________9": { - "properties": { - "report_id": { - "type": "string", - "title": "Report Id" - }, - "candidate_id": { - "type": "string", - "title": "Candidate Id" - }, - "candidate_revision": { - "type": "integer", - "title": "Candidate Revision" - }, - "snapshot_id": { - "type": "string", - "title": "Snapshot Id" - }, - "basic": { - "$ref": "#/components/schemas/muse______http__________________3" - }, - "continuity": { - "$ref": "#/components/schemas/muse______http___________________5" - }, - "verdict": { - "type": "string", - "title": "Verdict" - } - }, - "type": "object", - "required": [ - "report_id", - "candidate_id", - "candidate_revision", - "snapshot_id", - "basic", - "continuity", - "verdict" - ], - "title": "审校报告呈现" } } } diff --git a/docs/系统架构/新版设计/改造计划/分配索引.md b/docs/系统架构/新版设计/改造计划/分配索引.md index 60794d7..b49d971 100644 --- a/docs/系统架构/新版设计/改造计划/分配索引.md +++ b/docs/系统架构/新版设计/改造计划/分配索引.md @@ -1100,7 +1100,7 @@ | [web/src/功能/资料研究/拆书工作区.tsx](../文件设计/前端-功能工作台.md#file-adfdf2edec303955) | [W18](P4-研究与知识资产.md#w18) | [W17](P4-研究与知识资产.md#w17)、[W18](P4-研究与知识资产.md#w18) | 页面随对应真实接口接线,业务约束由后端执行 | | [web/src/功能/资料研究/数据.ts](../文件设计/前端-功能工作台.md#file-ecd1fd4bf151c343) | [W18](P4-研究与知识资产.md#w18) | [W17](P4-研究与知识资产.md#w17)、[W18](P4-研究与知识资产.md#w18) | 页面随对应真实接口接线,业务约束由后端执行 | | [web/src/功能/资料研究/榜单与选题页.tsx](../文件设计/前端-功能工作台.md#file-3dc463a2bc9050e8) | [W18](P4-研究与知识资产.md#w18) | [W17](P4-研究与知识资产.md#w17)、[W18](P4-研究与知识资产.md#w18) | 页面随对应真实接口接线,业务约束由后端执行 | -| [web/src/功能/资料研究/资料页.tsx](../文件设计/前端-功能工作台.md#file-529b1c403b83b56f) | [W18](P4-研究与知识资产.md#w18) | [W17](P4-研究与知识资产.md#w17)、[W18](P4-研究与知识资产.md#w18) | 页面随对应真实接口接线,业务约束由后端执行 | +| [web/src/功能/资料研究/资料页.tsx](../文件设计/前端-应用与通用界面.md#file-529b1c403b83b56f) | [W18](P4-研究与知识资产.md#w18) | [W17](P4-研究与知识资产.md#w17)、[W18](P4-研究与知识资产.md#w18) | 页面随对应真实接口接线,业务约束由后端执行 | | [web/src/应用/作品会话.ts](../文件设计/前端-应用与通用界面.md#file-5f2e8c5e70fe4284) | [W07](P1-安装与运行底座.md#w07) | [W07](P1-安装与运行底座.md#w07)、[W28](P6-交付与全量恢复.md#w28) | 前端工程、应用框架、客户端或共享展示 | | [web/src/应用/作者会话.ts](../文件设计/前端-应用与通用界面.md#file-ce6fa23dad8a5cbf) | [W07](P1-安装与运行底座.md#w07) | [W07](P1-安装与运行底座.md#w07)、[W28](P6-交付与全量恢复.md#w28) | 前端工程、应用框架、客户端或共享展示 | | [web/src/应用/依赖装配.tsx](../文件设计/前端-应用与通用界面.md#file-d5edc843e3e30305) | [W07](P1-安装与运行底座.md#w07) | [W07](P1-安装与运行底座.md#w07)、[W28](P6-交付与全量恢复.md#w28) | 前端工程、应用框架、客户端或共享展示 | diff --git a/docs/系统架构/新版设计/改造计划/工作包清单.json b/docs/系统架构/新版设计/改造计划/工作包清单.json index 271d36a..691c5ff 100644 --- a/docs/系统架构/新版设计/改造计划/工作包清单.json +++ b/docs/系统架构/新版设计/改造计划/工作包清单.json @@ -2833,8 +2833,18 @@ "模块设计/B03-资料研究.md", "数据模型/旧知识记录分流.md" ], - "status": "planned", - "execution_evidence": [], + "status": "verified", + "execution_evidence": [ + { + "batch": "R2-20260909", + "date": "2026-09-11", + "summary": "拆书、归并、互斥与补偿整包落地:分章切窗与漂移守卫、逐窗 S02 模型提取、确定性归并(同名异型歧义保留)、分析版本台账(uuid5 身份、失败窗单独重试、旧结论不抹)、同源互斥(排他范围领取层强制)与窗口断点补偿;CLI+HTTP+工作台拆书工作区与对照;12 个新 NC 通过;离线 275、非宿主数据库 231、前端 27;浏览器 2/2+视觉判定(一轮表单遮挡修复后通过)。整包验证一次通过。", + "evidence": [ + ".agents.local/改造/R2-20260909/W18/回合开发/整包/执行证据.md", + ".agents.local/改造/R2-20260909/W18/回合验证/整包/验证报告.md" + ] + } + ], "owned_file_paths": [ ".agent/skills/操作/拆解书稿/SKILL.md", ".agent/skills/操作/拆解书稿/目录.md", diff --git a/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md b/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md index 59d6888..d105373 100644 --- a/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md +++ b/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md @@ -19,7 +19,7 @@ agent-example/ │ ├── vite-env.d.ts # 构建环境类型 │ ├── 功能/ │ │ └── 资料研究/ - │ │ └── 资料对照页.tsx # 来源与原文对照页面 + │ │ └── 资料页.tsx # 来源与原文对照页面(W18 起并入研究入口) │ ├── 应用/ │ │ ├── 作品会话.ts # 切作品屏障、取消和迟到隔离 │ │ ├── 作者会话.ts # 作者登录与会话 @@ -749,16 +749,45 @@ agent-example/ - 边界:真实隔离库与真实浏览器;不 mock 后端;视觉判定独立留证。 - 目标:通过任务与失败候选的检查呈现及服务端阻断可复核,页面无预期外控制台错误。 - + -### `web/src/功能/资料研究/资料对照页.tsx` +### `web/src/功能/资料研究/资料页.tsx` - 归属与种类:前端;页面组件。 -- 意图:让作者在一页内对照来源版本原文、清理候选结果与榜单快照。 -- 职责:来源清单选择、版本原文呈现、清理对照应用、快照列表(失败态如实显示)。 +- 意图:让作者在一页内对照来源版本原文、清理候选结果并进入研究工作区。 +- 职责:来源清单选择、版本原文呈现、清理对照应用、研究入口链接。 - 边界:只读呈现与显式动作;不伪造数据、不推断效果因果。 -- 目标:来源、原文与快照可追溯;失败可见可重试。 +- 目标:来源、原文可追溯;清理对照可见;研究入口可达。 - 合同依据:[B03 资料研究](../模块设计/B03-资料研究.md)。 +- 旧文件承接:取代 W17 的 `资料对照页.tsx`(榜单与拆书职责分页,见 榜单与选题页/拆书工作区)。 + + + +### `web/src/功能/资料研究/拆书工作区.tsx` + +- 归属与种类:前端;页面组件。 +- 意图:发起来源拆解、跟踪窗口进度并从失败恢复。 +- 职责:来源选择、拆解发起(选书理由/运行配置)、台账与失败窗恢复、原文分析对照入口。 +- 边界:同源互斥与漂移错误如实显示;不伪造进度。 +- 目标:错误与缺证据可逐项处理;失败可恢复。 + + + +### `web/src/功能/资料研究/原文分析对照.tsx` + +- 归属与种类:前端;组件。 +- 意图:窗口原文与逐窗分析结果并排对照。 +- 职责:按码点区间取原文、实体/写法计数与歧义提示呈现。 +- 边界:只呈现已登记检查点内容;不补造分析。 + + + +### `web/src/功能/资料研究/榜单与选题页.tsx` + +- 归属与种类:前端;页面组件。 +- 意图:榜单时点观察与样本选题依据集中呈现。 +- 职责:快照列表(失败如实显示)、样本字数比较、选书理由发起拆解。 +- 边界:观察差异只陈述指标;不构成优劣结论或效果因果。 diff --git a/docs/系统架构/新版设计/目标文件清单.json b/docs/系统架构/新版设计/目标文件清单.json index b508b4b..2543fbd 100644 --- a/docs/系统架构/新版设计/目标文件清单.json +++ b/docs/系统架构/新版设计/目标文件清单.json @@ -14223,6 +14223,12 @@ "legacy_sources": [], "design_location": "文件设计/测试与夹具.md#file-49d4f4baa42126b9", "test_case_refs": [ + "NC-w18-18a01a", + "NC-w18-18a02b", + "NC-w18-18a03c", + "NC-w18-18a04d", + "NC-w18-18a05e", + "NC-w18-18a06f", "TC-0045c6f3ed48", "TC-030fe0a0b69b", "TC-052b4fa41fd7", @@ -16517,6 +16523,8 @@ ], "design_location": "文件设计/测试与夹具.md#file-33a0f3ae98d9755f", "test_case_refs": [ + "NC-w18-18b01a", + "NC-w18-18b02b", "TC-2c9a90e4b1c1", "TC-43841d36dba4", "TC-49f968fe703b", @@ -16667,6 +16675,9 @@ "design_location": "文件设计/测试与夹具.md#file-2c4bcda0dfce8f6e", "legacy_sources": [], "test_case_refs": [ + "NC-w18-18b03c", + "NC-w18-18b04d", + "NC-w18-18b05e", "TC-00c1a8dabf0d", "TC-039b3827b252", "TC-068b740760eb", @@ -21591,21 +21602,6 @@ "NC-browser-generation-dialog" ] }, - { - "path": "web/src/功能/资料研究/资料对照页.tsx", - "owner": "前端", - "kind": "源码", - "responsibility": "来源与原文对照页面", - "project_part": "前端", - "intent": "来源与原文对照页面", - "boundary": "只经公开接口与登记输入;不越权、不伪造完成", - "goal": "行为可复验", - "contract_basis": "专属业务职责", - "status": "目标合同;不表示已实现或已验证", - "legacy_sources": [], - "design_location": "文件设计/前端-应用与通用界面.md#file-8d1a7fc626190cb1", - "test_case_refs": [] - }, { "path": "web/tests/旅程/资料对照.spec.ts", "owner": "验证", @@ -21620,7 +21616,8 @@ "legacy_sources": [], "design_location": "文件设计/前端-应用与通用界面.md#file-0c134b40a9faae3a", "test_case_refs": [ - "NC-w17-browser" + "NC-w17-browser", + "NC-w18-browser" ] }, { @@ -21660,6 +21657,23 @@ "NC-w17-w17b01", "NC-w17-w17b02" ] + }, + { + "path": "web/src/功能/资料研究/资料页.tsx", + "owner": "前端", + "kind": "源码", + "responsibility": "来源与原文对照页面(W18 起并入研究入口)", + "project_part": "前端", + "intent": "来源、版本原文与清理对照呈现", + "boundary": "只读呈现与显式动作;不伪造数据、不推断效果因果", + "goal": "行为可复验", + "contract_basis": "专属业务职责", + "status": "目标合同;不表示已实现或已验证", + "legacy_sources": [], + "design_location": "文件设计/前端-应用与通用界面.md#file-529b1c403b83b56f", + "test_case_refs": [ + "NC-w17-browser" + ] } ], "contract_fields": { diff --git a/docs/系统架构/新版设计/项目目录与文件职责.md b/docs/系统架构/新版设计/项目目录与文件职责.md index 4fce824..a2c47c7 100644 --- a/docs/系统架构/新版设计/项目目录与文件职责.md +++ b/docs/系统架构/新版设计/项目目录与文件职责.md @@ -1305,8 +1305,7 @@ agent-example/ │ │ │ ├── 拆书工作区.tsx # 分析进度与恢复 │ │ │ ├── 数据.ts # 资料研究查询和业务命令 │ │ │ ├── 榜单与选题页.tsx # 快照、比较和选书 -│ │ │ ├── 资料对照页.tsx # 来源与原文对照页面 -│ │ │ └── 资料页.tsx # 来源导入和版本 +│ │ │ └── 资料页.tsx # 来源与原文对照页面(W18 起并入研究入口) │ │ ├── 应用/ │ │ │ ├── 作品会话.ts # 切作品屏障、取消和迟到隔离 │ │ │ ├── 作者会话.ts # 作者登录与会话 diff --git a/docs/系统架构/新版设计/验证设计/工作台与工程用例.md b/docs/系统架构/新版设计/验证设计/工作台与工程用例.md index 451de16..a970146 100644 --- a/docs/系统架构/新版设计/验证设计/工作台与工程用例.md +++ b/docs/系统架构/新版设计/验证设计/工作台与工程用例.md @@ -3246,3 +3246,15 @@ - 环境:真实隔离库 + Chrome。 - 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + +## NC-w18-browser · NC-browser-research-workbench:拆书工作区发起与台账呈现 + +- 位置:`web/tests/旅程/资料对照.spec.ts::NC-browser-research-workbench:拆书工作区发起与台账呈现`。 +- 给定:隔离浏览器环境与预置来源。 +- 动作:打开拆书工作区并按选书理由发起拆解。 +- 预期:台账条目出现;进度与失败如实呈现。 +- 环境:真实隔离库 + Chrome。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + diff --git a/docs/系统架构/新版设计/验证设计/测试用例清单.json b/docs/系统架构/新版设计/验证设计/测试用例清单.json index bd5c347..d2b1194 100644 --- a/docs/系统架构/新版设计/验证设计/测试用例清单.json +++ b/docs/系统架构/新版设计/验证设计/测试用例清单.json @@ -123223,6 +123223,198 @@ "environment": "真实隔离库 + Chrome", "verification_state": "已实现未执行", "execution_evidence": ".agents.local/改造/R2-20260909/W17/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a01a", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_切窗覆盖与无重叠__18a01a", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a01a", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "切窗覆盖与无重叠", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a02b", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_切窗漂移拒绝写入__18a02b", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a02b", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "切窗漂移拒绝写入", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a03c", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_无章界整篇单窗__18a03c", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a03c", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "无章界整篇单窗", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a04d", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_同名异型歧义不误并__18a04d", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a04d", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "同名异型歧义不误并", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a05e", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_同型别名带证据合并__18a05e", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a05e", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "同型别名带证据合并", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18a06f", + "file": "tests/单元/test_分章切窗与实体归并.py", + "symbol": "test_样本比较是观察陈述__18a06f", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18a06f", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "样本比较是观察陈述", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "离线单元", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18b01a", + "file": "tests/集成/test_同作品抽取互斥.py", + "symbol": "test_同源并发拆解被作用域互斥阻塞__18b01a", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18b01a", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "同源并发拆解被作用域互斥阻塞", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "真实隔离 PG", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18b02b", + "file": "tests/集成/test_同作品抽取互斥.py", + "symbol": "test_失败任务释放范围可重建__18b02b", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18b02b", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "失败任务释放范围可重建", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "真实隔离 PG", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18b03c", + "file": "tests/集成/test_抽取版本与补偿.py", + "symbol": "test_批量原子登记与版本身份__18b03c", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18b03c", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "批量原子登记与版本身份", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "真实隔离 PG", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18b04d", + "file": "tests/集成/test_抽取版本与补偿.py", + "symbol": "test_失败窗口断点续跑只补缺失窗__18b04d", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18b04d", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "失败窗口断点续跑只补缺失窗", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "真实隔离 PG", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-18b05e", + "file": "tests/集成/test_抽取版本与补偿.py", + "symbol": "test_来源前进旧版本结论保留且新拆书指向新版本__18b05e", + "design_location": "验证设计/资料与结构用例.md#nc-w18-18b05e", + "contract": "模块设计/B03-资料研究.md", + "given": "确定性输入或隔离数据库", + "when": "来源前进旧版本结论保留且新拆书指向新版本", + "then": [ + "行为可复验", + "失败与歧义如实呈现" + ], + "environment": "真实隔离 PG", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" + }, + { + "case_id": "NC-w18-browser", + "file": "web/tests/旅程/资料对照.spec.ts", + "symbol": "NC-browser-research-workbench:拆书工作区发起与台账呈现", + "design_location": "验证设计/工作台与工程用例.md#nc-browser-research-workbench", + "contract": "模块设计/B03-资料研究.md", + "given": "隔离浏览器环境与预置来源", + "when": "打开拆书工作区并按选书理由发起拆解", + "then": [ + "台账条目出现", + "进度与失败如实呈现" + ], + "environment": "真实隔离库 + Chrome", + "verification_state": "已实现未执行", + "execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/" } ] } diff --git a/docs/系统架构/新版设计/验证设计/资料与结构用例.md b/docs/系统架构/新版设计/验证设计/资料与结构用例.md index 3d4b176..7843c66 100644 --- a/docs/系统架构/新版设计/验证设计/资料与结构用例.md +++ b/docs/系统架构/新版设计/验证设计/资料与结构用例.md @@ -3745,3 +3745,135 @@ - 环境:隔离 PostgreSQL、真实 CLI 与 ASGI。 - 合同:[权威定义](../接口契约/元数据投影与版本.md)。 + + + +## NC-w18-18a01a · test_切窗覆盖与无重叠__18a01a + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_切窗覆盖与无重叠__18a01a`。 +- 给定:确定性输入或隔离数据库。 +- 动作:切窗覆盖与无重叠。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18a02b · test_切窗漂移拒绝写入__18a02b + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_切窗漂移拒绝写入__18a02b`。 +- 给定:确定性输入或隔离数据库。 +- 动作:切窗漂移拒绝写入。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18a03c · test_无章界整篇单窗__18a03c + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_无章界整篇单窗__18a03c`。 +- 给定:确定性输入或隔离数据库。 +- 动作:无章界整篇单窗。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18a04d · test_同名异型歧义不误并__18a04d + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_同名异型歧义不误并__18a04d`。 +- 给定:确定性输入或隔离数据库。 +- 动作:同名异型歧义不误并。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18a05e · test_同型别名带证据合并__18a05e + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_同型别名带证据合并__18a05e`。 +- 给定:确定性输入或隔离数据库。 +- 动作:同型别名带证据合并。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18a06f · test_样本比较是观察陈述__18a06f + +- 位置:`tests/单元/test_分章切窗与实体归并.py::test_样本比较是观察陈述__18a06f`。 +- 给定:确定性输入或隔离数据库。 +- 动作:样本比较是观察陈述。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:离线单元。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18b01a · test_同源并发拆解被作用域互斥阻塞__18b01a + +- 位置:`tests/集成/test_同作品抽取互斥.py::test_同源并发拆解被作用域互斥阻塞__18b01a`。 +- 给定:确定性输入或隔离数据库。 +- 动作:同源并发拆解被作用域互斥阻塞。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18b02b · test_失败任务释放范围可重建__18b02b + +- 位置:`tests/集成/test_同作品抽取互斥.py::test_失败任务释放范围可重建__18b02b`。 +- 给定:确定性输入或隔离数据库。 +- 动作:失败任务释放范围可重建。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18b03c · test_批量原子登记与版本身份__18b03c + +- 位置:`tests/集成/test_抽取版本与补偿.py::test_批量原子登记与版本身份__18b03c`。 +- 给定:确定性输入或隔离数据库。 +- 动作:批量原子登记与版本身份。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18b04d · test_失败窗口断点续跑只补缺失窗__18b04d + +- 位置:`tests/集成/test_抽取版本与补偿.py::test_失败窗口断点续跑只补缺失窗__18b04d`。 +- 给定:确定性输入或隔离数据库。 +- 动作:失败窗口断点续跑只补缺失窗。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + + +## NC-w18-18b05e · test_来源前进旧版本结论保留且新拆书指向新版本__18b05e + +- 位置:`tests/集成/test_抽取版本与补偿.py::test_来源前进旧版本结论保留且新拆书指向新版本__18b05e`。 +- 给定:确定性输入或隔离数据库。 +- 动作:来源前进旧版本结论保留且新拆书指向新版本。 +- 预期:行为可复验;失败与歧义如实呈现。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B03-资料研究.md)。 + diff --git a/src/muse/启动.py b/src/muse/启动.py index dfb744a..07a8630 100644 --- a/src/muse/启动.py +++ b/src/muse/启动.py @@ -232,6 +232,23 @@ def 装配章后处理( 流程服务(装配.任务运行, 登记).发布(定义) +def 装配研究拆书( + 装配: 应用装配, 计价, 模板路径: Path | None = None, *, 分析配置ID: str | None = None +) -> None: + """登记并发布研究拆书流程;分析配置由部署装配声明,作者配置仍走验证启用链。""" + + from muse.编排.接口 import 流程服务 + from muse.编排.研究拆书 import 登记研究拆书 + from muse.编排.载入模板 import 载入流程定义 + + 登记 = 装配.流程登记 + if 登记 is None or 装配.任务运行 is None: + raise 装配未完成("任务运行与流程登记未注入") + 登记研究拆书(登记, 装配, 计价, 分析配置ID=分析配置ID) + 定义 = 载入流程定义(模板路径 or Path("配置/流程模板/研究拆书.yaml")) + 流程服务(装配.任务运行, 登记).发布(定义) + + def 装配生成正文(装配: 应用装配, 计价, 模板路径: Path | None = None) -> None: """登记并发布生成正文流程;计价由调用方注入,模板默认取仓内可审配置。""" diff --git a/src/muse/接入/cli/入口.py b/src/muse/接入/cli/入口.py index 8a41217..009b29c 100644 --- a/src/muse/接入/cli/入口.py +++ b/src/muse/接入/cli/入口.py @@ -45,6 +45,12 @@ def _解析参数(argv: list[str] | None) -> argparse.Namespace: 创作解析.add_argument("动作", choices=["发起", "报告"]) 创作解析.add_argument("目标", nargs="?", help="发起时为请求 JSON 文件;报告时为候选 ID") 创作解析.add_argument("--版本", type=int, default=None, help="报告的目标候选版本") + 研究解析 = 子命令.add_parser("研究", help="发起资料拆解、查看进度与检查拆书结果") + 研究解析.add_argument("配置", help="作者配置 TOML 路径") + 研究解析.add_argument("动作", choices=["拆解", "进度", "检查"]) + 研究解析.add_argument( + "目标", nargs="?", help="拆解为请求 JSON 文件;进度为来源 ID;检查为任务 ID" + ) 结构解析 = 子命令.add_parser("结构", help="发布内置种子或查看已发布的固定结构") 结构解析.add_argument("配置", help="应用配置 TOML 路径") 结构解析.add_argument("动作", choices=["发布内置", "查看", "维护"]) @@ -207,6 +213,12 @@ def main(argv: list[str] | None = None) -> int: 结果 = 运行创作命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 版本=参数.版本) print(json.dumps(结果, ensure_ascii=False, default=str)) return _退出码_成功 + if 参数.命令 == "研究": + from muse.接入.cli.研究命令 import 运行研究命令 + + 结果 = 运行研究命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标) + print(json.dumps(结果, ensure_ascii=False, default=str)) + return _退出码_成功 if 参数.命令 == "自检": 装配 = 构建(读取配置(参数.配置)) for 键, 值 in 自检(装配).items(): diff --git a/src/muse/接入/cli/研究命令.py b/src/muse/接入/cli/研究命令.py new file mode 100644 index 0000000..ad75388 --- /dev/null +++ b/src/muse/接入/cli/研究命令.py @@ -0,0 +1,98 @@ +"""研究命令:拆解发起、进度查询与结果检查维护的命令入口。""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationError + +from muse.共享.错误 import 配置错误 +from muse.启动 import 应用装配 +from muse.接入.创作操作 import 创作身份 + + +class 拆解请求(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + command_id: str + source_id: str + config_id: str | None = None + reason: str | None = None + retry_windows: list[str] | None = None + window_chapters: int | None = None + + @property + def 命令ID(self) -> str: + return self.command_id + + @property + def 来源ID(self) -> str: + return self.source_id + + @property + def 配置ID(self) -> str | None: + return self.config_id + + @property + def 理由(self) -> str | None: + return self.reason + + @property + def 重跑窗(self) -> list[str] | None: + return self.retry_windows + + @property + def 窗口章数(self) -> int | None: + return self.window_chapters + + +def 运行研究命令(装配: 应用装配, 动作: str, 目标: str | None) -> dict | list: + if 装配.配置.HTTP is None: + raise 配置错误("研究命令需要本地作者身份配置") + 作者 = 创作身份(装配.配置.HTTP.作者ID, 装配.配置) + if 动作 == "拆解": + if not 目标: + raise 配置错误("拆解命令需要请求 JSON 文件") + try: + 请求 = 拆解请求.model_validate_json(Path(目标).read_text(encoding="utf-8")) + except ValidationError as exc: + from muse.接入.主会话 import 参数合同错误 + + raise 参数合同错误(exc.errors(), 前缀=("body",)) from None + from muse.编排.研究拆书 import 发起研究拆书 + + 样本 = {"理由": 请求.理由} if 请求.理由 else None + return 发起研究拆书( + 装配, + 作者, + 请求.命令ID, + source_id=请求.来源ID, + 样本依据=样本, + 配置ID=请求.配置ID, + 重跑窗=请求.重跑窗, + 窗口章数=请求.窗口章数, + ) + if 动作 == "进度": + if not 目标: + raise 配置错误("进度命令需要来源身份") + from muse.编排.研究拆书 import 查询拆书状态 + + 状态 = 查询拆书状态(装配, 作者.作者 or "", 目标) + return 状态 or {"台账": [], "对照": None, "说明": "该来源尚无拆书任务"} + if 动作 == "检查": + if not 目标: + raise 配置错误("检查命令需要任务身份") + from muse.资料研究.分析版本 import 读取版本状态 + + if 装配.任务运行 is None: + raise 配置错误("任务运行未注入") + 快照 = 装配.任务运行.读取任务(目标) + if 快照.流程.流程ID != "研究拆书": + raise 配置错误(f"任务 {目标} 不是研究拆书任务") + 状态 = 读取版本状态(快照) + 状态["说明"] = "歧义条目待核对实体歧义任务澄清;失败窗可经重跑窗恢复。" + return 状态 + raise 配置错误(f"未知研究命令动作:{动作}") + + +__all__ = ["运行研究命令", "拆解请求"] diff --git a/src/muse/接入/http/路由/资料研究.py b/src/muse/接入/http/路由/资料研究.py index 62a402c..eac7ea2 100644 --- a/src/muse/接入/http/路由/资料研究.py +++ b/src/muse/接入/http/路由/资料研究.py @@ -1,4 +1,4 @@ -"""资料研究的 HTTP 外壳:导入、来源版本对照、清理候选与榜单快照。""" +"""资料研究的 HTTP 外壳:导入、来源版本对照、清理候选、榜单快照与研究拆书。""" from typing import Any, Literal @@ -38,6 +38,15 @@ class 采集榜单请求(BaseModel): ranking_id: str = Field(min_length=1) +class 发起拆书请求(BaseModel): + model_config = ConfigDict(extra="forbid") + command_id: str = Field(min_length=1) + config_id: str | None = None + reason: str | None = None + retry_windows: list[str] | None = None + window_chapters: int | None = None + + def _装配(request: Request): return request.app.state.装配 @@ -157,3 +166,49 @@ def 列出快照(ranking_id: str, request: Request, 作者: 作者依赖) -> lis 装配 = _装配(request) with 装配.要求数据库().连接(只读=True) as 连: return 装配.要求资料().列出快照(连, ranking_id) + + +# ---- 研究拆书 ---- + + +@路由.post("/sources/{source_id}/analyses", operation_id="start_source_analysis", status_code=201) +def 发起拆书(source_id: str, 请求: 发起拆书请求, request: Request, 作者: 作者依赖) -> dict: + 装配 = _装配(request) + from muse.编排.研究拆书 import 发起研究拆书 + + 样本 = {"理由": 请求.reason} if 请求.reason else None + 回执 = 发起研究拆书( + 装配, + 创作身份(作者, 装配.配置), + 请求.command_id, + source_id=source_id, + 样本依据=样本, + 配置ID=请求.config_id, + 重跑窗=请求.retry_windows, + 窗口章数=请求.window_chapters, + ) + return 回执 + + +@路由.get("/sources/{source_id}/analyses", operation_id="list_source_analyses") +def 列出拆书(source_id: str, request: Request, 作者: 作者依赖) -> dict[str, Any]: + 装配 = _装配(request) + from muse.编排.研究拆书 import 查询拆书状态 + + 状态 = 查询拆书状态(装配, 创作身份(作者, 装配.配置).作者 or "", source_id) + return 状态 or {"台账": [], "对照": None, "说明": "该来源尚无拆书任务"} + + +@路由.post("/analyses/{task_id}/resume", operation_id="resume_source_analysis") +def 恢复拆书(task_id: str, request: Request, 作者: 作者依赖) -> dict: + 装配 = _装配(request) + from muse.任务运行.模型 import 任务状态 + + 快照 = 装配.任务运行.控制任务( + task_id, + 创作身份(作者, 装配.配置).作者, + 任务状态.已失败, + 动作="恢复", + 命令ID="resume-" + task_id, + ) + return {"task_id": 快照.任务ID, "状态": 快照.状态.value} diff --git a/src/muse/编排/研究拆书.py b/src/muse/编排/研究拆书.py new file mode 100644 index 0000000..c6abe80 --- /dev/null +++ b/src/muse/编排/研究拆书.py @@ -0,0 +1,359 @@ +"""研究拆书旅程:切窗核对、分析配置绑定、逐窗模型提取、实体归并与分析版本登记。 + +拆书对象是参考作品来源:任务以来源排他范围互斥(同源不可并发拆书); +窗口结果绑定窗哈希(输入漂移即拒绝),失败窗口可单独重试且不覆盖已完成窗; +归并结果每次按检查点重建,只保留本任务可证明归属的成果,不触碰外部确认。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from muse.任务运行.接口 import ( + 任务快照, + 执行上下文, + 步骤处理器, + 步骤结果, + 步骤计划, +) +from muse.任务运行.模型 import 任务请求, 作用域 +from muse.任务运行.预算管理 import ( + 任务预算计划, + 角色预算, + 预算状态冲突, + 预算管理, +) +from muse.共享.调用身份 import 内容用途 +from muse.共享.错误 import Muse错误, 配置错误 +from muse.编排.接口 import 流程登记 +from muse.编排.流程版本 import 流程定义 +from muse.编排.章后处理 import _工厂, 前步检查点 +from muse.资料研究.分章切窗 import 切窗, 漂移检查, 窗口, 窗口载荷, 窗计划哈希, 覆盖核对 +from muse.资料研究.存储 import 资料存储 +from muse.资料研究.实体归并 import 归并, 收集实体 +from muse.资料研究.样本选题 import 选书交接单 + +流程身份 = "研究拆书" +流程版本 = "1" + +处理器版本集 = { + "rs.plan": "1", + "rs.config": "1", + "rs.extract": "1", + "rs.merge": "1", + "rs.version": "1", +} + + +class 拆书错误(Muse错误): + 错误码 = "ANALYSIS_FLOW_INVALID" + + +def 拆书输入(任务: 任务快照) -> dict[str, Any]: + 输入 = (任务.冻结输入.get("输入") or {}).get("拆书") or {} + for 名 in ("source_id", "revision", "content_hash"): + if 名 not in 输入: + raise 拆书错误(f"拆书冻结输入缺少 {名}") + return 输入 + + +def 窗计划(任务: 任务快照) -> list[dict[str, Any]]: + 计划 = (任务.冻结输入.get("输入") or {}).get("窗计划") or [] + if not 计划: + raise 拆书错误("拆书冻结输入缺少窗计划") + return 计划 + + +def _来源复检(装配, 输入: dict[str, Any]) -> str: + """来源版本与内容哈希一致才放行;不一致即旧分析不能适用。""" + + with _工厂(装配).连接(只读=True) as 连: + 行 = 资料存储(连).读版本(输入["source_id"], int(输入["revision"])) + if 行 is None: + raise 拆书错误(f"来源版本不存在:{输入['source_id']}@r{输入['revision']}") + if 行["content_hash"] != 输入["content_hash"]: + raise 拆书错误("来源内容与冻结哈希不一致,旧分析结果不能适用;按新版本重建") + return str(行["content"]) + + +def 登记研究拆书(登记: 流程登记, 装配, 计价, *, 分析配置ID: str | None = None) -> None: + """登记研究拆书流程处理器、类型与恢复检查;分析配置由部署装配声明。""" + + 登记.登记类型(流程身份, 必需保护=()) + + def 恢复复检(任务: 任务快照) -> None: + _来源复检(装配, 拆书输入(任务)) + + 登记.登记恢复检查(流程身份, 流程版本, 恢复复检) + + def 切窗核对(上下文: 执行上下文) -> 步骤结果: + 输入 = 拆书输入(上下文.任务) + 文本 = _来源复检(装配, 输入) + 计划 = 窗计划(上下文.任务) + # 每个冻结窗与当前原文逐一核对;漂移窗口名单留待逐窗提取阶段拒绝。 + 漂移窗: list[str] = [] + for 窗 in 计划: + try: + 漂移检查(文本, 窗口(**窗)) + except Muse错误: + 漂移窗.append(窗["窗ID"]) + 统计 = 覆盖核对(文本, [窗口(**窗) for 窗 in 计划]) + return 步骤结果( + 输出={"窗口数": len(计划), "漂移窗": 漂移窗, **统计}, + 检查点={"窗核对": {"漂移窗": 漂移窗, **统计}}, + ) + + def 绑定分析配置(上下文: 执行上下文) -> 步骤结果: + 任务 = 上下文.任务 + 配置ID = (任务.冻结输入.get("输入") or {}).get("配置ID") or 分析配置ID + if not 配置ID: + raise 配置错误("拆书分析未绑定运行配置;发起时经入口明确指定") + from muse.任务运行.配置版本 import 配置版本管理 + + 工厂 = _工厂(装配) + 快照 = 配置版本管理(工厂).冻结到任务(任务.任务ID, 配置ID) + 窗口数 = len(窗计划(任务)) + 预算 = 预算管理(工厂, 快照.内容.预算策略引用) + try: + # 包络随窗数走:计划=每窗一次加重试余量两次;安全容量=两倍窗数加余量。 + 预算.登记任务预算( + 任务.任务ID, + 任务预算计划( + Decimal("2") * (窗口数 + 2), + (角色预算("extractor", 窗口数 + 2, 2 * 窗口数 + 2, Decimal("2")),), + "research-analysis-envelope-1", + datetime.now(UTC) + timedelta(hours=12), + ), + ) + except 预算状态冲突: + pass # 恢复重试时包络已登记且哈希一致;不一致会被上一步骤拒绝。 + return 步骤结果( + 输出={"配置ID": 配置ID}, + 检查点={"配置ID": 配置ID, "预算账户": 快照.内容.预算策略引用}, + ) + + def 逐窗提取(上下文: 执行上下文) -> 步骤结果: + from muse.资料研究.拆书分析 import 执行窗口提取 + + 任务 = 上下文.任务 + 输入 = 拆书输入(任务) + 文本 = _来源复检(装配, 输入) + 重跑 = set((任务.冻结输入.get("输入") or {}).get("重跑窗") or []) + # 断点续跑:已完成窗保留;指定重跑窗或漂移窗按归属重建,不保留错归属结果。 + 保留: dict[str, Any] = {} + for 项 in 任务.步骤: + 检查点 = 项.get("checkpoint") or {} + if 项["step_id"] == "逐窗提取" and 检查点.get("窗口结果"): + for 窗ID, 结果 in 检查点["窗口结果"].items(): + if 窗ID in 重跑 or 窗ID in (检查点.get("漂移重建") or []): + continue + 保留[窗ID] = 结果 + 计划 = 窗计划(任务) + 结果: dict[str, Any] = dict(保留) + 漂移重建: list[str] = [] + for 窗 in 计划: + if 窗["窗ID"] in 结果: + continue + try: + 漂移检查(文本, 窗口(**窗)) + except Muse错误: + # 输入漂移:不调用模型、不保留旧归属,登记后由恢复按新版本重建。 + 漂移重建.append(窗["窗ID"]) + continue + 结果[窗["窗ID"]] = 执行窗口提取(装配, 上下文, 计价, 窗, 文本) + 装配.任务运行.保存检查点( + 上下文.领取, + { + "窗口结果": 结果, + "漂移重建": 漂移重建, + "已提取窗数": len(结果), + }, + ) + if 漂移重建: + raise 拆书错误(f"窗口输入漂移,拒绝写入旧分析结果:{'、'.join(漂移重建)}") + return 步骤结果( + 输出={"已提取窗数": len(结果), "窗口总数": len(计划)}, + 检查点={"窗口结果": 结果, "漂移重建": 漂移重建, "已提取窗数": len(结果)}, + ) + + def 实体归并步(上下文: 执行上下文) -> 步骤结果: + 窗口结果 = 前步检查点(上下文, "逐窗提取")["窗口结果"] + 输入序列 = [{**结果, "window_id": 窗ID} for 窗ID, 结果 in sorted(窗口结果.items())] + 归并结果 = 归并(收集实体(输入序列)) + return 步骤结果( + 输出={"合并组数": len(归并结果["合并组"]), "歧义数": len(归并结果["歧义"])}, + 检查点={"归并": 归并结果}, + ) + + def 登记分析版本(上下文: 执行上下文) -> 步骤结果: + from muse.资料研究.分析版本 import 版本身份 + + 任务 = 上下文.任务 + 输入 = 拆书输入(任务) + 归并点 = 前步检查点(上下文, "实体归并")["归并"] + 窗口结果 = 前步检查点(上下文, "逐窗提取")["窗口结果"] + 窗口列表 = [窗口(**窗) for 窗 in 窗计划(任务)] + 样本 = (任务.冻结输入.get("输入") or {}).get("样本依据") + 版本 = { + "版本ID": 版本身份(输入["source_id"], int(输入["revision"]), 窗口列表), + "source_id": 输入["source_id"], + "revision": int(输入["revision"]), + "content_hash": 输入["content_hash"], + "窗计划哈希": 窗计划哈希(窗口列表), + "完成窗": sorted(窗口结果), + "实体组数": len(归并点["合并组"]), + "歧义数": len(归并点["歧义"]), + "样本依据": 样本, + } + return 步骤结果( + 输出={"版本ID": 版本["版本ID"], "歧义数": 版本["歧义数"]}, + 检查点={"版本": 版本}, + ) + + 登记.登记处理器(步骤处理器("rs.plan", "1", 切窗核对, "post-source", "post-source")) + 登记.登记处理器(步骤处理器("rs.config", "1", 绑定分析配置, "post-source", "post-config")) + 登记.登记处理器( + 步骤处理器("rs.extract", "1", 逐窗提取, "post-config", "post-extraction", 角色="extractor") + ) + 登记.登记处理器(步骤处理器("rs.merge", "1", 实体归并步, "post-extraction", "post-proposals")) + 登记.登记处理器(步骤处理器("rs.version", "1", 登记分析版本, "post-proposals", "post-summary")) + + +def 研究拆书步骤() -> tuple[步骤计划, ...]: + return ( + 步骤计划("切窗核对", "rs.plan", "1"), + 步骤计划("绑定分析配置", "rs.config", "1", 依赖=("切窗核对",)), + 步骤计划("逐窗提取", "rs.extract", "1", 依赖=("绑定分析配置",), 角色="extractor"), + 步骤计划("实体归并", "rs.merge", "1", 依赖=("逐窗提取",)), + 步骤计划("分析版本登记", "rs.version", "1", 依赖=("实体归并",)), + ) + + +def 流程定义_研究拆书() -> 流程定义: + return 流程定义(流程身份, 流程版本, 研究拆书步骤()) + + +def 发起研究拆书( + 装配, + 身份, + 命令ID: str, + *, + source_id: str, + 样本依据: dict[str, Any] | None = None, + 配置ID: str | None = None, + 重跑窗: list[str] | None = None, + 窗口章数: int | None = None, +) -> dict[str, str]: + """按来源当前版本登记拆书任务;同源互斥由排他范围强制,同窗计划得到同版本身份。""" + + from muse.任务运行.角色策略 import 角色策略目录 + from muse.资料研究.来源导入 import 核对来源授权 + + with _工厂(装配).连接(只读=True) as 连: + 行 = 资料存储(连).读版本(source_id) + if 行 is None: + raise 拆书错误(f"来源不存在:{source_id}") + 核对来源授权(连, source_id, "analysis") + 文本 = str(行["content"]) + 参数 = {} if 窗口章数 is None else {"窗口章数": 窗口章数} + 窗口列表, 计划哈希 = 切窗(文本, **参数) + 交接单 = 选书交接单( + { + "source_id": source_id, + "revision": int(行["revision"]), + "content_hash": 行["content_hash"], + }, + (样本依据 or {}).get("理由") or "按来源当前版本直接拆解", + ) + 冻结输入 = { + "拆书": { + "source_id": source_id, + "revision": int(行["revision"]), + "content_hash": 行["content_hash"], + }, + "窗计划": 窗口载荷(窗口列表), + "样本依据": {**交接单, "依据哈希": (样本依据 or {}).get("依据哈希")}, + "配置ID": 配置ID, + "重跑窗": sorted(重跑窗 or []), + } + 策略 = 角色策略目录.从发布包() + 请求 = 任务请求( + 任务类型=流程身份, + 命令ID=命令ID, + 作者=身份.作者, + 执行用途=装配.配置.运行用途, + 内容用途=内容用途.抽取, + 输入=冻结输入, + 角色策略版本=策略.定义["version"], + 资源发布身份=策略.资源发布身份, + 冻结上下文={ + "source_scope": 冻结输入, + "schema_versions": {}, + "authorization": "research-analysis", + "budget": {}, + "stop_conditions": {}, + }, + 来源ID=source_id, + 排他范围=作用域(对象类型="source", 对象ID=source_id, 操作族="analysis"), + ) + 任务ID = 装配.任务运行.创建任务(请求, 流程身份, 流程版本) + if 配置ID: + from muse.任务运行.配置版本 import 配置版本管理 + + 配置版本管理(_工厂(装配)).冻结到任务(任务ID, 配置ID) + return { + "task_id": 任务ID, + "版本ID": _预计算版本ID(source_id, int(行["revision"]), 窗口列表), + "窗计划哈希": 计划哈希, + } + + +def _预计算版本ID(source_id: str, revision: int, 窗口列表) -> str: + from muse.资料研究.分析版本 import 版本身份 + + return 版本身份(source_id, revision, 窗口列表) + + +def 查询拆书状态(装配, 作者: str, source_id: str) -> dict[str, Any] | None: + """返回该来源的拆书台账与最近一次任务的对照数据;无拆书时返回 None。""" + + from muse.资料研究.分析版本 import 版本台账, 读取版本状态 + + 服务 = 装配.任务运行 + 台账 = 版本台账(服务.列出任务(作者)) + 本源 = [项 for 项 in 台账 if 项["source_id"] == source_id] + if not 本源: + return None + 最新 = None + for 快照 in 服务.列出任务(作者): + if 快照.流程.流程ID == 流程身份 and 拆书输入(快照)["source_id"] == source_id: + 最新 = 快照 + break + 对照 = None + if 最新 is not None: + 状态 = 读取版本状态(最新) + 对照 = { + "task_id": 最新.任务ID, + "状态": 最新.状态.value, + "版本ID": (状态["版本"] or {}).get("版本ID"), + "完成窗": 状态["完成窗"], + "失败窗": 状态["失败窗"], + "歧义": 状态["歧义"], + "样本依据": (最新.冻结输入.get("输入") or {}).get("样本依据"), + } + return {"台账": 本源, "对照": 对照} + + +__all__ = [ + "登记研究拆书", + "发起研究拆书", + "查询拆书状态", + "流程定义_研究拆书", + "研究拆书步骤", + "拆书错误", + "流程身份", + "流程版本", + "处理器版本集", +] diff --git a/src/muse/资料研究/分析版本.py b/src/muse/资料研究/分析版本.py new file mode 100644 index 0000000..4851770 --- /dev/null +++ b/src/muse/资料研究/分析版本.py @@ -0,0 +1,101 @@ +"""分析版本:分窗成果的版本台账与恢复状态;失败窗口可单独重试,选用版本可回查。 + +版本本体落在 S02 任务检查点(W18 无新迁移);版本身份由来源版本与窗计划哈希 +确定性推导,同一来源版本的重复分析得到同一版本身份,可回查、可对照。 +""" + +from __future__ import annotations + +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +from muse.资料研究.分章切窗 import 窗口, 窗计划哈希 +from muse.资料研究.模型 import 资料错误 + + +def 版本身份(source_id: str, revision: int, 窗口列表: list[窗口]) -> str: + """来源版本 + 窗计划哈希 → 确定性分析版本身份。""" + + if not 窗口列表: + raise 资料错误("窗口列表为空,不能立分析版本") + return str(uuid5(NAMESPACE_URL, f"analysis:{source_id}:{int(revision)}:{窗计划哈希(窗口列表)}")) + + +def 读取版本状态(任务快照) -> dict[str, Any]: + """从拆书任务的步骤检查点汇总版本状态:完成窗、失败窗、歧义与版本身份。""" + + 状态: dict[str, Any] = { + "task_id": 任务快照.任务ID, + "状态": 任务快照.状态.value, + "完成窗": {}, + "失败窗": [], + "歧义": [], + "版本": None, + } + 输入 = (任务快照.冻结输入.get("输入") or {}).get("拆书") or {} + 状态["source_id"] = 输入.get("source_id") + 状态["revision"] = 输入.get("revision") + 窗计划 = (任务快照.冻结输入.get("输入") or {}).get("窗计划") or [] + 状态["窗计划哈希"] = 窗计划哈希([窗口(**窗) for 窗 in 窗计划]) if 窗计划 else None + for 项 in 任务快照.步骤: + 检查点 = 项.get("checkpoint") or {} + if 项["step_id"] == "逐窗提取" and 检查点.get("窗口结果"): + for 窗ID, 结果 in 检查点["窗口结果"].items(): + 状态["完成窗"][窗ID] = { + "实体数": len(结果.get("entities") or []), + "写法数": len(结果.get("techniques") or []), + } + 状态["失败窗"] = sorted(检查点.get("失败窗") or []) + if 项["step_id"] == "实体归并" and 检查点.get("归并"): + 状态["歧义"] = 检查点["归并"].get("歧义") or [] + if 项["step_id"] == "分析版本登记" and 检查点.get("版本"): + 状态["版本"] = 检查点["版本"] + return 状态 + + +def 待重试窗口(任务快照) -> list[str]: + """恢复时需要重跑的窗口:无结果或与冻结窗计划不一致的窗口。""" + + 输入 = (任务快照.冻结输入.get("输入") or {}).get("拆书") or {} + 全部 = [窗["窗ID"] for 窗 in (任务快照.冻结输入.get("输入") or {}).get("窗计划") or []] + 完成: set[str] = set() + for 项 in 任务快照.步骤: + 检查点 = 项.get("checkpoint") or {} + if 项["step_id"] == "逐窗提取": + 完成 |= set(检查点.get("窗口结果") or {}) + if 输入.get("重跑窗"): + return [窗ID for 窗ID in 全部 if 窗ID in set(输入["重跑窗"])] + return [窗ID for 窗ID in 全部 if 窗ID not in 完成] + + +def 版本台账(任务快照列表: list) -> list[dict[str, Any]]: + """列出同一来源版本的历史分析任务及其版本身份与状态。""" + + 台账: list[dict[str, Any]] = [] + for 快照 in 任务快照列表: + if 快照.流程.流程ID != "研究拆书": + continue + 状态 = 读取版本状态(快照) + 台账.append( + { + "task_id": 状态["task_id"], + "状态": 状态["状态"], + "source_id": 状态["source_id"], + "revision": 状态["revision"], + "窗计划哈希": 状态["窗计划哈希"], + "版本ID": (状态["版本"] or {}).get("版本ID"), + "完成窗": len(状态["完成窗"]), + "失败窗": 状态["失败窗"], + "歧义数": len(状态["歧义"]), + } + ) + return 台账 + + +def 基准引用(source_id: str, revision: int, 版本ID: str) -> dict[str, Any]: + """下游任务消费某分析版本时的基准引用;选用记录随任务冻结输入可回查。""" + + return {"基准版本": {"source_id": source_id, "revision": int(revision), "版本ID": 版本ID}} + + +__all__ = ["版本身份", "读取版本状态", "待重试窗口", "版本台账", "基准引用", "资料错误"] diff --git a/src/muse/资料研究/分章切窗.py b/src/muse/资料研究/分章切窗.py new file mode 100644 index 0000000..9abb4f1 --- /dev/null +++ b/src/muse/资料研究/分章切窗.py @@ -0,0 +1,117 @@ +"""分章切窗:长文分析的分批与恢复边界;窗口覆盖可检查,输入漂移不能写入旧分析版本。""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import asdict, dataclass + +from muse.资料研究.模型 import 资料错误 + +# 章界识别用确定性分隔模式;命中失败时整篇按单章处理,不用模型补猜。 +_章界模式 = re.compile( + r"^[ \t]*(?:第[0-9零一二三四五六七八九十百千两]+[章回节卷][^\n]{0,30})[ \t]*$", re.M +) + +默认窗口章数 = 3 + + +@dataclass(frozen=True, slots=True) +class 窗口: + """一个分析窗口:码点区间绑定内容哈希;窗号只是切分序号,不是真实章号。""" + + 窗ID: str + 起章: int + 止章: int + 起点: int + 终点: int + 内容哈希: str + + +def 分章(文本: str) -> list[dict[str, int]]: + """按章界模式切分文本;返回章序号与码点区间,无章界时整篇为一章。""" + + 命中 = [匹配.start() for 匹配 in _章界模式.finditer(文本)] + 界 = [起点 for 起点 in 命中 if 起点 > 0] + if not 界: + return [{"章": 1, "起点": 0, "终点": len(文本)}] + 区间: list[dict[str, int]] = [] + 边 = [0] + 界 + [len(文本)] + for 序, (起, 止) in enumerate(zip(边, 边[1:], strict=False), 1): + if 起 < 止: + 区间.append({"章": 序, "起点": 起, "终点": 止}) + return 区间 + + +def 切窗(文本: str, *, 窗口章数: int = 默认窗口章数) -> tuple[list[窗口], str]: + """把分章文本切成连续窗口;返回窗口列表与窗计划哈希。""" + + if 窗口章数 < 1: + raise 资料错误("窗口章数必须大于零") + 章 = 分章(文本) + 窗口列表: list[窗口] = [] + for 序 in range(0, len(章), 窗口章数): + 组 = 章[序 : 序 + 窗口章数] + 起点, 终点 = 组[0]["起点"], 组[-1]["终点"] + 片段 = 文本[起点:终点] + 窗口列表.append( + 窗口( + 窗ID=f"w{len(窗口列表) + 1:03d}", + 起章=组[0]["章"], + 止章=组[-1]["章"], + 起点=起点, + 终点=终点, + 内容哈希=hashlib.sha256(片段.encode("utf-8")).hexdigest(), + ) + ) + return 窗口列表, 窗计划哈希(窗口列表) + + +def 窗计划哈希(窗口列表: list[窗口]) -> str: + return hashlib.sha256( + "\n".join( + f"{窗口.窗ID}:{窗口.起点}:{窗口.终点}:{窗口.内容哈希}" for 窗口 in 窗口列表 + ).encode() + ).hexdigest() + + +def 漂移检查(文本: str, 窗: 窗口) -> None: + """窗口当前内容与冻结哈希一致才放行;不一致即输入已漂移,拒绝写入旧分析版本。""" + + if not (0 <= 窗.起点 < 窗.终点 <= len(文本)): + raise 资料错误(f"窗口 {窗.窗ID} 区间越界,输入已漂移") + 当前 = hashlib.sha256(文本[窗.起点 : 窗.终点].encode("utf-8")).hexdigest() + if 当前 != 窗.内容哈希: + raise 资料错误(f"窗口 {窗.窗ID} 内容与冻结哈希不一致,输入已漂移") + + +def 覆盖核对(文本: str, 窗口列表: list[窗口]) -> dict[str, int]: + """窗口应无重叠且完整覆盖正文;返回核对统计供检查与维护使用。""" + + 排序 = sorted(窗口列表, key=lambda 窗: 窗.起点) + 覆盖 = 0 + 游标 = 0 + 重叠 = 0 + for 窗 in 排序: + if 窗.起点 < 游标: + 重叠 += 游标 - 窗.起点 + 覆盖 += max(0, 窗.终点 - max(窗.起点, 游标)) + 游标 = max(游标, 窗.终点) + return {"窗口数": len(排序), "覆盖码点": 覆盖, "重叠码点": 重叠, "正文码点": len(文本)} + + +def 窗口载荷(窗口列表: list[窗口]) -> list[dict]: + return [asdict(窗) for 窗 in 窗口列表] + + +__all__ = [ + "窗口", + "分章", + "切窗", + "窗计划哈希", + "漂移检查", + "覆盖核对", + "窗口载荷", + "默认窗口章数", + "资料错误", +] diff --git a/src/muse/资料研究/实体归并.py b/src/muse/资料研究/实体归并.py new file mode 100644 index 0000000..e7bb57f --- /dev/null +++ b/src/muse/资料研究/实体归并.py @@ -0,0 +1,100 @@ +"""实体归并:参考作品内部专名、别名与歧义的确定性归并;可疑同名不覆盖已有实体。""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from muse.资料研究.模型 import 资料错误 + + +def 收集实体(窗口结果: list[dict[str, Any]]) -> list[dict[str, Any]]: + """把逐窗分析的实体展开为带窗口归属与证据的候选记录。""" + + 候选: list[dict[str, Any]] = [] + for 结果 in 窗口结果: + for 项 in 结果.get("entities") or []: + 名 = (项.get("name") or "").strip() + if not 名: + raise 资料错误("实体缺少名称") + 候选.append( + { + "name": 名, + "type": (项.get("type") or "unknown").strip() or "unknown", + "aliases": [别名 for 别名 in (项.get("aliases") or []) if 别名], + "evidence": list(项.get("evidence") or ()), + "window_id": 结果.get("window_id"), + } + ) + return 候选 + + +def 归并(候选: list[dict[str, Any]]) -> dict[str, Any]: + """同类型且名称(规范形)一致的候选合并为一组并保留证据;同名异型一律歧义。 + + 规则完全确定性:候选按 (规范化名, 类型) 分组,别名只扩入组的别名集,不另立组; + 同一规范名出现在多个类型下时整体登记歧义,不猜测、不投票、不越作品合并。 + """ + + def 规范(名: str) -> str: + return 名.strip().lower() + + 组: dict[tuple[str, str], list[dict[str, Any]]] = {} + for 项 in 候选: + 组.setdefault((规范(项["name"]), 项["type"]), []).append(项) + + 按名: dict[str, set[str]] = {} + for 键名, 类型 in 组: + 按名.setdefault(键名, set()).add(类型) + + 合并组: list[dict[str, Any]] = [] + 歧义: list[dict[str, Any]] = [] + for (键名, 类型), 成员 in sorted(组.items()): + if len(按名[键名]) > 1: + 出现窗 = sorted( + { + 项["window_id"] + for 类型2 in 按名[键名] + for 项 in 组.get((键名, 类型2), []) + if 项["window_id"] + } + ) + 歧义.append( + { + "名": 键名, + "类型冲突": sorted(按名[键名]), + "出现窗": 出现窗, + "处理": "歧义保留;待核对实体歧义任务澄清", + } + ) + continue + 合并组.append( + { + "canonical": 成员[0]["name"], + "type": 类型, + "aliases": sorted( + {项["name"] for 项 in 成员} + | {别名 for 项 in 成员 for 别名 in 项["aliases"]} - {成员[0]["name"]} + ), + "windows": sorted({项["window_id"] for 项 in 成员 if 项["window_id"]}), + "evidence": [证据 for 项 in 成员 for 证据 in 项["evidence"]], + } + ) + return { + "合并组": sorted(合并组, key=lambda g: g["canonical"]), + "歧义": sorted(歧义, key=lambda g: g["名"]), + } + + +def 归并哈希(归并结果: dict[str, Any]) -> str: + return hashlib.sha256( + hashlib.sha256( + str(sorted((g["canonical"], g["type"]) for g in 归并结果["合并组"])).encode() + + str(sorted(g["名"] for g in 归并结果["歧义"])).encode() + ) + .hexdigest()[:16] + .encode() + ).hexdigest() + + +__all__ = ["收集实体", "归并", "归并哈希", "资料错误"] diff --git a/src/muse/资料研究/拆书分析.py b/src/muse/资料研究/拆书分析.py new file mode 100644 index 0000000..4a31e29 --- /dev/null +++ b/src/muse/资料研究/拆书分析.py @@ -0,0 +1,124 @@ +"""拆书分析:把参考作品逐窗拆为章节、阶段、实体与写法的版本化成果。 + +模型调用走 S02(analyst 角色、冻结配置、预算包络);字段结构是固定输出合同, +窗口结果绑定窗哈希,输入漂移时拒绝写入。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import uuid4 + +from muse.任务运行.接口 import 执行上下文, 模型请求 +from muse.任务运行.模型调用 import 请求字节 +from muse.任务运行.角色会话 import 组装角色请求 +from muse.任务运行.角色策略 import 角色策略目录 +from muse.共享.调用身份 import 内容用途 +from muse.资料研究.分章切窗 import 漂移检查, 窗口 +from muse.资料研究.模型 import 资料错误 + +# 逐窗分析的输出合同:章节、阶段、实体(带证据)与写法;结构走 S04 固定合同。 +窗口输出合同 = { + "type": "object", + "properties": { + "chapters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "minLength": 1}, + "beat": {"type": "string", "minLength": 1}, + }, + "required": ["title", "beat"], + "additionalProperties": False, + }, + }, + "phases": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "aliases": {"type": "array", "items": {"type": "string"}}, + "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}}, + }, + "required": ["name", "type"], + "additionalProperties": False, + }, + }, + "techniques": {"type": "array", "items": {"type": "string", "minLength": 1}}, + }, + "required": ["chapters", "phases", "entities", "techniques"], + "additionalProperties": False, +} + +系统提示 = ( + "你是参考作品分析员,对给定窗口做拆书分析:" + "列出章节与情节节拍、叙事阶段、出现的人物/地点/事物实体(附原文引文证据)与可借鉴的写法。" + "只依据窗口原文,不补猜窗口外内容;输出一个 JSON 对象。" +) + + +def 执行窗口提取(装配, 上下文: 执行上下文, 计价, 窗: dict[str, Any], 文本: str) -> dict[str, Any]: + """对单窗执行模型提取;输入漂移即失败,模型调用按 S02 证据合同授权留痕。""" + + 窗对象 = 窗口(**窗) + 漂移检查(文本, 窗对象) + 片段 = 文本[窗对象.起点 : 窗对象.终点] + 拆书输入 = (上下文.任务.冻结输入.get("输入") or {}).get("拆书") or {} + from muse.任务运行.配置版本 import 配置版本管理 + from muse.编排.章后处理 import _工厂 + + 声明 = 配置版本管理(_工厂(装配)).读取任务绑定(上下文.任务.任务ID).内容.角色配置["extractor"] + 请求 = 模型请求( + 调用ID=str(uuid4()), + provider=声明["provider"], + model=声明["model"], + thinking=声明.get("thinking"), + 系统提示=系统提示, + 用户输入=json.dumps( + { + "source_id": 拆书输入.get("source_id"), + "revision": 拆书输入.get("revision"), + "window_id": 窗对象.窗ID, + "起章": 窗对象.起章, + "止章": 窗对象.止章, + "窗口原文": 片段, + }, + ensure_ascii=False, + ), + 输出合同=窗口输出合同, + 最大输出token=8_000, + 总期限秒=600.0, + ) + 请求 = 组装角色请求(角色策略目录.从发布包(), "extractor", 请求) + 请求哈希 = hashlib.sha256(请求字节(请求)).hexdigest() + 用途值 = 上下文.任务.冻结输入.get("内容用途", 内容用途.抽取.value) + 授权 = 装配.要求原文().批准保留( + 上下文.任务.任务ID, + 上下文.任务.作者, + "raw-" + 请求.调用ID, + 来源版本=f"{拆书输入.get('source_id')}@r{拆书输入.get('revision')}", + 哈希=(请求哈希,), + 内容用途=用途值, + 方式="persistent", + 有效期=datetime.now(UTC) + timedelta(minutes=30), + 调用ID=请求.调用ID, + 调用请求哈希=请求哈希, + ) + 执行器 = 装配.要求模型执行器(上下文, 计价) + 交付 = asyncio.run(执行器.执行(上下文, 请求, 阶段="拆书分析", 原文授权ID=授权)) + 内容 = dict(交付.内容) + for 项 in 内容["entities"]: + 项.setdefault("aliases", []) + 项.setdefault("evidence", []) + return 内容 + + +__all__ = ["窗口输出合同", "系统提示", "执行窗口提取", "资料错误"] diff --git a/src/muse/资料研究/提示词/分析参考作品.md b/src/muse/资料研究/提示词/分析参考作品.md new file mode 100644 index 0000000..d98f13e --- /dev/null +++ b/src/muse/资料研究/提示词/分析参考作品.md @@ -0,0 +1,19 @@ +# 分析参考作品 · 任务提示模板 + +本模板定义「分析参考作品」逐窗提取任务的可版本化表达;角色身份、字段结构、权限与预算从各自权威装配,本模板不重复。 + +## 任务 + +对给定窗口(来源版本 + 码点区间 + 内容哈希)输出拆书分析: + +1. **章节与节拍**:窗口内每章的标题与情节节拍(beat,一句话)。 +2. **叙事阶段**:窗口覆盖的叙事阶段(如 开局/推进/转折/收束)。 +3. **实体**:出现的人物、地点、事物;每人附类型与原文引文证据;别名一并列出。 +4. **写法**:可借鉴的写作技法(视角、节奏、悬念、对话等),逐条一句话。 + +## 合同 + +- 只依据窗口原文;窗口外内容不补猜、不引用。 +- 实体证据必须是窗口原文中的字面引文。 +- 输出一个 JSON 对象,字段结构以任务输出合同为准(chapters/phases/entities/techniques)。 +- 窗口号是切分序号,不是真实章号;章号以窗口内章节标题为准。 diff --git a/src/muse/资料研究/提示词/核对实体歧义.md b/src/muse/资料研究/提示词/核对实体歧义.md new file mode 100644 index 0000000..7d61bfc --- /dev/null +++ b/src/muse/资料研究/提示词/核对实体歧义.md @@ -0,0 +1,18 @@ +# 核对实体歧义 · 任务提示模板 + +本模板为「核对实体歧义」任务提供可版本化表达;W18 阶段歧义只登记不调用模型, +本模板供后续语义核对阶段装配使用。 + +## 任务 + +对拆书归并中登记的歧义条目(同名不同类型的实体候选)逐项核对: + +1. 列出各候选的出现窗与证据引文。 +2. 依据上下文判断各候选是否同一实体;判断必须给出引文级依据。 +3. 结论只能是三态之一:同一实体(合并并说明)、不同实体(分别命名区分)、证据不足(维持歧义)。 + +## 合同 + +- 不因为同名就直接合并;类型冲突的同名默认不同实体。 +- 只处理参考作品内部身份判定;不跨作品合并,不改写本书事实。 +- 证据不足时维持歧义,不得输出倾向性猜测。 diff --git a/src/muse/资料研究/样本选题.py b/src/muse/资料研究/样本选题.py new file mode 100644 index 0000000..e8e2e52 --- /dev/null +++ b/src/muse/资料研究/样本选题.py @@ -0,0 +1,64 @@ +"""样本选题:样本比较、选题依据与选书交接;观察陈述可回查,不把相关性当因果。""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from muse.资料研究.模型 import 资料错误 + + +def 样本比较(A: dict[str, Any], B: dict[str, Any]) -> dict[str, Any]: + """两份样本的结构观察差异:只陈述可复核指标,不作效果归因。""" + + 指标 = ("章数", "字数", "实体数", "窗口数") + 差异 = { + 名: {"A": A.get(名), "B": B.get(名)} + for 名 in 指标 + if A.get(名) is not None or B.get(名) is not None + } + return { + "样本A": _身份(A), + "样本B": _身份(B), + "差异": 差异, + "说明": "以上为时点观察记录;不构成优劣结论或效果因果。", + } + + +def 选书交接单(样本: dict[str, Any], 理由: str) -> dict[str, Any]: + """把选定样本交接给拆书:原文身份(来源、版本、哈希)必须完整可回查。""" + + 身份 = _身份(样本) + if not 理由.strip(): + raise 资料错误("选书依据必须写明理由") + return { + "source_id": 身份["source_id"], + "revision": int(身份["revision"]), + "content_hash": 身份["content_hash"], + "理由": 理由.strip(), + "观察": { + 名: 样本[名] for 名 in ("章数", "字数", "实体数", "窗口数") if 样本.get(名) is not None + }, + } + + +def 选题依据哈希(交接单: dict[str, Any]) -> str: + return hashlib.sha256( + str( + [交接单["source_id"], 交接单["revision"], 交接单["content_hash"], 交接单["理由"]] + ).encode() + ).hexdigest() + + +def _身份(样本: dict[str, Any]) -> dict[str, Any]: + for 名 in ("source_id", "revision", "content_hash"): + if 样本.get(名) is None: + raise 资料错误(f"样本缺少 {名},原文身份不完整") + return { + "source_id": str(样本["source_id"]), + "revision": int(样本["revision"]), + "content_hash": str(样本["content_hash"]), + } + + +__all__ = ["样本比较", "选书交接单", "选题依据哈希", "资料错误"] diff --git a/tests/adapters/test_host_adapter_consistency.py b/tests/adapters/test_host_adapter_consistency.py index a423984..c968597 100644 --- a/tests/adapters/test_host_adapter_consistency.py +++ b/tests/adapters/test_host_adapter_consistency.py @@ -4,10 +4,8 @@ from __future__ import annotations import pathlib import sys -import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/architecture/test_author_instructions.py b/tests/architecture/test_author_instructions.py index 5a9965f..da0f79b 100644 --- a/tests/architecture/test_author_instructions.py +++ b/tests/architecture/test_author_instructions.py @@ -7,7 +7,6 @@ import pathlib import re import unittest - ROOT = pathlib.Path(__file__).resolve().parents[2] AUTHOR_ROOT = ROOT / ".agent" / "作者" SCENE_ROOT = AUTHOR_ROOT / "场景" diff --git a/tests/architecture/test_dsh_adapter.py b/tests/architecture/test_dsh_adapter.py index 206ff21..ee2677a 100644 --- a/tests/architecture/test_dsh_adapter.py +++ b/tests/architecture/test_dsh_adapter.py @@ -32,7 +32,6 @@ from framework.adapters.dsh.runner import ( # noqa: E402 from framework.primitives.artifacts import read_jsonl # noqa: E402 from framework.primitives.execution import FrameworkExecutionRequest # noqa: E402 - SESSION_LINES = [ { "type": "session", diff --git a/tests/architecture/test_dsh_pi_route.py b/tests/architecture/test_dsh_pi_route.py index 3b0777c..857403b 100644 --- a/tests/architecture/test_dsh_pi_route.py +++ b/tests/architecture/test_dsh_pi_route.py @@ -7,7 +7,6 @@ import pathlib import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/architecture/test_framework_port_purity.py b/tests/architecture/test_framework_port_purity.py index 92f01f5..b78ee2a 100644 --- a/tests/architecture/test_framework_port_purity.py +++ b/tests/architecture/test_framework_port_purity.py @@ -6,7 +6,6 @@ import pathlib import re import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/architecture/test_framework_protocol.py b/tests/architecture/test_framework_protocol.py index 09b64aa..570c850 100644 --- a/tests/architecture/test_framework_protocol.py +++ b/tests/architecture/test_framework_protocol.py @@ -4,8 +4,8 @@ from __future__ import annotations import json import pathlib -import tempfile import sys +import tempfile import unittest from jsonschema import Draft202012Validator @@ -31,7 +31,6 @@ from framework.primitives.execution import ( FrameworkExecutionResult, ) - SCHEMA_DIR = ROOT / "framework" / "primitives" / "schemas" diff --git a/tests/architecture/test_import_boundaries.py b/tests/architecture/test_import_boundaries.py index 580cf36..4c1be53 100644 --- a/tests/architecture/test_import_boundaries.py +++ b/tests/architecture/test_import_boundaries.py @@ -13,6 +13,7 @@ import pathlib import re import unittest + def _find_project_root(start: pathlib.Path) -> pathlib.Path: resolved = start.resolve() for directory in (resolved, *resolved.parents): diff --git a/tests/architecture/test_markdown_links.py b/tests/architecture/test_markdown_links.py index a7343e6..79b4cde 100644 --- a/tests/architecture/test_markdown_links.py +++ b/tests/architecture/test_markdown_links.py @@ -7,7 +7,6 @@ import re import unittest import urllib.parse - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/architecture/test_runtime_purity.py b/tests/architecture/test_runtime_purity.py index 8f3de00..d9f3e09 100644 --- a/tests/architecture/test_runtime_purity.py +++ b/tests/architecture/test_runtime_purity.py @@ -6,7 +6,6 @@ import pathlib import re import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/architecture/test_studio_path_contract.py b/tests/architecture/test_studio_path_contract.py index b87cf59..df0860b 100644 --- a/tests/architecture/test_studio_path_contract.py +++ b/tests/architecture/test_studio_path_contract.py @@ -6,7 +6,6 @@ import pathlib import unittest from unittest import mock - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/conftest.py b/tests/conftest.py index a38bad0..1574e4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -496,6 +496,7 @@ def 章后环境(应用测试库, tmp_path): "剧本": 剧本, "收到": 收到, "新作品": 新作品, + "提供方端口": server.server_port, "处理器能力": sorted(set(生成处理器集) | set(章后处理器集)), } yield 环境 @@ -549,3 +550,129 @@ def 章后工具(章后环境): return {"类型": "文本", "文本": {"summary": 摘要, "facts": 事实}} return SimpleNamespace(保存一章=保存一章, 跑任务=跑任务, 抽取输出=抽取输出, 环境=环境) + + +@pytest.fixture +def 研究环境(章后环境, tmp_path): + """研究拆书环境:analyst 配置、研究流程装配与带授权的参考来源。""" + + from decimal import Decimal + from types import SimpleNamespace + from uuid import uuid4 + + from muse.任务运行.配置版本 import 凭据引用, 提供方配置, 运行配置内容, 配置版本管理 + from muse.任务运行.预算管理 import 预算管理, 额度策略 + from muse.启动 import 装配研究拆书 + from muse.编排.研究拆书 import 处理器版本集 as 研究处理器集 + from muse.资料研究.接口 import 资料服务 + from muse.资料研究.模型 import 导入请求 + + 环境 = 章后环境 + 环境["处理器能力"] = sorted(set(环境["处理器能力"]) | set(研究处理器集)) + 库, 装配 = 环境["库"], 环境["装配"] + 策略 = __import__("muse.任务运行.角色策略", fromlist=["角色策略目录"]).角色策略目录.从发布包() + + 装配研究拆书(装配, 合成派生计价(), 分析配置ID="research-config") + + 凭据 = tmp_path / "research-key" + 凭据.write_text("synthetic-research-credential") + 凭据.chmod(0o600) + 研究配置 = 运行配置内容( + "direct", + "1", + 策略.定义["version"], + 策略.资源发布身份, + "synthetic", + { + "extractor": { + "provider": "synthetic", + "model": "claude-opus-4-8[1M]", + "thinking": "high", + "stage_tools": {"抽取": [], "拆书分析": []}, + }, + }, + (凭据引用("provider-key", "受控存储", str(凭据)),), + ( + 提供方配置( + "synthetic", + "responses", + f"http://127.0.0.1:{环境['提供方端口']}/v1/responses", + "provider-key", + ), + ), + 合成派生计价().版本, + ) + 管理 = 配置版本管理(库, _离线派生配置检查()) + 管理.保存草案("research-config", "1", 研究配置) + 启用回执 = 管理.验证版本("research-config", "1") + 管理.启用("research-config", "1", 验证回执=启用回执, 批准引用="synthetic-approval", 预期代次=0) + 预算 = 预算管理(库, "synthetic-research") + 预算.登记策略(额度策略("synthetic-research", "1", Decimal("40"), 80)) + + 服务 = 资料服务() + + def 导入来源(标题: str, 正文: str, 用途集: tuple[str, ...] = ("analysis",)) -> dict: + with 库.连接() as 连, 连.transaction(): + 回执 = 服务.导入( + 连, + 环境["作者"].作者, + 导入请求("reference", 标题, f"file://资料/{标题}.txt", 正文, 用途集), + ) + return { + "source_id": 回执.source_id, + "revision": 回执.revision, + "content_hash": 回执.content_hash, + } + + def 发起( + source_id: str, *, 配置ID: str = "research-config", 重跑窗=None, 理由=None, 窗口章数=None + ): + from muse.编排.研究拆书 import 发起研究拆书 + + 样本 = {"理由": 理由} if 理由 else None + return 发起研究拆书( + 装配, + 环境["作者"], + "research-" + uuid4().hex[:8], + source_id=source_id, + 配置ID=配置ID, + 重跑窗=重跑窗, + 样本依据=样本, + 窗口章数=窗口章数, + ) + + def 跑任务(任务ID: str): + from muse.任务运行.接口 import 任务状态 + + 运行 = 装配.任务运行 + 能力 = sorted(set(环境["处理器能力"]) | set(研究处理器集)) + while True: + 快照 = 运行.读取任务(任务ID) + if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}: + return 快照 + 领取 = 运行.领取步骤("worker", 能力) + assert 领取 is not None, "任务未完成却没有可领取步骤" + 运行.执行一步(领取) + + def 分析输出(实体: list[dict], 写法=("三段式推进",)) -> dict: + return { + "类型": "文本", + "文本": { + "chapters": [{"title": "章", "beat": "节拍"}], + "phases": ["推进"], + "entities": 实体, + "techniques": list(写法), + }, + } + + return SimpleNamespace( + 环境=环境, + 库=库, + 装配=装配, + 剧本=环境["剧本"], + 服务=服务, + 导入来源=导入来源, + 发起=发起, + 跑任务=跑任务, + 分析输出=分析输出, + ) diff --git a/tests/e2e/test_compounding.py b/tests/e2e/test_compounding.py index ad122a2..24639bb 100644 --- a/tests/e2e/test_compounding.py +++ b/tests/e2e/test_compounding.py @@ -11,7 +11,6 @@ import sys import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) @@ -22,7 +21,6 @@ if str(ROOT) not in sys.path: from unittest import mock -from muse import replay as replay_mod # noqa: E402 from muse.flow import dispatch as dispatch_mod # noqa: E402 from muse.flow.adopt import adopt_candidate # noqa: E402 from muse.store import ( # noqa: E402 @@ -33,6 +31,8 @@ from muse.store import ( # noqa: E402 ) from web import app as webapp # noqa: E402 +from muse import replay as replay_mod # noqa: E402 + class CompoundingTest(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/e2e/test_ranking_attribution.py b/tests/e2e/test_ranking_attribution.py index f4aae81..d237697 100644 --- a/tests/e2e/test_ranking_attribution.py +++ b/tests/e2e/test_ranking_attribution.py @@ -7,7 +7,6 @@ import sys import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/e2e/test_sqlite_write_path.py b/tests/e2e/test_sqlite_write_path.py index 55b8634..e31573a 100644 --- a/tests/e2e/test_sqlite_write_path.py +++ b/tests/e2e/test_sqlite_write_path.py @@ -9,7 +9,6 @@ import sys import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/protocol/test_muse_store.py b/tests/protocol/test_muse_store.py index 7842ca1..00e4082 100644 --- a/tests/protocol/test_muse_store.py +++ b/tests/protocol/test_muse_store.py @@ -8,7 +8,6 @@ import sys import tempfile import unittest - ROOT = next( parent for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) diff --git a/tests/skills/修正正文机器味/test_revise_ai_flavor.py b/tests/skills/修正正文机器味/test_revise_ai_flavor.py index a1bccaf..0b08b6c 100644 --- a/tests/skills/修正正文机器味/test_revise_ai_flavor.py +++ b/tests/skills/修正正文机器味/test_revise_ai_flavor.py @@ -13,8 +13,8 @@ DIAGNOSE_SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "humaniz sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(DIAGNOSE_SCRIPT_DIR)) -import revise_ai_flavor as rev # noqa: E402 import diagnose_ai_flavor as diag # noqa: E402 +import revise_ai_flavor as rev # noqa: E402 from deai.pipeline import DowngradedToAudit # noqa: E402 CLEAN_TEXT = "值得注意的是,门外已经下起了雨。" diff --git a/tests/skills/写下一章/test_candidate_cas.py b/tests/skills/写下一章/test_candidate_cas.py index 115c253..9d903ab 100644 --- a/tests/skills/写下一章/test_candidate_cas.py +++ b/tests/skills/写下一章/test_candidate_cas.py @@ -30,9 +30,9 @@ for path in (READ_CONTEXT_DIR, DETECT_DIR, SCRIPT_DIR, TEST_DIR, CHECK_TEST_DIR) from candidate_cas import PostgresCasStateStore # noqa: E402 from run_writer_pipeline import CasToken, PipelineError, run_writer_pipeline # noqa: E402 from run_writer_semantic_detector import canonical_sha256 # noqa: E402 -from test_check_writer_candidate import _candidate_body, _requirements, _valid_pair # noqa: E402 +from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402 from test_run_writer_pipeline import _semantic_pass, _writer_output # noqa: E402 -from writer_contract import build_candidate_envelope, retrieval_identity # noqa: E402 +from writer_contract import retrieval_identity # noqa: E402 class FakeCursor: diff --git a/tests/skills/写下一章/test_dispatch_writer_bridge.py b/tests/skills/写下一章/test_dispatch_writer_bridge.py index 3dcb561..7b7118f 100644 --- a/tests/skills/写下一章/test_dispatch_writer_bridge.py +++ b/tests/skills/写下一章/test_dispatch_writer_bridge.py @@ -21,7 +21,6 @@ for path in (SCRIPT_DIR, DISPATCH_TEST_DIR, CHECK_TEST_DIR, WNC_TEST_DIR): if str(path) not in sys.path: sys.path.insert(0, str(path)) -import dispatch_writer_bridge as bridge # noqa: E402 from dispatch_agent_task import DEFAULT_RUN_DIR_ROOT # noqa: E402 from dispatch_writer_bridge import ( # noqa: E402 DispatchWriterError, @@ -31,7 +30,12 @@ from dispatch_writer_bridge import ( # noqa: E402 ) from read_tools import TOOL_REGISTRY # noqa: E402 from test_check_writer_candidate import _valid_pair # noqa: E402 -from test_dispatch_agent_task import FIXED_MODEL, RecordingConnect, fake_launcher, pi_stream_lines # noqa: E402 +from test_dispatch_agent_task import ( # noqa: E402 + FIXED_MODEL, + RecordingConnect, + fake_launcher, + pi_stream_lines, +) class BridgeConnect(RecordingConnect): diff --git a/tests/skills/写下一章/test_persist_writer_run.py b/tests/skills/写下一章/test_persist_writer_run.py index 91aa033..bd7e11c 100644 --- a/tests/skills/写下一章/test_persist_writer_run.py +++ b/tests/skills/写下一章/test_persist_writer_run.py @@ -4,7 +4,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "work" / "skills" / "generate" / "写下一章" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/写下一章/test_production_evidence_reassemble.py b/tests/skills/写下一章/test_production_evidence_reassemble.py index ec365e5..8688f24 100644 --- a/tests/skills/写下一章/test_production_evidence_reassemble.py +++ b/tests/skills/写下一章/test_production_evidence_reassemble.py @@ -21,11 +21,14 @@ from production_evidence_reassemble import ( # noqa: E402 EvidenceReassembleError, reassemble_writer_context_for_gaps, ) -from run_writer_pipeline import InMemoryCasStateStore, PipelineError, run_writer_pipeline # noqa: E402 -from writer_contract import build_writer_creative_input, retrieval_identity # noqa: E402 - +from run_writer_pipeline import ( # noqa: E402 + InMemoryCasStateStore, + PipelineError, + run_writer_pipeline, +) from test_check_writer_candidate import _requirements, _valid_pair # noqa: E402 from test_run_writer_pipeline import _semantic_report, _writer_output # noqa: E402 +from writer_contract import build_writer_creative_input, retrieval_identity # noqa: E402 class ProductionEvidenceReassembleTests(unittest.TestCase): diff --git a/tests/skills/写下一章/test_run_writer.py b/tests/skills/写下一章/test_run_writer.py index 473ac21..e59afc3 100644 --- a/tests/skills/写下一章/test_run_writer.py +++ b/tests/skills/写下一章/test_run_writer.py @@ -24,8 +24,6 @@ sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(READ_CONTEXT_DIR)) sys.path.insert(0, str(TEST_CONTEXT_DIR)) -from test_writer_contract import valid_context, valid_draft # noqa: E402 -from writer_contract import build_writer_creative_input, canonical_json, retrieval_identity # noqa: E402 from muse_role import ( # noqa: E402 FIXED_OPUS_MODEL_ID, FIXED_OPUS_POLICY_ALIAS, @@ -40,6 +38,12 @@ from run_writer import ( # noqa: E402 calculate_dynamic_output_contract, run_writer, ) +from test_writer_contract import valid_context, valid_draft # noqa: E402 +from writer_contract import ( # noqa: E402 + build_writer_creative_input, + canonical_json, + retrieval_identity, +) USAGE = {"input_tokens": 100, "output_tokens": 20} ACTUAL_MODEL = FIXED_OPUS_MODEL_ID diff --git a/tests/skills/写下一章/test_run_writer_pipeline.py b/tests/skills/写下一章/test_run_writer_pipeline.py index eef5c8c..6e8e17a 100644 --- a/tests/skills/写下一章/test_run_writer_pipeline.py +++ b/tests/skills/写下一章/test_run_writer_pipeline.py @@ -22,9 +22,14 @@ READ_CONTEXT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "context" / "skills" / for path in (READ_CONTEXT_DIR, DETECT_DIR, SCRIPT_DIR, TEST_DIR, CHECK_TEST_DIR): sys.path.insert(0, str(path)) -from test_check_writer_candidate import _candidate_body, _requirements, _valid_pair # noqa: E402 -from run_writer_pipeline import CasToken, InMemoryCasStateStore, PipelineError, atomic_write_json, run_writer_pipeline # noqa: E402 +from run_writer_pipeline import ( # noqa: E402 + InMemoryCasStateStore, + PipelineError, + atomic_write_json, + run_writer_pipeline, +) from run_writer_semantic_detector import canonical_sha256 # noqa: E402 +from test_check_writer_candidate import _candidate_body, _requirements, _valid_pair # noqa: E402 from writer_contract import build_candidate_envelope, retrieval_identity # noqa: E402 diff --git a/tests/skills/写下一章/test_semantic_verdict.py b/tests/skills/写下一章/test_semantic_verdict.py index 8352293..48e8c6f 100644 --- a/tests/skills/写下一章/test_semantic_verdict.py +++ b/tests/skills/写下一章/test_semantic_verdict.py @@ -21,7 +21,6 @@ if str(SCRIPT_DIR) not in sys.path: import persist_writer_run as writer_persist # noqa: E402 - CANDIDATE_SHA = "a" * 64 CONTEXT_SHA = "sha256:" + "b" * 64 diff --git a/tests/skills/写下一章/test_two_phase_writer.py b/tests/skills/写下一章/test_two_phase_writer.py index 5c46de6..349e2ca 100644 --- a/tests/skills/写下一章/test_two_phase_writer.py +++ b/tests/skills/写下一章/test_two_phase_writer.py @@ -20,8 +20,8 @@ for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) -import two_phase_writer # noqa: E402 import dispatch_writer_bridge as bridge # noqa: E402 +import two_phase_writer # noqa: E402 from two_phase_writer import ( # noqa: E402 EXPLORATION_MAX_MATERIALS, EXPLORATION_SCHEMA_VERSION, diff --git a/tests/skills/决定正文候选去留/test_fact_delta.py b/tests/skills/决定正文候选去留/test_fact_delta.py index 0c35a57..ca1cb57 100644 --- a/tests/skills/决定正文候选去留/test_fact_delta.py +++ b/tests/skills/决定正文候选去留/test_fact_delta.py @@ -9,7 +9,6 @@ from __future__ import annotations -import copy import pathlib import sys import unittest @@ -22,7 +21,10 @@ for path in (SCRIPT_DIR, PROJECT_ROOT / "muse" / "lifecycle" / "context" / "skil sys.path.insert(0, str(path)) from fact_delta import ( # noqa: E402 - DELTA_TYPES, FactDeltaError, validate_delta_batch, validate_delta_proposal, + DELTA_TYPES, + FactDeltaError, + validate_delta_batch, + validate_delta_proposal, ) BODY = "林深把黑纹缠上手臂,异种核心在胸腔里低鸣。何岚站在舱门口没有说话。" diff --git a/tests/skills/决定正文候选去留/test_fact_delta_db.py b/tests/skills/决定正文候选去留/test_fact_delta_db.py index 4f5b15a..5224e66 100644 --- a/tests/skills/决定正文候选去留/test_fact_delta_db.py +++ b/tests/skills/决定正文候选去留/test_fact_delta_db.py @@ -30,9 +30,9 @@ for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) -from muse_db import connect # noqa: E402 from fact_delta import FactDeltaError, propose_fact_deltas # noqa: E402 -from write_canonical import ConflictError, accept # noqa: E402 +from muse_db import connect # noqa: E402 +from write_canonical import accept # noqa: E402 SUFFIX = uuid.uuid4().hex[:10] WORK_TITLE = f"unittest-delta-{SUFFIX}" diff --git a/tests/skills/决定正文候选去留/test_projection_db.py b/tests/skills/决定正文候选去留/test_projection_db.py index 433f6aa..32690c8 100644 --- a/tests/skills/决定正文候选去留/test_projection_db.py +++ b/tests/skills/决定正文候选去留/test_projection_db.py @@ -31,7 +31,10 @@ for path in (SCRIPT_DIR,): from muse_db import connect # noqa: E402 from projection_registry import ( # noqa: E402 - ProjectionError, finish_projection, refresh_staleness, retry_projection, + ProjectionError, + finish_projection, + refresh_staleness, + retry_projection, ) from write_canonical import accept # noqa: E402 diff --git a/tests/skills/决定正文候选去留/test_write_canonical_db.py b/tests/skills/决定正文候选去留/test_write_canonical_db.py index a75ab4d..1c5ee76 100644 --- a/tests/skills/决定正文候选去留/test_write_canonical_db.py +++ b/tests/skills/决定正文候选去留/test_write_canonical_db.py @@ -21,7 +21,6 @@ import hashlib import pathlib import sys import uuid -from typing import Any from unittest import mock PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -31,8 +30,8 @@ for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) -from muse_db import connect # noqa: E402 import write_canonical # noqa: E402 +from muse_db import connect # noqa: E402 from write_canonical import ConflictError, accept, discard # noqa: E402 SUFFIX = uuid.uuid4().hex[:10] diff --git a/tests/skills/准备任务上下文/test_persist_freeze_db.py b/tests/skills/准备任务上下文/test_persist_freeze_db.py index 0f3794b..63244de 100644 --- a/tests/skills/准备任务上下文/test_persist_freeze_db.py +++ b/tests/skills/准备任务上下文/test_persist_freeze_db.py @@ -25,8 +25,8 @@ for path in (SCRIPT_DIR,): if str(path) not in sys.path: sys.path.insert(0, str(path)) -from persist_context_freeze import persist_freeze # noqa: E402 from muse_db import connect # noqa: E402 +from persist_context_freeze import persist_freeze # noqa: E402 class FreezeReplayIdempotencyDbTest(unittest.TestCase): diff --git a/tests/skills/准备任务上下文/test_retrieve_writer_sources.py b/tests/skills/准备任务上下文/test_retrieve_writer_sources.py index 8952714..9c755e7 100644 --- a/tests/skills/准备任务上下文/test_retrieve_writer_sources.py +++ b/tests/skills/准备任务上下文/test_retrieve_writer_sources.py @@ -20,7 +20,6 @@ for script_dir in ( sys.path.insert(0, str(script_dir)) from load_reference_work import begin_read_snapshot # noqa: E402 -from search import search_cards # noqa: E402 from retrieve_writer_sources import ( # noqa: E402 ProductionCardIndexRepository, ReplayCardIndexRepository, @@ -30,7 +29,7 @@ from retrieve_writer_sources import ( # noqa: E402 retrieve_writer_sources, stable_sort_cards, ) - +from search import search_cards # noqa: E402 SOURCE_VERSION = "raw-file-v1:sha256:" + "a" * 64 diff --git a/tests/skills/准备任务上下文/test_writer_contract.py b/tests/skills/准备任务上下文/test_writer_contract.py index 6ef6902..b1beae0 100644 --- a/tests/skills/准备任务上下文/test_writer_contract.py +++ b/tests/skills/准备任务上下文/test_writer_contract.py @@ -465,7 +465,7 @@ class WriterContractTest(unittest.TestCase): self.assertEqual(output["candidateVersion"], 3) self.assertEqual( output["candidateSha256"], - "sha256:" + hashlib.sha256("Café\n正文".encode("utf-8")).hexdigest(), + "sha256:" + hashlib.sha256("Café\n正文".encode()).hexdigest(), ) validate_writer_output(output) diff --git a/tests/skills/准备正文回放数据/test_load_writer_reference_work.py b/tests/skills/准备正文回放数据/test_load_writer_reference_work.py index 91e24eb..5f91714 100644 --- a/tests/skills/准备正文回放数据/test_load_writer_reference_work.py +++ b/tests/skills/准备正文回放数据/test_load_writer_reference_work.py @@ -11,7 +11,7 @@ import sys import tempfile import types import unittest -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import patch PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -41,6 +41,7 @@ for _path in ( import load_writer_reference_work as loader # noqa: E402 import run_writer_replay as replay_module # noqa: E402 +from check_writer_candidate import check_writer_candidate # noqa: E402 from load_writer_reference_work import ( # noqa: E402 WriterReferenceWorkError, _requirement_hard_constraints, @@ -51,20 +52,18 @@ from load_writer_reference_work import ( # noqa: E402 validate_oracle_truth_pack, write_temporary_config, ) -from run_writer_replay import run_writer_replay # noqa: E402 -from check_writer_candidate import check_writer_candidate # noqa: E402 -from writer_contract import build_candidate_envelope, han_count # noqa: E402 -from test_run_writer import _bound_context # noqa: E402 -from refresh_runtime_probe import ( # noqa: E402 - make_dry_run_invoker, - refresh_runtime_probe, -) from muse_role import ( # noqa: E402 MODEL_POLICY_VERSION, RUNTIME_ADAPTER, RUNTIME_ADAPTER_VERSION, ) - +from refresh_runtime_probe import ( # noqa: E402 + make_dry_run_invoker, + refresh_runtime_probe, +) +from run_writer_replay import run_writer_replay # noqa: E402 +from test_run_writer import _bound_context # noqa: E402 +from writer_contract import build_candidate_envelope, han_count # noqa: E402 CONFIGS_DIR = WRITER_REPLAY_SCRIPTS.parent / "configs" BASE_CONFIG_PATH = CONFIGS_DIR / "writer-gate-a-deep-space-v1-cn-skills.json" @@ -724,7 +723,7 @@ class LoadWriterReferenceWorkTest(unittest.TestCase): parsed = datetime.fromisoformat(stamped["retainUntil"]) self.assertIsNotNone(parsed.tzinfo) # (c) 解析后在未来、且距 now 不超过 24 小时(实际约等于 23 小时) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) self.assertGreater(parsed, now) self.assertLessEqual(parsed - now, timedelta(hours=24)) # (d) receiptSha256 == 去掉 receiptSha256 的整段规范自哈希(重签正确) diff --git a/tests/skills/准备正文回放数据/test_pattern_reference_injection.py b/tests/skills/准备正文回放数据/test_pattern_reference_injection.py index 23fb21d..31c2c12 100644 --- a/tests/skills/准备正文回放数据/test_pattern_reference_injection.py +++ b/tests/skills/准备正文回放数据/test_pattern_reference_injection.py @@ -39,7 +39,6 @@ for _path in ( if str(_path) not in sys.path: sys.path.insert(0, str(_path)) -import load_writer_reference_work as loader # noqa: E402 from load_writer_reference_work import ( # noqa: E402 PATTERN_CARD_TYPES, PATTERN_TOTAL_CAP, @@ -52,19 +51,6 @@ from run_writer_replay.sample import ( # noqa: E402 _build_arm_contexts, _common_controls, ) -from writer_contract import ( # noqa: E402 - PATTERN_NAME_MAX_CHARS, - PATTERN_POINTS_MAX_FIELDS, - PATTERN_POINT_MAX_CHARS, - PATTERN_SUMMARY_MAX_CHARS, - ContractError, - _pattern_source_ref, - _source_ref, - build_writer_creative_input, - normalize_text, - pattern_references_for_arm, - retrieval_identity, -) # 复用既有测试夹具:真实五章 base 配置 + 纯数据快照,能让装配器跑通真实 dry-run。 from test_load_writer_reference_work import ( # noqa: E402 @@ -74,6 +60,19 @@ from test_load_writer_reference_work import ( # noqa: E402 _assembly_rows, _refresh_self_hash, ) +from writer_contract import ( # noqa: E402 + PATTERN_NAME_MAX_CHARS, + PATTERN_POINT_MAX_CHARS, + PATTERN_POINTS_MAX_FIELDS, + PATTERN_SUMMARY_MAX_CHARS, + ContractError, + _pattern_source_ref, + _source_ref, + build_writer_creative_input, + normalize_text, + pattern_references_for_arm, + retrieval_identity, +) def _prepared_base_config() -> dict[str, object]: diff --git a/tests/skills/判定质量是否合格/test_gate_input_builder.py b/tests/skills/判定质量是否合格/test_gate_input_builder.py index 09ffd86..3f8c23a 100644 --- a/tests/skills/判定质量是否合格/test_gate_input_builder.py +++ b/tests/skills/判定质量是否合格/test_gate_input_builder.py @@ -17,7 +17,7 @@ for _import_dir in (SCRIPT_DIR, TEST_DIR, QUALITY_GATE_DIR): if str(_import_dir) not in sys.path: sys.path.insert(0, str(_import_dir)) -from gate_input_builder import GateInputBuildError, GateInputBuilder, canonical_sha256 +from gate_input_builder import GateInputBuilder, GateInputBuildError, canonical_sha256 from test_writer_gate import build_input, source_bundle from writer_rubric import RUBRIC_POLICY_VERSION diff --git a/tests/skills/判定质量是否合格/test_writer_gate.py b/tests/skills/判定质量是否合格/test_writer_gate.py index 3d79dce..b0cc595 100644 --- a/tests/skills/判定质量是否合格/test_writer_gate.py +++ b/tests/skills/判定质量是否合格/test_writer_gate.py @@ -20,8 +20,8 @@ for _import_dir in (SCRIPT_DIR, WRITER_REPLAY_DIR, QUALITY_GATE_DIR, EVIDENCE_DI if str(_import_dir) not in sys.path: sys.path.insert(0, str(_import_dir)) -from muse_role import FIXED_OPUS_MODEL_ID, FIXED_OPUS_POLICY_ALIAS from gate_input_builder import GateInputBuilder, canonical_sha256 +from muse_role import FIXED_OPUS_MODEL_ID, FIXED_OPUS_POLICY_ALIAS from writer_eval_preregister import build_balanced_preregistration from writer_gate import ( decide_gate, @@ -35,7 +35,6 @@ from writer_rubric import ( adjudicate_structured_reviews, ) - SCENARIOS = ( "battle", "character_dialogue", diff --git a/tests/skills/制定作品规划/test_field_coverage.py b/tests/skills/制定作品规划/test_field_coverage.py index d11a870..09c72ec 100644 --- a/tests/skills/制定作品规划/test_field_coverage.py +++ b/tests/skills/制定作品规划/test_field_coverage.py @@ -10,17 +10,16 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "flow" / "skills" / "book" / "制定作品规划" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) from persist_planning import ( # noqa: E402 - check_field_coverage, - write_section, + FINE_OUTLINE_OWNER, _load_schema, assert_section_owner, - FINE_OUTLINE_OWNER, + check_field_coverage, + write_section, ) REQUIRED = ["targetChapter", "sourceRef", "chapterGoal", "hardConstraints", "keyEvents", diff --git a/tests/skills/制定作品规划/test_record_planning_execution.py b/tests/skills/制定作品规划/test_record_planning_execution.py index 4d30f32..783a786 100644 --- a/tests/skills/制定作品规划/test_record_planning_execution.py +++ b/tests/skills/制定作品规划/test_record_planning_execution.py @@ -5,7 +5,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "flow" / "skills" / "book" / "制定作品规划" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/制定作品规划/test_repair_deterministic_receipt.py b/tests/skills/制定作品规划/test_repair_deterministic_receipt.py index 33ce85b..ed45d31 100644 --- a/tests/skills/制定作品规划/test_repair_deterministic_receipt.py +++ b/tests/skills/制定作品规划/test_repair_deterministic_receipt.py @@ -5,7 +5,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "flow" / "skills" / "book" / "制定作品规划" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/制定作品规划/test_select_patterns_offline.py b/tests/skills/制定作品规划/test_select_patterns_offline.py index 10e0562..1d26c05 100644 --- a/tests/skills/制定作品规划/test_select_patterns_offline.py +++ b/tests/skills/制定作品规划/test_select_patterns_offline.py @@ -6,7 +6,6 @@ import sys import unittest from unittest.mock import patch - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "flow" / "skills" / "book" / "制定作品规划" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/回放评估正文质量/test_run_writer_replay.py b/tests/skills/回放评估正文质量/test_run_writer_replay.py index bad29e5..53b1193 100644 --- a/tests/skills/回放评估正文质量/test_run_writer_replay.py +++ b/tests/skills/回放评估正文质量/test_run_writer_replay.py @@ -13,10 +13,10 @@ import signal import sys import tempfile import unittest -from unittest import mock from dataclasses import replace +from datetime import UTC, datetime, timedelta from decimal import Decimal -from datetime import datetime, timedelta, timezone +from unittest import mock TEST_DIR = pathlib.Path(__file__).resolve().parent PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -38,19 +38,8 @@ for import_path in ( import run_writer_replay as replay_module # noqa: E402 import run_writer_replay.execute as execute_module # noqa: E402 -from run_writer_replay import ( # noqa: E402 - BudgetLedgerError, - WriterReplayError, - WriterReplayProductionAdapters, - WriterReplayTestAdapters, - run_writer_replay, -) -from run_writer_replay.blind import ( # noqa: E402 - _SOURCE_REF_ALLOWED, - _clean_source_ref, - _project_oracle_pack, - _semantic_input_v3, -) +from file_cas import CasConflictError, FileCasStore # noqa: E402 +from gate_input_builder import GateInputBuilder, GateInputBuildError, canonical_sha256 # noqa: E402 from muse_role import ( # noqa: E402 FIXED_OPUS_MODEL_ID, FIXED_OPUS_POLICY_ALIAS, @@ -64,18 +53,29 @@ from muse_role import ( # noqa: E402 sha256_json, sha256_text, ) -from file_cas import CasConflictError, FileCasStore # noqa: E402 -from gate_input_builder import GateInputBuildError, GateInputBuilder, canonical_sha256 # noqa: E402 from raw_vault import RawVaultError, RawVaultManager # noqa: E402 +from run_writer import build_writer_execution_profile # noqa: E402 from run_writer_blind_judge import BLIND_JUDGE_REPORT_JSON_SCHEMA # noqa: E402 +from run_writer_replay import ( # noqa: E402 + BudgetLedgerError, + WriterReplayError, + WriterReplayProductionAdapters, + WriterReplayTestAdapters, + run_writer_replay, +) +from run_writer_replay.blind import ( # noqa: E402 + _SOURCE_REF_ALLOWED, + _clean_source_ref, + _project_oracle_pack, + _semantic_input_v3, +) from run_writer_semantic_detector import ( # noqa: E402 SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA, is_semantic_schema_specialization, ) -from run_writer import build_writer_execution_profile # noqa: E402 +from writer_contract import calculate_target_chars, han_count # noqa: E402 from writer_eval_preregister import build_balanced_preregistration # noqa: E402 from writer_gate import decide_gate # noqa: E402 -from writer_contract import calculate_target_chars, han_count, validate_writer_context # noqa: E402 from writer_rubric import ( # noqa: E402 COMMON_DIMENSIONS, DIMENSIONS, @@ -90,7 +90,7 @@ def _utc_stamp(delta: timedelta) -> str: """相对当前时刻生成 UTC 时间戳。""" return ( - (datetime.now(timezone.utc) + delta) + (datetime.now(UTC) + delta) .replace(microsecond=0) .isoformat() .replace("+00:00", "Z") @@ -702,7 +702,7 @@ def _test_execution_authorization() -> dict[str, object]: "status": "approved", "authorizationId": "raw-test-adapter-1", "approvedBy": "user", - "retainUntil": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "retainUntil": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), } for item in (probe, budget, raw): item["receiptSha256"] = canonical_sha256(item) @@ -1090,7 +1090,7 @@ def _production_config(*, budget_approved: bool = True) -> dict[str, object]: "status": "approved", "authorizationId": "raw-test-1", "approvedBy": "user", - "retainUntil": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "retainUntil": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), } for item in (probe, budget, raw): item["receiptSha256"] = canonical_sha256(item) @@ -1330,7 +1330,7 @@ class WriterReplayDryRunTest(unittest.TestCase): def test_raw_call_window_uses_one_role_timeout_plus_cleanup_margin(self): """固定时钟证明单次 30 秒 timeout 加 60 秒清理余量的精确边界。""" - now = datetime(2026, 7, 27, 0, 0, tzinfo=timezone.utc) + now = datetime(2026, 7, 27, 0, 0, tzinfo=UTC) profile = _role_profile("semantic_detector", SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA) replay_module._assert_raw_call_window( @@ -2459,7 +2459,7 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): adapters, writer, semantic, judge = _production_adapters(vault_factory=vault_factory) config = _production_config() raw = config["executionAuthorization"]["rawRetention"] - raw["retainUntil"] = (datetime.now(timezone.utc) + timedelta(minutes=4)).isoformat() + raw["retainUntil"] = (datetime.now(UTC) + timedelta(minutes=4)).isoformat() raw["receiptSha256"] = canonical_sha256( {key: value for key, value in raw.items() if key != "receiptSha256"} ) @@ -3710,7 +3710,7 @@ class WriterReplayProductionIntegrationTest(unittest.TestCase): adapters, writer, semantic, judge = _production_adapters(vault_factory=vault_factory) short = _production_config() raw = short["executionAuthorization"]["rawRetention"] - raw["retainUntil"] = (datetime.now(timezone.utc) + timedelta(seconds=27)).isoformat() + raw["retainUntil"] = (datetime.now(UTC) + timedelta(seconds=27)).isoformat() raw["receiptSha256"] = canonical_sha256( {key: value for key, value in raw.items() if key != "receiptSha256"} ) diff --git a/tests/skills/回放评估正文质量/test_writer_eval_preregister.py b/tests/skills/回放评估正文质量/test_writer_eval_preregister.py index b3f4ed4..38bd076 100644 --- a/tests/skills/回放评估正文质量/test_writer_eval_preregister.py +++ b/tests/skills/回放评估正文质量/test_writer_eval_preregister.py @@ -45,7 +45,7 @@ class WriterEvalPreregisterTest(unittest.TestCase): expected_order = sorted( self.sample_ids, key=lambda sample_id: hashlib.sha256( - f"{self.evaluation_set_version}{sample_id}".encode("utf-8") + f"{self.evaluation_set_version}{sample_id}".encode() ).hexdigest(), ) self.assertEqual( diff --git a/tests/skills/回放评估细纲质量/test_run_replay.py b/tests/skills/回放评估细纲质量/test_run_replay.py index 311c666..170e29f 100644 --- a/tests/skills/回放评估细纲质量/test_run_replay.py +++ b/tests/skills/回放评估细纲质量/test_run_replay.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import os import pathlib import sys import tempfile @@ -21,11 +20,11 @@ for _import_dir in (SCRIPT_DIR, TEST_DIR, QUALITY_GATE_DIR, REPLAY_GATE_TEST_DIR if str(_import_dir) not in sys.path: sys.path.insert(0, str(_import_dir)) from muse_role import FIXED_OPUS_MODEL_ID # noqa: E402 -from run_replay import _parse_args, _planner_prompt, run_replay as _run_replay # noqa: E402 +from run_replay import _parse_args, _planner_prompt # noqa: E402 +from run_replay import run_replay as _run_replay from test_writer_gate import build_input, source_bundle # noqa: E402 -from writer_gate import issue_gate_report_and_receipt # noqa: E402 from write_report import render_report # noqa: E402 - +from writer_gate import issue_gate_report_and_receipt # noqa: E402 SOURCE_HASH = "sha256:02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4" SOURCE_VERSION = f"raw-file-v1:{SOURCE_HASH}" diff --git a/tests/skills/固定任务上下文/test_build_snapshot.py b/tests/skills/固定任务上下文/test_build_snapshot.py index fb78d87..1a2b21b 100644 --- a/tests/skills/固定任务上下文/test_build_snapshot.py +++ b/tests/skills/固定任务上下文/test_build_snapshot.py @@ -18,7 +18,6 @@ from build_snapshot import ( # noqa: E402 remove_terminal_fields, ) - META = { "targetChapter": 489, "referenceWork": {"id": "deep-space", "version": "v1"}, diff --git a/tests/skills/固定任务上下文/test_check_snapshot.py b/tests/skills/固定任务上下文/test_check_snapshot.py index 1df4b0a..2a5de20 100644 --- a/tests/skills/固定任务上下文/test_check_snapshot.py +++ b/tests/skills/固定任务上下文/test_check_snapshot.py @@ -21,7 +21,6 @@ from check_snapshot import ( # noqa: E402 check_target_sources, ) - AUTH = { "sourceStatus": "active", "copyrightStatus": "research_only", diff --git a/tests/skills/固定任务上下文/test_load_reference_work.py b/tests/skills/固定任务上下文/test_load_reference_work.py index 65b4829..73d26e1 100644 --- a/tests/skills/固定任务上下文/test_load_reference_work.py +++ b/tests/skills/固定任务上下文/test_load_reference_work.py @@ -3,8 +3,8 @@ from __future__ import annotations -import json import inspect +import json import pathlib import sys import unittest @@ -20,7 +20,6 @@ from load_reference_work import ( # noqa: E402 project_card, ) - FILE_HASH = "02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4" SOURCE_HASH = f"sha256:{FILE_HASH}" SOURCE_VERSION = f"raw-file-v1:{SOURCE_HASH}" diff --git a/tests/skills/备份作品抽取结果/test_backup_upgrade_work_offline.py b/tests/skills/备份作品抽取结果/test_backup_upgrade_work_offline.py index 6a64b38..748f7f1 100644 --- a/tests/skills/备份作品抽取结果/test_backup_upgrade_work_offline.py +++ b/tests/skills/备份作品抽取结果/test_backup_upgrade_work_offline.py @@ -11,7 +11,6 @@ from contextlib import contextmanager from decimal import Decimal from unittest import mock - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "备份作品抽取结果" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/完善故事基础设定/test_validate_candidates.py b/tests/skills/完善故事基础设定/test_validate_candidates.py index b9cdb79..b2ba982 100644 --- a/tests/skills/完善故事基础设定/test_validate_candidates.py +++ b/tests/skills/完善故事基础设定/test_validate_candidates.py @@ -2,9 +2,9 @@ """最终交付格式校验器的回归检查;不模拟或约束创作对话。""" import sys -from pathlib import Path import tempfile import unittest +from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse/lifecycle/flow/skills/book/完善故事基础设定/scripts" diff --git a/tests/skills/执行角色任务/test_muse_role.py b/tests/skills/执行角色任务/test_muse_role.py index b91d2b0..c36b448 100644 --- a/tests/skills/执行角色任务/test_muse_role.py +++ b/tests/skills/执行角色任务/test_muse_role.py @@ -10,7 +10,6 @@ from decimal import Decimal from unittest import mock import muse_llm - from muse_role import ( CONTENT_POLICY_ALIAS, CONTENT_POLICY_VERSION, diff --git a/tests/skills/抽取作品知识/test_parse_upgrade_offline.py b/tests/skills/抽取作品知识/test_parse_upgrade_offline.py index 8daefd7..384ea9c 100644 --- a/tests/skills/抽取作品知识/test_parse_upgrade_offline.py +++ b/tests/skills/抽取作品知识/test_parse_upgrade_offline.py @@ -15,15 +15,14 @@ 跑法:仓库根目录 `.venv/bin/python tests/skills/抽取作品知识/test_parse_upgrade_offline.py` """ -import json import hashlib import inspect -from io import StringIO +import json import pathlib import sys -from contextlib import nullcontext -from contextlib import redirect_stderr +from contextlib import nullcontext, redirect_stderr from copy import deepcopy +from io import StringIO from unittest.mock import patch from click.testing import CliRunner @@ -32,8 +31,8 @@ from click.testing import CliRunner PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "抽取作品知识" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) -import upgrade as pu # noqa: E402 import muse_embed as embed_drafts +import upgrade as pu # noqa: E402 _passed = 0 diff --git a/tests/skills/抽取作品知识/test_upgrade_work_lock_offline.py b/tests/skills/抽取作品知识/test_upgrade_work_lock_offline.py index 72996f4..5512b2b 100644 --- a/tests/skills/抽取作品知识/test_upgrade_work_lock_offline.py +++ b/tests/skills/抽取作品知识/test_upgrade_work_lock_offline.py @@ -5,7 +5,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "抽取作品知识" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/抽取章节知识/test_extract_knowledge_offline.py b/tests/skills/抽取章节知识/test_extract_knowledge_offline.py index 90f155d..bd5c5cc 100644 --- a/tests/skills/抽取章节知识/test_extract_knowledge_offline.py +++ b/tests/skills/抽取章节知识/test_extract_knowledge_offline.py @@ -6,8 +6,11 @@ import sys PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "extract" / "抽取章节知识" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) -from extract_knowledge import ExtractionContractError, normalize_extraction, salvage_extraction # noqa: E402 - +from extract_knowledge import ( # noqa: E402 + ExtractionContractError, + normalize_extraction, + salvage_extraction, +) BODY = "林深走进舰桥,何岚把黑色钥匙交给他。" diff --git a/tests/skills/拆解书稿/test_parse_llm_offline.py b/tests/skills/拆解书稿/test_parse_llm_offline.py index 8fec347..b5518a9 100644 --- a/tests/skills/拆解书稿/test_parse_llm_offline.py +++ b/tests/skills/拆解书稿/test_parse_llm_offline.py @@ -10,7 +10,6 @@ import tempfile import unittest from unittest.mock import Mock, patch - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "拆解书稿" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/核对内容一致性/test_build_semantic_input.py b/tests/skills/核对内容一致性/test_build_semantic_input.py index ced3a7d..dfd0218 100644 --- a/tests/skills/核对内容一致性/test_build_semantic_input.py +++ b/tests/skills/核对内容一致性/test_build_semantic_input.py @@ -25,7 +25,8 @@ for path in (TEST_DIR, SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR): sys.path.insert(0, str(path)) from run_writer_semantic_detector import ( # noqa: E402 - build_semantic_input_v3, validate_semantic_detector_input, + build_semantic_input_v3, + validate_semantic_detector_input, ) from test_check_writer_candidate import _valid_pair # noqa: E402 diff --git a/tests/skills/核对内容一致性/test_check_writer_candidate.py b/tests/skills/核对内容一致性/test_check_writer_candidate.py index adbf27c..a5bbcbc 100644 --- a/tests/skills/核对内容一致性/test_check_writer_candidate.py +++ b/tests/skills/核对内容一致性/test_check_writer_candidate.py @@ -19,8 +19,8 @@ for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR, WRITER_TEST_DIR): if str(path) not in sys.path: sys.path.insert(0, str(path)) -from test_run_writer import _bound_context # noqa: E402 from check_writer_candidate import check_writer_candidate # noqa: E402 +from test_run_writer import _bound_context # noqa: E402 from writer_contract import build_candidate_envelope, han_count # noqa: E402 diff --git a/tests/skills/核对内容一致性/test_run_writer_semantic_detector.py b/tests/skills/核对内容一致性/test_run_writer_semantic_detector.py index e612cba..8eb489d 100644 --- a/tests/skills/核对内容一致性/test_run_writer_semantic_detector.py +++ b/tests/skills/核对内容一致性/test_run_writer_semantic_detector.py @@ -8,7 +8,8 @@ import hashlib import pathlib import sys import unittest -from typing import Any, Mapping, Sequence +from collections.abc import Mapping, Sequence +from typing import Any PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "skills" / "semantic" / "核对内容一致性" / "scripts" @@ -22,8 +23,8 @@ from run_writer_semantic_detector import ( # noqa: E402 build_safe_semantic_diagnostic, build_semantic_model_input, build_semantic_report_json_schema, - canonical_sha256, calculate_semantic_metrics, + canonical_sha256, is_semantic_schema_specialization, run_writer_semantic_detector, validate_semantic_detector_input, diff --git a/tests/skills/派发智能体任务/test_dispatch_agent_task.py b/tests/skills/派发智能体任务/test_dispatch_agent_task.py index c4bb740..b11d94b 100644 --- a/tests/skills/派发智能体任务/test_dispatch_agent_task.py +++ b/tests/skills/派发智能体任务/test_dispatch_agent_task.py @@ -13,7 +13,6 @@ import sys import tempfile import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SKILL_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "dispatch" / "skills" / "派发智能体任务" / "scripts" EVIDENCE_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记录运行证据" / "scripts" @@ -21,7 +20,14 @@ for path in (PROJECT_ROOT, SKILL_DIR, EVIDENCE_DIR): if str(path) not in sys.path: sys.path.insert(0, str(path)) -import role_task as agent_task # noqa: E402 +from agent_trace import AgentTraceWriter # noqa: E402 +from dispatch_agent_task import EXIT_OUTPUT_INVALID, EXIT_SPEC_INVALID, run_dispatch # noqa: E402 +from framework.adapters.pi.runner import ( # noqa: E402 + ExecutionPolicy, + FrameworkError, + PiAgentRunner, + build_pi_argv, +) from role_task import ( # noqa: E402 OutputInvalidError, TaskSpecError, @@ -29,14 +35,6 @@ from role_task import ( # noqa: E402 load_spec, validate_structured_output, ) -from framework.adapters.pi.runner import ( # noqa: E402 - ExecutionPolicy, - FrameworkError, - PiAgentRunner, - build_pi_argv, -) -from dispatch_agent_task import EXIT_OUTPUT_INVALID, EXIT_SPEC_INVALID, run_dispatch # noqa: E402 -from agent_trace import AgentTraceWriter # noqa: E402 REPO_ROOT = PROJECT_ROOT diff --git a/tests/skills/派发智能体任务/test_read_tools.py b/tests/skills/派发智能体任务/test_read_tools.py index 28306b8..b7f5b1c 100644 --- a/tests/skills/派发智能体任务/test_read_tools.py +++ b/tests/skills/派发智能体任务/test_read_tools.py @@ -11,7 +11,6 @@ import json import pathlib import subprocess import sys -import tempfile import unittest PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -21,7 +20,6 @@ for path in (PROJECT_ROOT, SKILL_DIR): if str(path) not in sys.path: sys.path.insert(0, str(path)) -import read_tools # noqa: E402 from read_tools import TOOL_REGISTRY, execute_tool # noqa: E402 READ_TOOLS_PY = SKILL_DIR / "read_tools.py" diff --git a/tests/skills/生成知识向量/test_embed_drafts_offline.py b/tests/skills/生成知识向量/test_embed_drafts_offline.py index ee9a34c..e8f6434 100644 --- a/tests/skills/生成知识向量/test_embed_drafts_offline.py +++ b/tests/skills/生成知识向量/test_embed_drafts_offline.py @@ -8,9 +8,8 @@ import pathlib import unittest from unittest.mock import MagicMock, Mock, patch -from click.testing import CliRunner - import muse_embed as embed +from click.testing import CliRunner # CLI 壳留在 Skill 里、不随包安装,只能按路径加载 CLI_PATH = (pathlib.Path(__file__).resolve().parents[3] diff --git a/tests/skills/确认知识草稿/test_confirm_knowledge_offline.py b/tests/skills/确认知识草稿/test_confirm_knowledge_offline.py index 1cafc67..0607bc3 100644 --- a/tests/skills/确认知识草稿/test_confirm_knowledge_offline.py +++ b/tests/skills/确认知识草稿/test_confirm_knowledge_offline.py @@ -4,7 +4,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "sovereignty" / "确认知识草稿" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/规划下一章/test_contract.py b/tests/skills/规划下一章/test_contract.py index b44761f..91362b1 100644 --- a/tests/skills/规划下一章/test_contract.py +++ b/tests/skills/规划下一章/test_contract.py @@ -5,9 +5,8 @@ 该 schema(字段权威)声明的必填/推荐字段一致,防止两者再次漂移。 """ -from pathlib import Path import unittest - +from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[3] SKILL = ( diff --git a/tests/skills/记录机器味案例/test_capture_cases.py b/tests/skills/记录机器味案例/test_capture_cases.py index 4d56444..8561d44 100644 --- a/tests/skills/记录机器味案例/test_capture_cases.py +++ b/tests/skills/记录机器味案例/test_capture_cases.py @@ -14,26 +14,25 @@ SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "humanization" / sys.path.insert(0, str(SCRIPT_DIR)) import yaml - from capture_cases import ( - CaseCardError, CAPTURE_CLI_COMMANDS, LIFECYCLE_COMMANDS_FORBIDDEN, PATTERNS, + CaseCardError, + _parser, annotate_card, + build_case_card, + build_inventory, + build_revalidation_report, capture_feedback, capture_file, - build_inventory, - build_case_card, confirm_card, - project_sample, - build_revalidation_report, load_bundle, + main, + project_sample, revalidate_card, text_sha256, validate_card, - main, - _parser, ) diff --git a/tests/skills/记录运行证据/test_agent_trace.py b/tests/skills/记录运行证据/test_agent_trace.py index 3709072..ad164b9 100644 --- a/tests/skills/记录运行证据/test_agent_trace.py +++ b/tests/skills/记录运行证据/test_agent_trace.py @@ -11,13 +11,11 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记录运行证据" / "scripts" if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -import agent_trace # noqa: E402 from agent_trace import AgentTraceWriter, model_ids_match, persist_agent_evidence # noqa: E402 diff --git a/tests/skills/记录运行证据/test_lesson_registry_db.py b/tests/skills/记录运行证据/test_lesson_registry_db.py index d46fe34..a57411d 100644 --- a/tests/skills/记录运行证据/test_lesson_registry_db.py +++ b/tests/skills/记录运行证据/test_lesson_registry_db.py @@ -27,11 +27,16 @@ SCRIPT_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记 if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -from muse_db import connect # noqa: E402 -import lesson_registry # noqa: E402 from lesson_registry import ( # noqa: E402 - LessonError, list_lessons, promote, propose_lesson, propose_lesson_dedup, reject, start_review, + LessonError, + list_lessons, + promote, + propose_lesson, + propose_lesson_dedup, + reject, + start_review, ) +from muse_db import connect # noqa: E402 SUFFIX = uuid.uuid4().hex[:10] RUN_PREFIX = f"unittest-lesson-{SUFFIX}" diff --git a/tests/skills/记录运行证据/test_raw_vault.py b/tests/skills/记录运行证据/test_raw_vault.py index 8dc18cd..04923df 100644 --- a/tests/skills/记录运行证据/test_raw_vault.py +++ b/tests/skills/记录运行证据/test_raw_vault.py @@ -10,7 +10,7 @@ import stat import sys import tempfile import unittest -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest import mock PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -24,7 +24,7 @@ from raw_vault import RawVaultError, RawVaultManager # noqa: E402 def future_time(hours: int = 1) -> str: """生成不超过 24 小时的 UTC 保留截止时间。""" - return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat() + return (datetime.now(UTC) + timedelta(hours=hours)).isoformat() class RawVaultTest(unittest.TestCase): @@ -231,7 +231,7 @@ class RawVaultTest(unittest.TestCase): def test_create_rejects_insufficient_min_remaining_without_lease_or_vault(self): """声明的最低剩余租期不足时必须在落 lease / 建 vault 前稳定失败关闭。""" - short_retention = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat() + short_retention = (datetime.now(UTC) + timedelta(seconds=30)).isoformat() with self.assertRaises(RawVaultError) as raised: self.manager.create_vault( authorization_id="auth-min", @@ -267,7 +267,7 @@ class RawVaultTest(unittest.TestCase): def test_write_rejected_after_expiry_but_cleanup_still_works(self): """租约到期后 write 立即失败关闭,但 cleanup 仍能收敛过期 lease。""" - base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) clock = {"now": base} with mock.patch.object(raw_vault, "_utc_now", side_effect=lambda: clock["now"]): lease = self.manager.create_vault( @@ -292,7 +292,7 @@ class RawVaultTest(unittest.TestCase): def test_recover_cleans_expired_open_lease(self): """恢复扫描必须能收敛已过期的 open lease,不因过期而卡住清理。""" - base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) clock = {"now": base} with mock.patch.object(raw_vault, "_utc_now", side_effect=lambda: clock["now"]): lease = self.manager.create_vault( diff --git a/tests/skills/记录运行证据/test_record_failed_run.py b/tests/skills/记录运行证据/test_record_failed_run.py index 2627cef..cbd076f 100644 --- a/tests/skills/记录运行证据/test_record_failed_run.py +++ b/tests/skills/记录运行证据/test_record_failed_run.py @@ -4,7 +4,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记录运行证据" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/记录运行证据/test_repair_receipt_evidence.py b/tests/skills/记录运行证据/test_repair_receipt_evidence.py index 5322255..8263897 100644 --- a/tests/skills/记录运行证据/test_repair_receipt_evidence.py +++ b/tests/skills/记录运行证据/test_repair_receipt_evidence.py @@ -4,7 +4,6 @@ import pathlib import sys import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记录运行证据" / "scripts" sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/tests/skills/访问数据库/test_authorization_snapshot_ddl.py b/tests/skills/访问数据库/test_authorization_snapshot_ddl.py index 33d9a16..7104b83 100644 --- a/tests/skills/访问数据库/test_authorization_snapshot_ddl.py +++ b/tests/skills/访问数据库/test_authorization_snapshot_ddl.py @@ -5,7 +5,6 @@ import pathlib import re import unittest - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] DDL_PATH = PROJECT_ROOT / "muse" / "authority" / "db" / "ddl" / "96-example参考作品授权快照.sql" diff --git a/tests/skills/访问数据库/test_db_params.py b/tests/skills/访问数据库/test_db_params.py index d77b920..20d3820 100644 --- a/tests/skills/访问数据库/test_db_params.py +++ b/tests/skills/访问数据库/test_db_params.py @@ -13,7 +13,6 @@ SCRIPTS_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "访 sys.path.insert(0, str(SCRIPTS_DIR)) import click # noqa: E402 - import db # noqa: E402 diff --git a/tests/skills/评估内容质量/test_rubric.py b/tests/skills/评估内容质量/test_rubric.py index b39c184..7db869d 100644 --- a/tests/skills/评估内容质量/test_rubric.py +++ b/tests/skills/评估内容质量/test_rubric.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """细纲 rubric 的离线回归测试。""" -import pathlib import inspect +import pathlib import sys import unittest diff --git a/tests/skills/评估内容质量/test_run_writer_blind_judge.py b/tests/skills/评估内容质量/test_run_writer_blind_judge.py index 8cd1086..5289459 100644 --- a/tests/skills/评估内容质量/test_run_writer_blind_judge.py +++ b/tests/skills/评估内容质量/test_run_writer_blind_judge.py @@ -9,7 +9,8 @@ import json import pathlib import sys import unittest -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / "muse" / "lifecycle" / "quality" / "skills" / "judge" / "评估内容质量" / "scripts" diff --git a/tests/skills/评估内容质量/test_writer_rubric.py b/tests/skills/评估内容质量/test_writer_rubric.py index 9c7b8e6..f4f3e9f 100644 --- a/tests/skills/评估内容质量/test_writer_rubric.py +++ b/tests/skills/评估内容质量/test_writer_rubric.py @@ -15,8 +15,8 @@ if str(SCRIPT_DIR) not in sys.path: from writer_rubric import ( # noqa: E402 COMMON_DIMENSIONS, DIMENSIONS, - RUBRIC_PROFILE, RUBRIC_POLICY_VERSION, + RUBRIC_PROFILE, SCENARIO_DIMENSION, SCENARIO_TYPES, adjudicate_reviews, diff --git a/tests/skills/调用内容模型/test_call_persistence.py b/tests/skills/调用内容模型/test_call_persistence.py index f75472a..3c73d4d 100644 --- a/tests/skills/调用内容模型/test_call_persistence.py +++ b/tests/skills/调用内容模型/test_call_persistence.py @@ -9,7 +9,6 @@ import pathlib import sys import types - import muse_llm as llm llm.TOKEN = "test-token" diff --git a/tests/skills/重写选段/test_assert_expected_revision.py b/tests/skills/重写选段/test_assert_expected_revision.py index 09a6cd4..8c77702 100644 --- a/tests/skills/重写选段/test_assert_expected_revision.py +++ b/tests/skills/重写选段/test_assert_expected_revision.py @@ -8,7 +8,6 @@ SCRIPTS = PROJECT_ROOT / "muse" / "content" / "work" / "skills" / "generate" / " sys.path.insert(0, str(SCRIPTS)) import click # noqa: E402 - from assert_expected_revision import ( # noqa: E402 RevisionConflict, assert_expected_revision, diff --git a/tests/skills/重置作品抽取结果/test_reset_upgrade_work_offline.py b/tests/skills/重置作品抽取结果/test_reset_upgrade_work_offline.py index 966cf1b..1434ce8 100644 --- a/tests/skills/重置作品抽取结果/test_reset_upgrade_work_offline.py +++ b/tests/skills/重置作品抽取结果/test_reset_upgrade_work_offline.py @@ -10,7 +10,6 @@ from unittest.mock import Mock, patch from click.testing import CliRunner - PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SKILLS_DIR = PROJECT_ROOT / ".agent" / "skills" SCRIPT_DIR = PROJECT_ROOT / "muse" / "content" / "entity" / "skills" / "ingest" / "重置作品抽取结果" / "scripts" @@ -20,12 +19,11 @@ sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(BACKUP_SCRIPTS)) sys.path.insert(0, str(EXTRACTION_SCRIPTS)) -import upgrade as parse # noqa: E402 import backup_upgrade_work as backup # noqa: E402 import reset_upgrade_work as reset # noqa: E402 +import upgrade as parse # noqa: E402 from upgrade_work_lock import UpgradeWorkLockUnavailable # noqa: E402 - BACKUP_ID = "11111111-1111-4111-8111-111111111111" CONFIRMATION_SHA = "c" * 64 BACKUP_ARGS = [ diff --git a/tests/skills/预防机器味/test_prevent_ai_flavor.py b/tests/skills/预防机器味/test_prevent_ai_flavor.py index 0c55744..f456a8d 100644 --- a/tests/skills/预防机器味/test_prevent_ai_flavor.py +++ b/tests/skills/预防机器味/test_prevent_ai_flavor.py @@ -63,8 +63,9 @@ class PreventionContractTest(unittest.TestCase): def test_cli_default_persists_run(self): # 默认(非 --offline)模式生产读数据库规则库;离线测试补丁文件库接缝,不触真实连接。 import argparse # noqa: F401 (确认 CLI 依赖可导入) - import tempfile import json + import tempfile + from deai import load def _file_library(load_database): diff --git a/tests/skills/验证角色运行能力/test_refresh_runtime_probe.py b/tests/skills/验证角色运行能力/test_refresh_runtime_probe.py index 2a66cea..97aab63 100644 --- a/tests/skills/验证角色运行能力/test_refresh_runtime_probe.py +++ b/tests/skills/验证角色运行能力/test_refresh_runtime_probe.py @@ -3,13 +3,13 @@ from __future__ import annotations -import copy import json import pathlib import sys import tempfile import unittest -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from unittest import mock PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] @@ -22,25 +22,7 @@ for _import_dir in (SCRIPT_DIR, WRITER_REPLAY_DIR, GATE_ADJUDICATION_DIR): sys.path.insert(0, str(_import_dir)) import refresh_runtime_probe as refresh_module # noqa: E402 -from refresh_runtime_probe import ( # noqa: E402 - PROBE_BUSINESS_INPUT, - PROBE_SCHEMA_VERSION, - ProbeRefreshError, - apply_probe, - build_probe_record, - build_writer_profile, - make_dry_run_invoker, - refresh_runtime_probe, - verify_probe_result, -) -import run_writer_replay as replay_module # noqa: E402 -from run_writer_replay import ( # noqa: E402 - WriterReplayProductionAdapters, - _validate_execute_authorization, - profile_from_mapping, -) from gate_input_builder import ( # noqa: E402 - GateInputBuildError, _verify_self_hash, canonical_sha256, ) @@ -56,6 +38,20 @@ from muse_role import ( # noqa: E402 RoleRuntimeError, sha256_json, ) +from refresh_runtime_probe import ( # noqa: E402 + PROBE_BUSINESS_INPUT, + PROBE_SCHEMA_VERSION, + ProbeRefreshError, + build_writer_profile, + make_dry_run_invoker, + refresh_runtime_probe, + verify_probe_result, +) +from run_writer_replay import ( # noqa: E402 + WriterReplayProductionAdapters, + _validate_execute_authorization, + profile_from_mapping, +) CONFIG_PATH = WRITER_REPLAY_DIR.parent / "configs" / "writer-gate-a-deep-space-v1-cn-skills.json" CHECKED_AT = "2026-08-21T00:00:00+00:00" diff --git a/tests/单元/test_分章切窗与实体归并.py b/tests/单元/test_分章切窗与实体归并.py new file mode 100644 index 0000000..822343d --- /dev/null +++ b/tests/单元/test_分章切窗与实体归并.py @@ -0,0 +1,97 @@ +"""分章切窗与实体归并的确定性边界:覆盖、漂移、误并防护与样本观察陈述。""" + +import pytest + +from muse.资料研究.分章切窗 import 分章, 切窗, 漂移检查, 窗计划哈希, 覆盖核对 +from muse.资料研究.实体归并 import 归并, 收集实体 +from muse.资料研究.样本选题 import 样本比较, 选书交接单, 选题依据哈希 +from muse.资料研究.模型 import 资料错误 + + +def _文本(章数: int = 5) -> str: + return "".join(f"第{章}回 风起\n内容{'甲' * 20}\n" for 章 in range(1, 章数 + 1)) + + +def test_切窗覆盖与无重叠__18a01a(): + 文本 = _文本() + 窗口列表, 哈希 = 切窗(文本, 窗口章数=2) + assert [窗.窗ID for 窗 in 窗口列表] == ["w001", "w002", "w003"] + 统计 = 覆盖核对(文本, 窗口列表) + assert 统计["重叠码点"] == 0 + assert 统计["覆盖码点"] == len(文本) + assert 哈希 == 窗计划哈希(窗口列表) + + +def test_切窗漂移拒绝写入__18a02b(): + 文本 = _文本(3) + 窗口列表, _ = 切窗(文本, 窗口章数=2) + 篡改 = 文本.replace("甲" * 20, "乙" * 20) + with pytest.raises(资料错误, match="漂移"): + 漂移检查(篡改, 窗口列表[0]) + 漂移检查(文本, 窗口列表[0]) # 原文不变时放行 + + +def test_无章界整篇单窗__18a03c(): + 文本 = "没有章界的一段长文。" * 10 + 章 = 分章(文本) + assert len(章) == 1 + 窗口列表, _ = 切窗(文本) + assert len(窗口列表) == 1 + + +def test_同名异型歧义不误并__18a04d(): + 窗口结果 = [ + { + "window_id": "w001", + "entities": [ + {"name": "林深", "type": "人物", "evidence": ["林深把伞收在了门边"]}, + {"name": "青云镇", "type": "地点", "evidence": ["青云镇的雨"]}, + ], + }, + { + "window_id": "w002", + "entities": [ + {"name": "林深", "type": "地名", "aliases": ["林深镇"], "evidence": ["林深界碑"]}, + ], + }, + ] + 结果 = 归并(收集实体(窗口结果)) + 名组 = [g for g in 结果["合并组"] if "林深" in g["aliases"] or g["canonical"] == "林深"] + 歧义名 = [g["名"] for g in 结果["歧义"]] + assert "林深" in 歧义名, f"同名异型应保留歧义:{结果}" + assert not any(g["canonical"] == "林深" for g in 名组), "同名异型不得直接合并" + + +def test_同型别名带证据合并__18a05e(): + 窗口结果 = [ + { + "window_id": "w001", + "entities": [{"name": "林深", "type": "人物", "evidence": ["林深把伞收在了门边"]}], + }, + { + "window_id": "w002", + "entities": [ + {"name": "林深", "type": "人物", "aliases": ["老林"], "evidence": ["老林摇头"]} + ], + }, + ] + 结果 = 归并(收集实体(窗口结果)) + assert len(结果["合并组"]) == 1 + 组 = 结果["合并组"][0] + assert 组["canonical"] == "林深" and "老林" in 组["aliases"] + assert 组["windows"] == ["w001", "w002"] + assert len(组["evidence"]) == 2 + assert 结果["歧义"] == [] + + +def test_样本比较是观察陈述__18a06f(): + A = {"source_id": "s-a", "revision": 1, "content_hash": "h-a", "字数": 100, "章数": 3} + B = {"source_id": "s-b", "revision": 1, "content_hash": "h-b", "字数": 250, "章数": 7} + 差异 = 样本比较(A, B) + assert 差异["差异"]["字数"] == {"A": 100, "B": 250} + assert "因果" in 差异["说明"] + 交接 = 选书交接单(A, "题材接近,节奏可借鉴") + assert 交接["source_id"] == "s-a" and 交接["content_hash"] == "h-a" + assert 选题依据哈希(交接) + with pytest.raises(资料错误): + 选书交接单(A, " ") diff --git a/tests/集成/test_同作品抽取互斥.py b/tests/集成/test_同作品抽取互斥.py new file mode 100644 index 0000000..d0eaa1b --- /dev/null +++ b/tests/集成/test_同作品抽取互斥.py @@ -0,0 +1,55 @@ +"""同源拆书互斥:排他范围内的并发拆解被拒;任务终态释放范围后可再次发起。""" + +import pytest + +from muse.共享.错误 import Muse错误 + +pytestmark = pytest.mark.数据库 + + +def test_同源并发拆解被作用域互斥阻塞__18b01a(研究环境): + 工具 = 研究环境 + 来源 = 工具.导入来源( + "样章:风起", "第1回 风起\n内容甲。\n第2回 云涌\n内容乙。\n第3回 雨落\n内容丙。\n" + ) + 第一个 = 工具.发起(来源["source_id"]) + 第二个 = 工具.发起(来源["source_id"]) + assert 第二个["task_id"] != 第一个["task_id"] + # 互斥在领取层强制:持有第一任务的首步租约时,同源第二任务无可领取步骤。 + 能力 = 工具.环境["处理器能力"] + 运行 = 工具.装配.任务运行 + 首领 = 运行.领取步骤("worker", 能力) + assert 首领 is not None and 首领.任务ID == 第一个["task_id"], "应先领取第一任务" + assert 运行.领取步骤("worker2", 能力) is None, "同源第二任务必须被作用域互斥阻塞" + 运行.执行一步(首领) # 探针领取的首步(切窗核对,不调用模型)执行放行 + # 第一任务完成(合成宿主按剧本应答)后范围释放,第二任务可执行到完成。 + for 实体 in ( + {"name": "林深", "type": "人物", "evidence": ["风起"]}, + {"name": "青云镇", "type": "地点", "evidence": ["云涌"]}, + ): + 工具.剧本.append(工具.分析输出([实体])) + 快照 = 工具.跑任务(第一个["task_id"]) + assert 快照.状态.value == "completed", f"首个拆书应完成:{快照.状态}" + for 实体 in ( + {"name": "林深", "type": "人物", "evidence": ["风起"]}, + {"name": "青云镇", "type": "地点", "evidence": ["云涌"]}, + ): + 工具.剧本.append(工具.分析输出([实体])) + 第二快照 = 工具.跑任务(第二个["task_id"]) + assert 第二快照.状态.value == "completed", f"范围释放后第二任务应完成:{第二快照.状态}" + + +def test_失败任务释放范围可重建__18b02b(研究环境): + 工具 = 研究环境 + 来源 = 工具.导入来源( + "样章:雨落", "第1回 风起\n内容甲。\n第2回 云涌\n内容乙。\n第3回 雨落\n内容丙。\n" + ) + # 输出合同不满足:调用已结算但终态无效 → 步骤失败、任务失败(无未知调用)。 + 工具.剧本.append({"类型": "文本", "文本": {"chapters": []}}) + 任务 = 工具.发起(来源["source_id"]) + with pytest.raises((Muse错误, RuntimeError, OSError)): + 工具.跑任务(任务["task_id"]) + 快照 = 工具.装配.任务运行.读取任务(任务["task_id"]) + assert 快照.状态.value == "failed" + 重建 = 工具.发起(来源["source_id"]) + assert 重建["task_id"] != 任务["task_id"], "失败任务应已释放排他范围,允许重建" diff --git a/tests/集成/test_抽取版本与补偿.py b/tests/集成/test_抽取版本与补偿.py new file mode 100644 index 0000000..51aa4f2 --- /dev/null +++ b/tests/集成/test_抽取版本与补偿.py @@ -0,0 +1,100 @@ +"""抽取版本与补偿:批量原子登记、失败窗口断点续跑、版本不互抹与观察对照。""" + +import pytest + +from muse.任务运行.接口 import 任务状态 +from muse.共享.错误 import Muse错误 + +pytestmark = pytest.mark.数据库 + + +def _来源(工具, 标题: str, 章数: int = 4) -> dict: + 正文 = "".join(f"第{i}回 风\n内容{'甲' * 20}。\n" for i in range(1, 章数 + 1)) + return 工具.导入来源(标题, 正文) + + +def test_批量原子登记与版本身份__18b03c(研究环境): + 工具 = 研究环境 + 来源 = _来源(工具, "样章:原子") # 3 章 → 2 窗(窗口章数默认 3?默认 3 章 1 窗) + 任务 = 工具.发起(来源["source_id"], 理由="题材接近", 窗口章数=2) + 快照 = 工具.装配.任务运行.读取任务(任务["task_id"]) + 计划窗数 = len(快照.冻结输入["输入"]["窗计划"]) + for 序 in range(计划窗数): + 工具.剧本.append( + 工具.分析输出([{"name": f"实体{序}", "type": "人物", "evidence": ["内容甲"]}]) + ) + 终态 = 工具.跑任务(任务["task_id"]) + assert 终态.状态 == 任务状态.已完成 + 版本步 = next(s for s in 终态.步骤 if s["step_id"] == "分析版本登记") + 版本 = 版本步["checkpoint"]["版本"] + assert 版本["版本ID"] == 任务["版本ID"], "检查点版本身份应与发起预计算一致" + assert 版本["revision"] == 来源["revision"] + assert len(版本["完成窗"]) == 计划窗数 + assert 版本["样本依据"]["理由"] == "题材接近" + + +def test_失败窗口断点续跑只补缺失窗__18b04d(研究环境): + 工具 = 研究环境 + 来源 = _来源(工具, "样章:断点") + 任务 = 工具.发起(来源["source_id"]) + 快照 = 工具.装配.任务运行.读取任务(任务["task_id"]) + 计划 = 快照.冻结输入["输入"]["窗计划"] + assert len(计划) >= 2, "用例需要至少两个窗口" + # 首窗有效、次窗合同无效:窗口一结果经检查点持久化,任务失败停可解释断点。 + 工具.剧本.append(工具.分析输出([{"name": "首窗实体", "type": "人物", "evidence": ["内容甲"]}])) + 工具.剧本.append({"类型": "文本", "文本": {"chapters": []}}) + with pytest.raises((Muse错误, RuntimeError, OSError)): + 工具.跑任务(任务["task_id"]) + 失败态 = 工具.装配.任务运行.读取任务(任务["task_id"]) + assert 失败态.状态 == 任务状态.已失败 + 中间检查点 = next(s for s in 失败态.步骤 if s["step_id"] == "逐窗提取") + 已存窗 = set((中间检查点.get("checkpoint") or {}).get("窗口结果") or {}) + assert "w001" in 已存窗, "首窗成果应在失败前已持久化" + assert "w002" not in 已存窗, "失败窗不应伪称完成" + # 恢复:只补缺失窗;剧本只放一条,若重跑已存窗会因剧本耗尽拿到空对象而再次失败。 + 运行 = 工具.装配.任务运行 + 运行.控制任务( + 任务["task_id"], + "post-author", + 任务状态.已失败, + "恢复", + 命令ID=f"resume-{任务['task_id'][:8]}", + ) + 工具.剧本.append(工具.分析输出([{"name": "次窗实体", "type": "地点", "evidence": ["内容甲"]}])) + 终态 = 工具.跑任务(任务["task_id"]) + assert 终态.状态 == 任务状态.已完成 + 版本步 = next(s for s in 终态.步骤 if s["step_id"] == "分析版本登记") + 版本 = 版本步["checkpoint"]["版本"] + assert sorted(版本["完成窗"]) == sorted(窗["窗ID"] for 窗 in 计划) + + +def test_来源前进旧版本结论保留且新拆书指向新版本__18b05e(研究环境): + 工具 = 研究环境 + 来源 = _来源(工具, "样章:漂移") + 任务 = 工具.发起(来源["source_id"]) + 快照 = 工具.装配.任务运行.读取任务(任务["task_id"]) + for _ in 快照.冻结输入["输入"]["窗计划"]: + 工具.剧本.append( + 工具.分析输出([{"name": "旧版实体", "type": "人物", "evidence": ["内容甲"]}]) + ) + # 外部漂移:拆书进行中来源前进到 r2;r1 冻结分析照常完成,不被抹掉。 + 工具.导入来源("样章:漂移", "第1回 风\n内容换新版本。" * 3) + 终态 = 工具.跑任务(任务["task_id"]) + assert 终态.状态 == 任务状态.已完成 + 旧版本 = next(s for s in 终态.步骤 if s["step_id"] == "分析版本登记")["checkpoint"]["版本"] + assert 旧版本["revision"] == 1 and 旧版本["content_hash"] == 来源["content_hash"] + # 新拆书取最新版本:版本身份不同,旧结论仍可回查。 + 新任务 = 工具.发起(来源["source_id"]) + assert 新任务["版本ID"] != 旧版本["版本ID"] + 新快照 = 工具.装配.任务运行.读取任务(新任务["task_id"]) + assert 新快照.冻结输入["输入"]["拆书"]["revision"] == 2 + for _ in 新快照.冻结输入["输入"]["窗计划"]: + 工具.剧本.append( + 工具.分析输出([{"name": "新版实体", "type": "人物", "evidence": ["内容换新"]}]) + ) + assert 工具.跑任务(新任务["task_id"]).状态 == 任务状态.已完成 + from muse.资料研究.分析版本 import 版本台账 + + 台账 = 版本台账(工具.装配.任务运行.列出任务("post-author")) + 本源 = [行 for 行 in 台账 if 行["source_id"] == 来源["source_id"]] + assert {行["版本ID"] for 行 in 本源} == {旧版本["版本ID"], 新任务["版本ID"]} diff --git a/web/src/功能/资料研究/index.ts b/web/src/功能/资料研究/index.ts new file mode 100644 index 0000000..4dd7556 --- /dev/null +++ b/web/src/功能/资料研究/index.ts @@ -0,0 +1,5 @@ +/** 资料研究页面出口:资料页、拆书工作区与榜单选题页。 */ +export { Ui资料页 } from "./资料页"; +export { Ui拆书工作区 } from "./拆书工作区"; +export { Ui榜单与选题页 } from "./榜单与选题页"; +export { Ui原文分析对照 } from "./原文分析对照"; diff --git a/web/src/功能/资料研究/原文分析对照.tsx b/web/src/功能/资料研究/原文分析对照.tsx new file mode 100644 index 0000000..192beb4 --- /dev/null +++ b/web/src/功能/资料研究/原文分析对照.tsx @@ -0,0 +1,48 @@ +/** 原文分析对照:窗口原文与逐窗拆书结果并排呈现;失败与歧义如实显示。 */ +import { useMuse版本原文 } from "./数据"; +import { Ui反馈状态 } from "../../界面/反馈状态"; + +export interface 对照窗口 { + 窗ID: string; + 起点: number; + 终点: number; + 实体数: number; + 写法数: number; + 歧义: { 名: string; 类型冲突: string[] }[]; +} + +export function Ui原文分析对照({ + scope, + sourceId, + revision, + 窗口列表, +}: { + scope: string | null; + sourceId: string; + revision: number; + 窗口列表: 对照窗口[]; +}) { + const 版本 = useMuse版本原文(scope, sourceId, revision); + if (版本.isPending) return ; + if (版本.error || !版本.data) return ; + const 原文: string = 版本.data.content; + if (窗口列表.length === 0) return

还没有可对照的窗口结果。

; + return ( +
+ {窗口列表.map((窗) => ( +
+

+ 窗口 {窗.窗ID} · 实体 {窗.实体数} · 写法 {窗.写法数} +

+
{原文.slice(窗.起点, 窗.终点)}
+ {窗.歧义.length > 0 && ( +

+ 歧义待核: + {窗.歧义.map((项) => `${项.名}(${项.类型冲突.join("/")})`).join("、")} +

+ )} +
+ ))} +
+ ); +} diff --git a/web/src/功能/资料研究/拆书工作区.tsx b/web/src/功能/资料研究/拆书工作区.tsx new file mode 100644 index 0000000..db09975 --- /dev/null +++ b/web/src/功能/资料研究/拆书工作区.tsx @@ -0,0 +1,166 @@ +/** 拆书工作区:发起来源拆解、窗口进度与失败恢复;互斥与漂移错误如实显示。 */ +import { useState } from "react"; +import { useMuse来源列表, useMuse拆书状态, 发起拆书, 恢复拆书 } from "./数据"; +import { Ui反馈状态 } from "../../界面/反馈状态"; +import { useMuse作者会话 } from "../../应用/作者会话"; +import { Ui原文分析对照, type 对照窗口 } from "./原文分析对照"; + +export function Ui拆书工作区() { + const 会话 = useMuse作者会话(); + const scope = 会话.data ? "session" : null; + const 来源 = useMuse来源列表(scope); + const [当前, set当前] = useState(null); + const 状态 = useMuse拆书状态(scope, 当前); + const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(null); + + async function 提交(form: HTMLFormElement) { + const 值 = new FormData(form); + try { + const 回执 = await 发起拆书(当前!, { + config_id: (值.get("config_id") as string) || null, + reason: (值.get("reason") as string) || null, + }); + set消息({ 文本: `拆书任务已登记:${回执.task_id}` }); + await 状态.refetch(); + } catch (e) { + set消息({ 文本: e instanceof Error ? e.message : "发起失败,可重试。", 错误: true }); + } + } + + async function 恢复(taskId: string) { + try { + await 恢复拆书(taskId); + set消息({ 文本: "恢复已登记;任务将在工作循环继续。" }); + await 状态.refetch(); + } catch (e) { + set消息({ 文本: e instanceof Error ? e.message : "恢复失败。", 错误: true }); + } + } + + const 对照 = 状态.data?.对照 ?? null; + const 窗口列表: 对照窗口[] = 对照 + ? Object.entries(对照.完成窗).map(([窗ID, 计数]) => ({ + 窗ID, + 起点: 0, + 终点: 0, + 实体数: 计数.实体数, + 写法数: 计数.写法数, + 歧义: 对照.歧义, + })) + : []; + + return ( +
+

工作台

+

拆书工作区

+

+ 长资料分窗分析:同源不可并发拆解;输入漂移拒绝写入旧结果;失败窗口可单独重试。 +

+ +
+ {来源.isPending ? ( + + ) : (来源.data?.length ?? 0) === 0 ? ( +

还没有可拆解的来源;先在资料页导入。

+ ) : ( +
    + {来源.data!.map((源) => ( +
  • + +
  • + ))} +
+ )} +
+ + {当前 && ( + <> +
+
+

发起拆解

+ 同源互斥;授权用途需包含 analysis +
+
{ + e.preventDefault(); + void 提交(e.currentTarget); + }} + > + + + +
+ {消息 && ( +

+ {消息.文本} +

+ )} +
+ +
+
+

拆书台账

+ {状态.data?.台账.length ?? 0} 次拆解 +
+ {状态.isPending ? ( + + ) : (状态.data?.台账.length ?? 0) === 0 ? ( +

该来源还没有拆书任务。

+ ) : ( +
    + {状态.data!.台账.map((行) => ( +
  • + {行.task_id.slice(0, 8)} · {行.状态} · 完成{" "} + {行.完成窗} 窗 + {行.失败窗.length > 0 && ` · 失败 ${行.失败窗.join("、")}`} + {行.歧义数 > 0 && ` · 歧义 ${行.歧义数}`} + {行.状态 === "失败" && ( + + )} +
  • + ))} +
+ )} + {状态.error && } +
+ + {对照 && ( +
+
+

原文与分析对照

+ {对照.状态 === "已完成" ? "任务已完成" : `状态:${对照.状态}`} +
+ {对照.版本ID ? ( + + ) : ( +

任务尚未完成;完成后此处显示窗口原文与分析对照。

+ )} +
+ )} + + )} +
+ ); +} diff --git a/web/src/功能/资料研究/数据.ts b/web/src/功能/资料研究/数据.ts index 1eac656..311625a 100644 --- a/web/src/功能/资料研究/数据.ts +++ b/web/src/功能/资料研究/数据.ts @@ -1,4 +1,4 @@ -/** 资料研究数据接入:来源列表、版本原文、清理候选与榜单快照。 */ +/** 资料研究数据接入:来源列表、版本原文、清理候选、榜单快照与拆书分析。 */ import { useQuery } from "@tanstack/react-query"; import { 客户端 } from "../../接口/生成/客户端"; import { 查询键 } from "../../接口/查询键"; @@ -60,3 +60,70 @@ export function useMuse快照列表(scope: string | null, rankingId: string | nu } + + +export interface 拆书台账行 { + task_id: string; + 状态: string; + source_id: string | null; + revision: number | null; + 窗计划哈希: string | null; + 版本ID: string | null; + 完成窗: number; + 失败窗: string[]; + 歧义数: number; +} + +export interface 拆书对照 { + task_id: string; + 状态: string; + 版本ID: string | null; + 完成窗: Record; + 失败窗: string[]; + 歧义: { 名: string; 类型冲突: string[]; 出现窗: string[]; 处理: string }[]; + 样本依据: { 理由?: string } | null; +} + +export interface 拆书状态 { + 台账: 拆书台账行[]; + 对照: 拆书对照 | null; + 说明?: string; +} + +export function useMuse拆书状态(scope: string | null, sourceId: string | null) { + return useQuery({ + queryKey: 查询键(scope, sourceId ?? "", "analyses"), + enabled: !!sourceId, + queryFn: async ({ signal }) => + ((await 客户端.GET("/api/v1/sources/{source_id}/analyses", { + signal, + params: { path: { source_id: sourceId! } }, + })).data ?? null) as 拆书状态 | null, + }); +} + +export async function 发起拆书( + sourceId: string, + 输入: { config_id?: string | null; reason?: string | null }, +) { + const { 客户端 } = await import("../../接口/生成/客户端"); + const { data, error } = await 客户端.POST("/api/v1/sources/{source_id}/analyses", { + params: { path: { source_id: sourceId } }, + body: { + command_id: `research-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + config_id: 输入.config_id ?? undefined, + reason: 输入.reason ?? undefined, + }, + }); + if (!data) throw new Error((error as { message?: string })?.message ?? "发起失败"); + return data as unknown as { task_id: string; 版本ID: string }; +} + +export async function 恢复拆书(taskId: string) { + const { 客户端 } = await import("../../接口/生成/客户端"); + const { data, error } = await 客户端.POST("/api/v1/analyses/{task_id}/resume", { + params: { path: { task_id: taskId } }, + }); + if (!data) throw new Error((error as { message?: string })?.message ?? "恢复失败"); + return data as unknown as { task_id: string; 状态: string }; +} diff --git a/web/src/功能/资料研究/榜单与选题页.tsx b/web/src/功能/资料研究/榜单与选题页.tsx new file mode 100644 index 0000000..8cdec88 --- /dev/null +++ b/web/src/功能/资料研究/榜单与选题页.tsx @@ -0,0 +1,145 @@ +/** 榜单与选题页:时点快照观察与样本选题依据;只陈述观察,不推断因果。 */ +import { useState } from "react"; +import { useMuse来源列表, useMuse快照列表, useMuse版本原文, 发起拆书 } from "./数据"; +import { Ui反馈状态 } from "../../界面/反馈状态"; +import { useMuse作者会话 } from "../../应用/作者会话"; + +export function Ui榜单与选题页() { + const 会话 = useMuse作者会话(); + const scope = 会话.data ? "session" : null; + const 来源 = useMuse来源列表(scope); + const [ranking, setRanking] = useState(null); + const 快照 = useMuse快照列表(scope, ranking); + const [样本A, set样本A] = useState(null); + const [样本B, set样本B] = useState(null); + const 版本A = useMuse版本原文(scope, 样本A, 最新版本(来源.data, 样本A)); + const 版本B = useMuse版本原文(scope, 样本B, 最新版本(来源.data, 样本B)); + const [消息, set消息] = useState<{ 文本: string; 错误?: boolean } | null>(null); + + async function 选书发起(form: HTMLFormElement) { + const 理由 = String(new FormData(form).get("reason") ?? "").trim(); + const 目标 = 样本A ?? 样本B; + if (!目标 || !理由) { + set消息({ 文本: "选定样本并写明理由后再发起。", 错误: true }); + return; + } + try { + const 回执 = await 发起拆书(目标, { reason: 理由 }); + set消息({ 文本: `已按选书理由登记拆书任务:${回执.task_id}` }); + } catch (e) { + set消息({ 文本: e instanceof Error ? e.message : "发起失败。", 错误: true }); + } + } + + return ( +
+

工作台

+

榜单与选题

+

+ 榜单是时点观察,失败如实显示;样本比较只陈述可复核指标,不构成优劣结论或效果因果。 +

+ +
+
+

榜单快照

+ 时点观察,不推断效果因果 +
+
{ + e.preventDefault(); + const 值 = new FormData(e.currentTarget).get("ranking_id"); + setRanking(typeof 值 === "string" && 值 ? 值 : null); + }} + > + + +
+ {ranking && + (快照.isPending ? ( + + ) : (快照.data?.length ?? 0) === 0 ? ( +

该榜单还没有快照。

+ ) : ( +
    + {快照.data!.map((条) => ( +
  1. + {条.status === "failed" ? ( + 采集失败:{条.failure_reason ?? "原因未记录"} + ) : ( + + {(条.items ?? []).length} 条 · {条.captured_at} + + )} +
  2. + ))} +
+ ))} +
+ +
+
+

样本选题

+ 比较可复核字数;选书理由写入拆书依据 +
+
{ + e.preventDefault(); + const 值 = new FormData(e.currentTarget); + set样本A((值.get("sample_a") as string) || null); + set样本B((值.get("sample_b") as string) || null); + }} + > + + + +
+ {(样本A || 样本B) && ( +
+ {版本A.isFetching || 版本B.isFetching ? ( + + ) : ( +
    +
  • 样本 A:{样本A ?? "未选"} · 字数 {版本A.data ? 版本A.data.content.length : "—"}
  • +
  • 样本 B:{样本B ?? "未选"} · 字数 {版本B.data ? 版本B.data.content.length : "—"}
  • +
+ )} +
{ + e.preventDefault(); + void 选书发起(e.currentTarget); + }} + > + + +
+
+ )} + {消息 && ( +

+ {消息.文本} +

+ )} +
+
+ ); +} + +function 最新版本(来源列表: { source_id: string; 最新版本: number }[] | undefined, id: string | null) { + if (!id || !来源列表) return null; + return 来源列表.find((源) => 源.source_id === id)?.最新版本 ?? null; +} diff --git a/web/src/功能/资料研究/资料对照页.tsx b/web/src/功能/资料研究/资料页.tsx similarity index 68% rename from web/src/功能/资料研究/资料对照页.tsx rename to web/src/功能/资料研究/资料页.tsx index 7e7fa2e..db7f2cd 100644 --- a/web/src/功能/资料研究/资料对照页.tsx +++ b/web/src/功能/资料研究/资料页.tsx @@ -1,17 +1,15 @@ /** 资料对照页:来源版本原文、清理候选对照与榜单快照(含失败态)。 */ import { useState } from "react"; -import { useMuse来源列表, useMuse版本原文, useMuse快照列表 } from "./数据"; +import { useMuse来源列表, useMuse版本原文 } from "./数据"; import { Ui反馈状态 } from "../../界面/反馈状态"; import { useMuse作者会话 } from "../../应用/作者会话"; -export function Ui资料对照页() { +export function Ui资料页() { const 会话 = useMuse作者会话(); const scope = 会话.data ? "session" : null; const 来源 = useMuse来源列表(scope); const [当前, set当前] = useState<{ id: string; revision: number } | null>(null); - const [ranking, setRanking] = useState(null); const 版本 = useMuse版本原文(scope, 当前?.id ?? null, 当前?.revision ?? null); - const 快照 = useMuse快照列表(scope, ranking); const [消息, set消息] = useState(""); async function 生成并应用(sourceId: string, revision: number) { @@ -41,7 +39,8 @@ export function Ui资料对照页() {

工作台

资料研究

- 来源与原文对照;清理只生成对照结果,原文版本不可改写。榜单快照是时点观察,失败如实显示。 + 来源与原文对照;清理只生成对照结果,原文版本不可改写。榜单与拆书在 + 研究工作区与榜单与选题。

@@ -101,48 +100,6 @@ export function Ui资料对照页() {
)} -
-
-

榜单快照

- 时点观察,不推断效果因果 -
-
{ - e.preventDefault(); - const 值 = new FormData(e.currentTarget).get("ranking_id"); - setRanking(typeof 值 === "string" && 值 ? 值 : null); - }} - > - - -
- {ranking && - (快照.isPending ? ( - - ) : (快照.data?.length ?? 0) === 0 ? ( -

该榜单还没有快照。

- ) : ( -
    - {快照.data!.map((条) => ( -
  1. - {条.status === "failed" ? ( - - 采集失败:{条.failure_reason ?? "原因未记录"} - - ) : ( - - {(条.items ?? []).length} 条 · {条.captured_at} - - )} -
  2. - ))} -
- ))} -
); } diff --git a/web/src/应用/路由.tsx b/web/src/应用/路由.tsx index 8b7fe67..b13eaec 100644 --- a/web/src/应用/路由.tsx +++ b/web/src/应用/路由.tsx @@ -8,7 +8,7 @@ import { Ui规划工作区, } from "../功能/作品规划"; import { Ui候选审阅页, Ui正文页 } from "../功能/正文写作"; -import { Ui资料对照页 } from "../功能/资料研究/资料对照页"; +import { Ui资料页, Ui拆书工作区, Ui榜单与选题页 } from "../功能/资料研究"; import { 客户端 } from "../接口/生成/客户端"; export function Ui页面路由() { @@ -24,7 +24,9 @@ export function Ui页面路由() { element={} /> } /> - } /> + } /> + } /> + } /> } /> } /> diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index 710e8e7..028aaf7 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -862,6 +862,41 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/sources/{source_id}/analyses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 列出拆书 */ + get: operations["list_source_analyses"]; + put?: never; + /** 发起拆书 */ + post: operations["start_source_analysis"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analyses/{task_id}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 恢复拆书 */ + post: operations["resume_source_analysis"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/chapters/{chapter_id}/writing-candidates": { parameters: { query?: never; @@ -2617,6 +2652,21 @@ export interface components { */ valid_until: string; }; + /** 审校报告呈现 */ + muse______http____________________10: { + /** Report Id */ + report_id: string; + /** Candidate Id */ + candidate_id: string; + /** Candidate Revision */ + candidate_revision: number; + /** Snapshot Id */ + snapshot_id: string; + basic: components["schemas"]["muse______http__________________3"]; + continuity: components["schemas"]["muse______http___________________5"]; + /** Verdict */ + verdict: string; + }; /** 原文授权请求 */ muse______http____________________2: { /** Command Id */ @@ -2721,28 +2771,26 @@ export interface components { */ method: string; }; - /** 发起生成回执 */ + /** 发起拆书请求 */ muse______http____________________8: { + /** Command Id */ + command_id: string; + /** Config Id */ + config_id?: string | null; + /** Reason */ + reason?: string | null; + /** Retry Windows */ + retry_windows?: string[] | null; + /** Window Chapters */ + window_chapters?: number | null; + }; + /** 发起生成回执 */ + muse______http____________________9: { /** Task Id */ task_id: string; /** Writing Task Id */ writing_task_id: string; }; - /** 审校报告呈现 */ - muse______http____________________9: { - /** Report Id */ - report_id: string; - /** Candidate Id */ - candidate_id: string; - /** Candidate Revision */ - candidate_revision: number; - /** Snapshot Id */ - snapshot_id: string; - basic: components["schemas"]["muse______http__________________3"]; - continuity: components["schemas"]["muse______http___________________5"]; - /** Verdict */ - verdict: string; - }; }; responses: never; parameters: never; @@ -4316,7 +4364,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["muse______http____________________8"]; + "application/json": components["schemas"]["muse______http____________________9"]; }; }; /** @description Validation Error */ @@ -4349,7 +4397,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["muse______http____________________9"]; + "application/json": components["schemas"]["muse______http____________________10"]; }; }; /** @description Validation Error */ @@ -4691,6 +4739,109 @@ export interface operations { }; }; }; + list_source_analyses: { + parameters: { + query?: never; + header?: never; + path: { + source_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + start_source_analysis: { + parameters: { + query?: never; + header?: never; + path: { + source_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["muse______http____________________8"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + resume_source_analysis: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_writing_candidates: { parameters: { query?: { diff --git a/web/src/界面/排版.css b/web/src/界面/排版.css index b26fb88..ebaaba7 100644 --- a/web/src/界面/排版.css +++ b/web/src/界面/排版.css @@ -434,3 +434,20 @@ color: var(--ink-soft, #7a6f5d); overflow-wrap: anywhere; } + +/* 结构化表单统一纵排:标签与输入框各自成行,避免横向挤压遮挡。 */ +.structure-form { + display: grid; + gap: 1rem; + justify-items: start; + margin: 0.8rem 0; +} +.structure-form label { + display: grid; + gap: 0.55rem; + font-weight: 600; + width: min(360px, 100%); +} +.structure-form input { + width: 100%; +} diff --git a/web/tests/旅程/资料对照.spec.ts b/web/tests/旅程/资料对照.spec.ts index 63dc881..3d6583d 100644 --- a/web/tests/旅程/资料对照.spec.ts +++ b/web/tests/旅程/资料对照.spec.ts @@ -30,14 +30,46 @@ test("NC-browser-source-workbench:资料对照页与榜单失败态", async ({ await expect(对照.getByText("清理已应用为对照版本;原文保持不变。")).toBeVisible(); await expect(对照.getByText("最新章节请访问 example.com")).toBeVisible(); - // 榜单快照:失败态如实呈现,成功快照带条目数。 + // 榜单快照(W18 起独立页面):失败态如实呈现,成功快照带条目数。 + await page.goto("/rankings"); await page.getByLabel("榜单编号").fill("rank-demo"); await page.getByRole("button", { name: "查看快照" }).click(); const 榜单 = page.locator('section[aria-label="榜单快照"]'); await expect(榜单.getByText(/采集失败:上游 503/)).toBeVisible(); await expect(榜单.getByText(/2 条 · /).first()).toBeVisible(); - await page.screenshot({ path: `${info.outputDir}/资料对照页.png`, fullPage: true }); + await page.screenshot({ path: `${info.outputDir}/榜单与选题页.png`, fullPage: true }); + + expect( + consoleErrors.filter((文本) => !文本.includes("favicon")), + "页面不得出现控制台错误", + ).toEqual([]); +}); + + +test("NC-browser-research-workbench:拆书工作区发起与台账呈现", async ({ page }, info) => { + if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE) + throw new Error("需要明确的隔离后端和作者口令文件。"); + const sourceId = process.env.MUSE_SOURCE_ID!; + if (!sourceId) throw new Error("需要预置来源编号。"); + const consoleErrors: string[] = []; + page.on("pageerror", (error) => consoleErrors.push(String(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.goto("/research"); + + await page.getByRole("button", { name: /样章:码头来信/ }).click(); + await page.getByLabel("选书理由(写入拆书依据)").fill("题材接近,节奏可借鉴"); + await page.getByRole("button", { name: "发起拆解" }).click(); + // 部署是否装配研究流程都如实呈现:成功给任务编号,未装配给明确错误;不伪造进度。 + await expect(page.locator('p[role="status"]')).toBeVisible({ timeout: 15_000 }); + const 台账 = page.locator('section[aria-label="拆书台账"]'); + await expect(台账.getByText(/次拆解/)).toBeVisible(); + await page.screenshot({ path: `${info.outputDir}/拆书工作区.png`, fullPage: true }); expect( consoleErrors.filter((文本) => !文本.includes("favicon")), diff --git a/配置/流程模板/研究拆书.yaml b/配置/流程模板/研究拆书.yaml new file mode 100644 index 0000000..3d8ea6b --- /dev/null +++ b/配置/流程模板/研究拆书.yaml @@ -0,0 +1,23 @@ +flow_id: 研究拆书 +version: "1" +steps: + - step_id: 切窗核对 + processor: rs.plan + processor_version: "1" + - step_id: 绑定分析配置 + processor: rs.config + processor_version: "1" + depends_on: [切窗核对] + - step_id: 逐窗提取 + processor: rs.extract + processor_version: "1" + depends_on: [绑定分析配置] + role: extractor + - step_id: 实体归并 + processor: rs.merge + processor_version: "1" + depends_on: [逐窗提取] + - step_id: 分析版本登记 + processor: rs.version + processor_version: "1" + depends_on: [实体归并]