(null);
+ const 版本 = useMuse版本原文(scope, 当前?.id ?? null, 当前?.revision ?? null);
+ const 快照 = useMuse快照列表(scope, ranking);
+ const [消息, set消息] = useState("");
+
+ async function 生成并应用(sourceId: string, revision: number) {
+ try {
+ const { data, error } = await (await import("../../接口/生成/客户端")).客户端.POST(
+ "/api/v1/sources/{source_id}/versions/{revision}/clean-candidates",
+ { params: { path: { source_id: sourceId, revision } } },
+ );
+ if (!data) throw new Error((error as { message?: string })?.message ?? "生成失败");
+ const apply = await (await import("../../接口/生成/客户端")).客户端.POST(
+ "/api/v1/clean-candidates/{candidate_id}/apply",
+ { params: { path: { candidate_id: (data as { candidate_id: string }).candidate_id } } },
+ );
+ set消息(
+ apply.data
+ ? "清理已应用为对照版本;原文保持不变。"
+ : "清理候选已生成,但应用失败,可重试。",
+ );
+ set当前({ id: sourceId, revision });
+ } catch (e) {
+ set消息(e instanceof Error ? e.message : "清理流程失败,可重试。");
+ }
+ }
+
+ return (
+
+ 工作台
+ 资料研究
+
+ 来源与原文对照;清理只生成对照结果,原文版本不可改写。榜单快照是时点观察,失败如实显示。
+
+
+
+
来源清单
+ {来源.data?.length ?? 0} 个来源
+
+ {来源.isPending ? (
+
+ ) : (来源.data?.length ?? 0) === 0 ? (
+ 还没有导入来源;经 CLI 或主会话导入后这里可见。
+ ) : (
+
+ {来源.data!.map((源) => (
+ -
+
+ {源.origin}
+
+ ))}
+
+ )}
+ {来源.error && }
+
+
+ {当前 && (
+
+
+
原文与清理对照
+
+ r{当前.revision}
+
+
+ {版本.isPending ? (
+
+ ) : 版本.error ? (
+
+ ) : (
+ <>
+
+ {版本.data?.content}
+
+
+ {消息 && (
+ {消息}
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
榜单快照
+ 时点观察,不推断效果因果
+
+
+ {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 0694587..982148e 100644
--- a/web/src/应用/工作台布局.tsx
+++ b/web/src/应用/工作台布局.tsx
@@ -37,8 +37,11 @@ export function Ui工作台布局({ children }: PropsWithChildren) {
任务记录02
+
+ 资料研究03
+
- 连接状态03
+ 连接状态04
diff --git a/web/src/应用/路由.tsx b/web/src/应用/路由.tsx
index 03f84bb..8b7fe67 100644
--- a/web/src/应用/路由.tsx
+++ b/web/src/应用/路由.tsx
@@ -8,6 +8,7 @@ import {
Ui规划工作区,
} from "../功能/作品规划";
import { Ui候选审阅页, Ui正文页 } from "../功能/正文写作";
+import { Ui资料对照页 } from "../功能/资料研究/资料对照页";
import { 客户端 } from "../接口/生成/客户端";
export function Ui页面路由() {
@@ -23,6 +24,7 @@ export function Ui页面路由() {
element={}
/>
} />
+ } />
} />
} />
diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts
index e0d610b..710e8e7 100644
--- a/web/src/接口/生成/类型.ts
+++ b/web/src/接口/生成/类型.ts
@@ -724,6 +724,144 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/sources/import": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 导入来源 */
+ post: operations["import_source"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/sources": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 列出来源 */
+ get: operations["list_sources"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/sources/{source_id}/versions/{revision}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 读取版本 */
+ get: operations["read_source_version"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/sources/{source_id}/versions/{revision}/clean-candidates": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 列出清理候选 */
+ get: operations["list_clean_candidates"];
+ put?: never;
+ /** 生成清理候选 */
+ post: operations["create_clean_candidate"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/clean-candidates/{candidate_id}/apply": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 应用清理候选 */
+ post: operations["apply_clean_candidate"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/clean-candidates/{candidate_id}/reject": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 拒绝清理候选 */
+ post: operations["reject_clean_candidate"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/rankings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 登记榜单 */
+ post: operations["register_ranking"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/rankings/{ranking_id}/snapshots": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 列出快照 */
+ get: operations["list_ranking_snapshots"];
+ put?: never;
+ /** 采集快照 */
+ post: operations["capture_ranking_snapshot"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/chapters/{chapter_id}/writing-candidates": {
parameters: {
query?: never;
@@ -2550,15 +2688,48 @@ export interface components {
[key: string]: number;
} | null;
};
- /** 发起生成回执 */
+ /** 导入来源请求 */
muse______http____________________6: {
+ /**
+ * Kind
+ * @enum {string}
+ */
+ kind: "author_draft" | "reference" | "web" | "note" | "inspiration";
+ /** Title */
+ title: string;
+ /** Origin */
+ origin: string;
+ /** Content */
+ content: string;
+ /**
+ * Authorized Uses
+ * @default []
+ */
+ authorized_uses: string[];
+ };
+ /** 登记榜单请求 */
+ muse______http____________________7: {
+ /** Ranking Id */
+ ranking_id: string;
+ /** Name */
+ name: string;
+ /** Url */
+ url: string;
+ /**
+ * Method
+ * @default html
+ */
+ method: string;
+ };
+ /** 发起生成回执 */
+ muse______http____________________8: {
/** Task Id */
task_id: string;
/** Writing Task Id */
writing_task_id: string;
};
/** 审校报告呈现 */
- muse______http____________________7: {
+ muse______http____________________9: {
/** Report Id */
report_id: string;
/** Candidate Id */
@@ -4145,7 +4316,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["muse______http____________________6"];
+ "application/json": components["schemas"]["muse______http____________________8"];
};
};
/** @description Validation Error */
@@ -4178,7 +4349,335 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["muse______http____________________7"];
+ "application/json": components["schemas"]["muse______http____________________9"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ import_source: {
+ parameters: {
+ query: {
+ work_id: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["muse______http____________________6"];
+ };
+ };
+ 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"];
+ };
+ };
+ };
+ };
+ list_sources: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
+ }[];
+ };
+ };
+ };
+ };
+ read_source_version: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ source_id: string;
+ revision: number;
+ };
+ 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_clean_candidates: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ source_id: string;
+ revision: number;
+ };
+ 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"];
+ };
+ };
+ };
+ };
+ create_clean_candidate: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ source_id: string;
+ revision: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ 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"];
+ };
+ };
+ };
+ };
+ apply_clean_candidate: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ candidate_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"];
+ };
+ };
+ };
+ };
+ reject_clean_candidate: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ candidate_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"];
+ };
+ };
+ };
+ };
+ register_ranking: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["muse______http____________________7"];
+ };
+ };
+ 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"];
+ };
+ };
+ };
+ };
+ list_ranking_snapshots: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ ranking_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"];
+ };
+ };
+ };
+ };
+ capture_ranking_snapshot: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ ranking_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
+ };
};
};
/** @description Validation Error */
diff --git a/web/tests/旅程/资料对照.spec.ts b/web/tests/旅程/资料对照.spec.ts
new file mode 100644
index 0000000..63dc881
--- /dev/null
+++ b/web/tests/旅程/资料对照.spec.ts
@@ -0,0 +1,46 @@
+/** 真实浏览器与隔离库:资料对照页的来源清单、原文对照、清理应用与榜单快照失败态。 */
+import { readFileSync } from "node:fs";
+import { expect, test } from "@playwright/test";
+
+test.use({ trace: "off" });
+
+test("NC-browser-source-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.getByRole("link", { name: /资料研究/ }).click();
+ // 来源清单可见并点开预置来源。
+ const 清单 = page.locator('section[aria-label="来源清单"]');
+ await expect(清单.getByText("样章:码头来信")).toBeVisible();
+ await 清单.getByRole("button", { name: /样章:码头来信/ }).click();
+
+ // 原文与清理对照:噪声原文可见;应用清理后对照说明出现且原文不动。
+ const 对照 = page.locator('section[aria-label="原文与清理对照"]');
+ await expect(对照.getByText("最新章节请访问 example.com")).toBeVisible();
+ await 对照.getByRole("button", { name: "生成并应用清理对照" }).click();
+ await expect(对照.getByText("清理已应用为对照版本;原文保持不变。")).toBeVisible();
+ await expect(对照.getByText("最新章节请访问 example.com")).toBeVisible();
+
+ // 榜单快照:失败态如实呈现,成功快照带条目数。
+ 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 });
+
+ expect(
+ consoleErrors.filter((文本) => !文本.includes("favicon")),
+ "页面不得出现控制台错误",
+ ).toEqual([]);
+});
diff --git a/数据库/迁移/V0007__资料研究.sql b/数据库/迁移/V0007__资料研究.sql
new file mode 100644
index 0000000..774d7c9
--- /dev/null
+++ b/数据库/迁移/V0007__资料研究.sql
@@ -0,0 +1,67 @@
+-- W17(B03):外部与私人资料、原文版本、清理候选与榜单快照。
+-- 原文只追加不可改写;清理是对照产物;榜单快照把时点观察与效果推断分开(无 run 归因字段)。
+DO $migration$
+BEGIN
+ -- 来源:同源再导入落到版本链;kind 区分作者稿、参考书、网页、笔记与灵感。
+ CREATE TABLE public.muse_source (
+ source_id uuid PRIMARY KEY,
+ kind text NOT NULL CHECK (kind IN ('author_draft','reference','web','note','inspiration')),
+ title text NOT NULL,
+ origin text NOT NULL,
+ authorized_uses jsonb NOT NULL DEFAULT '[]'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ UNIQUE (origin, kind),
+ CHECK (jsonb_typeof(authorized_uses) = 'array')
+ );
+
+ -- 原文版本:只追加;内容哈希用于证据校验与重复辨认。
+ CREATE TABLE public.muse_source_version (
+ source_id uuid NOT NULL REFERENCES public.muse_source(source_id),
+ revision bigint NOT NULL CHECK (revision > 0),
+ content_hash text NOT NULL,
+ content text NOT NULL,
+ import_result jsonb NOT NULL,
+ imported_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ PRIMARY KEY (source_id, revision),
+ CHECK (jsonb_typeof(import_result) = 'object')
+ );
+ CREATE INDEX ON public.muse_source_version (source_id, imported_at);
+
+ -- 清理候选:对照产物,原文不受影响;删除项可定位、可回退。
+ CREATE TABLE public.muse_clean_candidate (
+ candidate_id uuid PRIMARY KEY,
+ source_id uuid NOT NULL REFERENCES public.muse_source(source_id),
+ revision bigint NOT NULL,
+ removals jsonb NOT NULL,
+ status text NOT NULL CHECK (status IN ('proposed','applied','rejected')),
+ cleaned_hash text,
+ cleaned_text text,
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ UNIQUE (source_id, revision, candidate_id),
+ CHECK (jsonb_typeof(removals) = 'array'),
+ CHECK ((status = 'applied') = (cleaned_hash IS NOT NULL AND cleaned_text IS NOT NULL)),
+ FOREIGN KEY (source_id, revision) REFERENCES public.muse_source_version(source_id, revision)
+ );
+
+ -- 榜单来源登记:具名榜种与采集方式。
+ CREATE TABLE public.muse_ranking_source (
+ ranking_id text PRIMARY KEY,
+ name text NOT NULL,
+ url text NOT NULL,
+ 采集方式 text NOT NULL
+ );
+
+ -- 榜单快照:时刻观察;失败保留失败态;不携带任何 run 归因。
+ CREATE TABLE public.muse_ranking_snapshot (
+ snapshot_id uuid PRIMARY KEY,
+ ranking_id text NOT NULL REFERENCES public.muse_ranking_source(ranking_id),
+ captured_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ status text NOT NULL CHECK (status IN ('ok','failed')),
+ failure_reason text,
+ items jsonb,
+ CHECK ((status = 'ok') = (items IS NOT NULL)),
+ CHECK (items IS NULL OR jsonb_typeof(items) = 'array')
+ );
+ CREATE INDEX ON public.muse_ranking_snapshot (ranking_id, captured_at DESC);
+END
+$migration$;