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
+
+
+ {消息 && (
+
+ {消息.文本}
+
+ )}
+
+
+
+
+
拆书台账
+ {状态.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 (
+
+ 工作台
+ 榜单与选题
+
+ 榜单是时点观察,失败如实显示;样本比较只陈述可复核指标,不构成优劣结论或效果因果。
+
+
+
+
+
榜单快照
+ 时点观察,不推断效果因果
+
+
+ {ranking &&
+ (快照.isPending ? (
+
+ ) : (快照.data?.length ?? 0) === 0 ? (
+ 该榜单还没有快照。
+ ) : (
+
+ {快照.data!.map((条) => (
+ -
+ {条.status === "failed" ? (
+ 采集失败:{条.failure_reason ?? "原因未记录"}
+ ) : (
+
+ {(条.items ?? []).length} 条 · {条.captured_at}
+
+ )}
+
+ ))}
+
+ ))}
+
+
+
+
+
样本选题
+ 比较可复核字数;选书理由写入拆书依据
+
+
+ {(样本A || 样本B) && (
+
+ {版本A.isFetching || 版本B.isFetching ? (
+
+ ) : (
+
+ - 样本 A:{样本A ?? "未选"} · 字数 {版本A.data ? 版本A.data.content.length : "—"}
+ - 样本 B:{样本B ?? "未选"} · 字数 {版本B.data ? 版本B.data.content.length : "—"}
+
+ )}
+
+
+ )}
+ {消息 && (
+
+ {消息.文本}
+
+ )}
+
+
+ );
+}
+
+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资料对照页() {
)}
-
-
-
榜单快照
- 时点观察,不推断效果因果
-
-
- {ranking &&
- (快照.isPending ? (
-
- ) : (快照.data?.length ?? 0) === 0 ? (
- 该榜单还没有快照。
- ) : (
-
- {快照.data!.map((条) => (
- -
- {条.status === "failed" ? (
-
- 采集失败:{条.failure_reason ?? "原因未记录"}
-
- ) : (
-
- {(条.items ?? []).length} 条 · {条.captured_at}
-
- )}
-
- ))}
-
- ))}
-
);
}
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: [实体归并]