dsh-plugin-deck-studio 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/cordis.example.yml +85 -7
  2. package/cordis.patch.yml +78 -7
  3. package/index.js +176 -25
  4. package/lib/capabilities.js +234 -0
  5. package/lib/citation-check.js +93 -0
  6. package/lib/config.js +27 -0
  7. package/lib/deck-brief.js +35 -1
  8. package/lib/deck-docs.js +45 -7
  9. package/lib/deck-templates.js +8 -1
  10. package/lib/deck-workspace.js +27 -1
  11. package/lib/designs.js +15 -0
  12. package/lib/fact-labels.js +81 -0
  13. package/lib/first-draft.js +119 -0
  14. package/lib/image-review.js +523 -0
  15. package/lib/page-contract.js +112 -0
  16. package/lib/proposal-decision.js +249 -75
  17. package/lib/review-agent.js +1 -1
  18. package/lib/review-personas.js +49 -2
  19. package/lib/review-protocol.js +411 -0
  20. package/lib/review-readers.js +300 -0
  21. package/lib/review-score.js +198 -0
  22. package/lib/title-chain.js +122 -0
  23. package/lib/tools.js +971 -77
  24. package/package.json +2 -1
  25. package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
  26. package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
  27. package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
  28. package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
  29. package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
  30. package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
  31. package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
  32. package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
  33. package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
  34. package/seeds/docs/install.en.md +13 -3
  35. package/seeds/docs/install.md +13 -4
  36. package/seeds/docs/sample-deck.en.md +1 -1
  37. package/seeds/docs/sample-deck.md +1 -1
  38. package/seeds/docs/styles.en.md +2 -1
  39. package/seeds/docs/styles.md +14 -4
  40. package/seeds/samples/DSA-01.jpg +0 -0
  41. package/seeds/samples/DSA-02.jpg +0 -0
  42. package/seeds/samples/DSA-03.jpg +0 -0
  43. package/seeds/styles/DSA.json +22 -0
  44. package/ui/workbench.css +5 -0
  45. package/ui/workbench.html +98 -6
@@ -0,0 +1,411 @@
1
+ /**
2
+ * F-172 · 审稿协议:盲审 → 冻结 → 对照(两段式),以「先盲后对照」替代「一上来就给意图」。
3
+ *
4
+ * 为什么两段:审稿人一旦知道作者想表达什么,就会自动补全读者没接住的部分——
5
+ * 它评的是「你想说的」,不是「你写出来的」。所以第一段只给身份 + 稿件,四问逐页作答,
6
+ * 结论落盘冻结;第二段才揭示意图,做 X/S/Y 三方差分。
7
+ *
8
+ * 本模块无 IO(不读不写文件):prompt 与解析纯函数,snapshot 哈希可测。
9
+ * 冻结文件与报告落盘由 tools.js 负责。
10
+ * @module dsh-plugin-deck-studio/review-protocol
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ /** 每页必答的四问(覆盖矩阵的列)。 */
16
+ export const BLIND_QUESTIONS = [
17
+ { id: "restate", label: "复述", ask: "只读这一页,用一句话说出它想让我记住什么。" },
18
+ { id: "relation", label: "承接", ask: "这一页和上一页是什么关系?(并列 / 递进 / 因果 / 转折 / 无关系 / 首次立题)" },
19
+ { id: "doubt", label: "追问", ask: "哪一句你信不过、想追问,或者觉得是空话?(原文引用;没有就写「无」)" },
20
+ { id: "next_action", label: "动作", ask: "读完这一页,你知道下一步该做什么吗?(知道就写出来;不知道写「不知道」)" },
21
+ ];
22
+
23
+ /** 冻结文件名(deck docs 下);报告沿用 F-159 的 review.experts.md,工作台不用改。 */
24
+ export const BLIND_FREEZE_FILE = "review.blind.json";
25
+ export const REVIEW_REPORT_FILE = "review.experts.md";
26
+
27
+ /** 盲审阶段允许发给审稿人的信息(这里显式列出来,方便测试与审计)。 */
28
+ export const BLIND_ALLOWED_FIELDS = ["audience", "deckTitle", "pages"];
29
+ /** 盲审阶段禁止出现的内容(意图/规则/骨架这些会替作者圆场)。 */
30
+ export const BLIND_FORBIDDEN_FIELDS = ["takeaway", "success", "scopeOut", "topic", "rulesText", "skeleton"];
31
+
32
+ const RELATIONS = new Set(["并列", "递进", "因果", "转折", "无关系", "首次立题"]);
33
+ const VERDICTS = new Set(["是", "部分", "否"]);
34
+
35
+ /** 从 script 里取页号(`### P01 · 标题`),去重保序。 */
36
+ export function pageIdsFromScript(script) {
37
+ const ids = [];
38
+ const seen = new Set();
39
+ for (const match of String(script ?? "").matchAll(/^###\s+(P\d+)\b/gm)) {
40
+ if (seen.has(match[1])) continue;
41
+ seen.add(match[1]);
42
+ ids.push(match[1]);
43
+ }
44
+ return ids;
45
+ }
46
+
47
+ /**
48
+ * 稿子快照哈希:空白归一后取 sha256。
49
+ * 冻结只对当轮快照有效——稿子变了,旧盲审结论自动失效(防止「拿旧结论评新稿」)。
50
+ */
51
+ export function scriptSnapshotHash(script) {
52
+ const normalized = String(script ?? "").replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
53
+ return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 32);
54
+ }
55
+
56
+ function clip(text, maxChars) {
57
+ const value = String(text ?? "");
58
+ return value.length > maxChars ? `${value.slice(0, maxChars)}\n\n(过长,以上为前 ${maxChars} 字符)` : value;
59
+ }
60
+
61
+ /** 盲审 prompt:只给身份与稿件,四问逐页作答。故意不含意图四件套。 */
62
+ export function blindReviewerPrompt({ reviewer, brief, deckTitle, script, maxScriptChars = 60_000 }) {
63
+ const questions = BLIND_QUESTIONS.map((item, index) => `${index + 1}. ${item.label}:${item.ask}`).join("\n");
64
+ return [
65
+ `你是这份分享的听众:${reviewer.label}`,
66
+ reviewer.focus,
67
+ "",
68
+ deckTitle ? `## 分享标题\n${deckTitle}` : "",
69
+ "## 你手上的材料",
70
+ "只有下面这份页面内容(页号 / 标题 / 这页回答什么 / 主张 / 正文)。",
71
+ "你不会看到作者的意图、成功标准、规则或任何解释——就像你只是坐进了听众席。",
72
+ "",
73
+ "## 页面内容",
74
+ clip(script, maxScriptChars),
75
+ "",
76
+ "## 你的任务(每一页都要答,一页都不能跳)",
77
+ questions,
78
+ "",
79
+ "整本再答一问:读完全部,你以为作者想让你做什么?用一句话说,不要参考标题。",
80
+ "",
81
+ "## 输出契约",
82
+ "只输出一个 JSON 对象,不要围栏外的解释:",
83
+ "{",
84
+ ' "reviewer": "你的身份(一句话)",',
85
+ ' "per_page": [',
86
+ ' {"page": "P01", "restate": "一句话", "relation": "首次立题", "doubt": "原文引用或「无」", "next_action": "知道:… / 不知道"}',
87
+ " ],",
88
+ ' "whole": "你以为作者想让你做什么"',
89
+ "}",
90
+ "规则:",
91
+ `- per_page 必须覆盖上面出现的每一页(共 ${pageIdsFromScript(script).length} 页),顺序与稿件一致;`,
92
+ "- 每个字段都只写一句话:restate ≤40 字、doubt ≤40 字、next_action ≤20 字、whole ≤40 字;不要写在 JSON 之外的解释;",
93
+ "- 只写你作为听众的真实反应;不确定就写「不确定」,不要替作者补全;",
94
+ "- 不给建议、不打分、不写通用写作建议;不提任何工具或文件。",
95
+ ]
96
+ .filter((line) => line !== "")
97
+ .join("\n");
98
+ }
99
+
100
+ /** 对照 prompt:冻结的盲审结论 + 稿件原文 + 作者意图,做三方差分(X 读者读出 / S 稿子写的 / Y 意图要的)。
101
+ * 必须带稿件原文:证据要逐字引用,只给盲审结论会让模型拿自己的复述当证据,被校验拒绝。 */
102
+ export function contrastReviewerPrompt({ reviewer, blind, brief, deckTitle, script = "", maxScriptChars = 60_000 }) {
103
+ const intentLines = [
104
+ brief?.topic ? `主题:${brief.topic}` : "",
105
+ brief?.audience ? `听众:${brief.audience}` : "",
106
+ brief?.takeaway ? `听完要带走:${brief.takeaway}` : "",
107
+ brief?.success ? `怎么算讲成功:${brief.success}` : "",
108
+ brief?.scopeOut ? `不讲的:${brief.scopeOut}` : "",
109
+ ].filter(Boolean);
110
+ return [
111
+ `你是这份分享的听众:${reviewer.label}(同一个身份,第二轮)`,
112
+ "",
113
+ "## 你上一轮看完之后的真实反应(已冻结,不许改)",
114
+ "这是你在不知道作者意图的情况下写下的结论:",
115
+ "```json",
116
+ JSON.stringify(blind, null, 2).slice(0, 40_000),
117
+ "```",
118
+ "",
119
+ "## 现在可以看的:作者写的意图",
120
+ intentLines.join("\n") || "(intent 未提供)",
121
+ "",
122
+ "## 稿件原文(引用证据只能从这里逐字摘)",
123
+ clip(script, maxScriptChars),
124
+ "",
125
+ deckTitle ? `## 分享标题\n${deckTitle}` : "",
126
+ "",
127
+ "## 你的任务",
128
+ "拿你上一轮的读出,跟作者意图逐页对照,标出差异属于哪一类:",
129
+ "- X ≠ S:你读出的(X)和稿子上写的(S)不一样——表达没到位;",
130
+ "- S ≠ Y:稿子写的(S)和作者意图(Y)不一样——内容偏了;",
131
+ "- X = Y ≠ S:你读出了意图,但稿子绕了——该删减;",
132
+ "- X = S = Y:达标。",
133
+ "另外两问:整本上你读出的和意图差在哪;哪几页可以删或合并。",
134
+ "",
135
+ "## 输出契约",
136
+ "只输出一个 JSON 对象:",
137
+ "{",
138
+ ' "reviewer": "你的身份(一句话)",',
139
+ ' "gaps": [',
140
+ ' {"page": "P01", "x": "你读出的", "y": "意图要的", "verdict": "是 | 部分 | 否", "evidence": "稿件原文片段", "fix": "具体怎么改(可直接落版的句子)"}',
141
+ " ],",
142
+ ' "deck_gap": "整本读出的和意图差在哪(一句话)",',
143
+ ' "delete_candidates": ["P0X(为什么可删)"]',
144
+ "}",
145
+ "规则:",
146
+ "- 每页一条 gap;没有差异就写 verdict=是,x 与 y 写同一句;",
147
+ "- 每个字段只写一句话:x / y ≤40 字、evidence ≤60 字(逐字摘)、fix ≤60 字;不要写在 JSON 之外的解释;",
148
+ "- evidence 必须从上面「稿件原文」里逐字摘一句,不要用自己的复述、不要编;没找到可引的句子就不写 evidence;",
149
+ "- 不改稿、不调用工具;只输出这份 JSON。",
150
+ ]
151
+ .filter((line) => line !== "")
152
+ .join("\n");
153
+ }
154
+
155
+ function jsonCandidate(text) {
156
+ const raw = String(text ?? "").trim();
157
+ if (!raw) return null;
158
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
159
+ for (const candidate of [fenced?.[1], raw].filter(Boolean)) {
160
+ const start = candidate.indexOf("{");
161
+ const end = candidate.lastIndexOf("}");
162
+ if (start < 0 || end <= start) continue;
163
+ try {
164
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
165
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
166
+ } catch {
167
+ /* 试下一个候选 */
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** 解析盲审输出;形状不合契约就标 unparsed(不静默丢,交给上层如实报告)。 */
174
+ export function parseBlindOutput(text) {
175
+ const parsed = jsonCandidate(text);
176
+ if (!parsed) return { blind: null, unparsed: true, raw: String(text ?? "") };
177
+ const reviewer = String(parsed.reviewer ?? "").trim();
178
+ const whole = String(parsed.whole ?? "").trim();
179
+ const rows = Array.isArray(parsed.per_page) ? parsed.per_page : null;
180
+ if (!reviewer || !whole || !rows || rows.length > 200) {
181
+ return { blind: null, unparsed: true, raw: String(text ?? "") };
182
+ }
183
+ const perPage = [];
184
+ for (const row of rows) {
185
+ if (!row || typeof row !== "object") continue;
186
+ const page = String(row.page ?? "").trim();
187
+ if (!/^P\d+$/.test(page)) continue;
188
+ perPage.push({
189
+ page,
190
+ restate: String(row.restate ?? "").trim(),
191
+ relation: RELATIONS.has(String(row.relation ?? "").trim()) ? String(row.relation).trim() : "其他",
192
+ doubt: String(row.doubt ?? "").trim() || "无",
193
+ next_action: String(row.next_action ?? "").trim() || "不知道",
194
+ });
195
+ }
196
+ if (!perPage.length) return { blind: null, unparsed: true, raw: String(text ?? "") };
197
+ return {
198
+ blind: { reviewer, whole, per_page: perPage },
199
+ unparsed: false,
200
+ raw: String(text ?? ""),
201
+ };
202
+ }
203
+
204
+ /** 覆盖矩阵:expected 是稿件页号,answered 是审稿人真答了的页。 */
205
+ export function checkCoverage(perPage, expectedPages) {
206
+ const answered = new Set((perPage ?? []).map((row) => row.page));
207
+ const expected = expectedPages ?? [];
208
+ const missing = expected.filter((page) => !answered.has(page));
209
+ const extra = [...answered].filter((page) => !expected.includes(page));
210
+ return { expected: expected.length, answered: answered.size, missing, extra, complete: missing.length === 0 && extra.length === 0 };
211
+ }
212
+
213
+ /** 解析对照输出。 */
214
+ export function parseContrastOutput(text) {
215
+ const parsed = jsonCandidate(text);
216
+ if (!parsed) return { contrast: null, unparsed: true, raw: String(text ?? "") };
217
+ const reviewer = String(parsed.reviewer ?? "").trim();
218
+ const rows = Array.isArray(parsed.gaps) ? parsed.gaps : null;
219
+ if (!reviewer || !rows) return { contrast: null, unparsed: true, raw: String(text ?? "") };
220
+ const gaps = [];
221
+ for (const row of rows) {
222
+ if (!row || typeof row !== "object") continue;
223
+ const page = String(row.page ?? "").trim();
224
+ if (!/^P\d+$/.test(page)) continue;
225
+ const verdictRaw = String(row.verdict ?? "").trim();
226
+ gaps.push({
227
+ page,
228
+ x: String(row.x ?? "").trim(),
229
+ y: String(row.y ?? "").trim(),
230
+ verdict: VERDICTS.has(verdictRaw) ? verdictRaw : "部分",
231
+ evidence: String(row.evidence ?? "").trim(),
232
+ fix: String(row.fix ?? "").trim(),
233
+ });
234
+ }
235
+ if (!gaps.length) return { contrast: null, unparsed: true, raw: String(text ?? "") };
236
+ const deleteCandidates = Array.isArray(parsed.delete_candidates)
237
+ ? parsed.delete_candidates.map((item) => String(item ?? "").trim()).filter(Boolean).slice(0, 10)
238
+ : [];
239
+ return {
240
+ contrast: {
241
+ reviewer,
242
+ gaps,
243
+ deck_gap: String(parsed.deck_gap ?? "").trim(),
244
+ delete_candidates: deleteCandidates,
245
+ },
246
+ unparsed: false,
247
+ raw: String(text ?? ""),
248
+ };
249
+ }
250
+
251
+ /** 多审稿人盲审合并:同页多条复述并列保留(不同听众读出不同意思本身就是信号)。 */
252
+ export function mergeBlind(blinds = []) {
253
+ const byPage = new Map();
254
+ for (const item of blinds) {
255
+ const from = item.label || item.blind?.reviewer || "";
256
+ for (const row of item.blind?.per_page ?? []) {
257
+ if (!byPage.has(row.page)) byPage.set(row.page, { page: row.page, answers: [] });
258
+ byPage.get(row.page).answers.push({ from, ...row });
259
+ }
260
+ }
261
+ return [...byPage.values()].sort((a, b) => a.page.localeCompare(b.page));
262
+ }
263
+
264
+ /**
265
+ * 证据是否真的来自稿件:先抹掉空白与引号差异再比。
266
+ * 模型常把跨行/全角空格/引号换成自己习惯的写法,直接 includes 会把真证据判成编的。
267
+ */
268
+ export function scriptContainsEvidence(script, evidence) {
269
+ const squash = (text) => String(text ?? "").replace(/[\s\u3000]+/g, "").replace(/[“”"「」『』']/g, "");
270
+ const needle = squash(evidence);
271
+ if (!needle) return false;
272
+ return squash(script).includes(needle);
273
+ }
274
+
275
+ /**
276
+ * 评审意见 → 可采纳清单(F-176 / 智力升级 I-18,同时是 B6 假证据治理的 G1)。
277
+ *
278
+ * 每条意见给一个稳定编号(R01…),并当场做 G1「逐字匹配」:
279
+ * 证据在稿件里找得到(规范化空白 / 引号后仍是子串)→ A 类可进提案;找不到 → C 类,
280
+ * 只进 dropped[](界面显示「无法核实」),绝不允许照着它改稿。B / D 类要等 G3 引用核查的模型判定,这里先不产生。
281
+ */
282
+ export function buildFindings(script, gaps = []) {
283
+ const findings = [];
284
+ const dropped = [];
285
+ const seen = new Set();
286
+ for (const gap of gaps) {
287
+ if (gap.verdict === "是") continue;
288
+ const page = String(gap.page ?? "").trim();
289
+ if (!page) continue;
290
+ const evidence = String(gap.evidence ?? "").trim();
291
+ const key = `${page}::${evidence.replace(/\s+/g, "")}`;
292
+ if (seen.has(key)) continue;
293
+ seen.add(key);
294
+ const verbatim = evidence.length >= 8 && scriptContainsEvidence(script, evidence);
295
+ const finding = {
296
+ id: `R${String(findings.length + dropped.length + 1).padStart(2, "0")}`,
297
+ page,
298
+ verdict: gap.verdict,
299
+ severity: gap.verdict === "否" ? "P0" : "P1",
300
+ // 三方差分里 X≠S 是表达问题、S≠Y 是内容问题、X=Y≠S 是删减——一次只改一类。
301
+ category: gap.x && gap.y ? (gap.x === gap.y ? "表达" : "内容") : gap.y ? "内容" : "表达",
302
+ evidence,
303
+ fix: String(gap.fix ?? "").trim(),
304
+ reviewer: String(gap.reviewer ?? ""),
305
+ confidence: verbatim ? "A" : "C",
306
+ verbatim,
307
+ };
308
+ if (verbatim) findings.push(finding);
309
+ else dropped.push(finding);
310
+ }
311
+ return { findings, dropped };
312
+ }
313
+
314
+ /** 对照意见 → 统一 issues(沿用 F-159 的严重度口径,工作台不需要改)。 */
315
+ export function gapsToIssues(gaps = []) {
316
+ const issues = [];
317
+ for (const gap of gaps) {
318
+ if (gap.verdict === "是") continue;
319
+ issues.push({
320
+ page: gap.page,
321
+ severity: gap.verdict === "否" ? "P0" : "P1",
322
+ problem: `读者读出「${gap.x || "(空)"}」,意图要「${gap.y || "(空)"}」`,
323
+ evidence: gap.evidence,
324
+ suggestion: gap.fix,
325
+ reviewer: gap.reviewer ?? "",
326
+ from: gap.from ?? gap.reviewer ?? "",
327
+ });
328
+ }
329
+ return issues;
330
+ }
331
+
332
+ /** 冻结记录:哈希 + 页 + 审稿人 + 覆盖情况 + 四问定义,一次写死。 */
333
+ export function makeFreezeRecord({ deckId, scriptHash, pages, reviewers, generatedAt }) {
334
+ return {
335
+ schemaVersion: 1,
336
+ deckId,
337
+ scriptHash,
338
+ generatedAt,
339
+ questions: BLIND_QUESTIONS.map((item) => item.id),
340
+ pages,
341
+ reviewers,
342
+ };
343
+ }
344
+
345
+ /** 冻结校验:稿子哈希必须与冻结时一致,否则旧盲审结论不能用于新稿。 */
346
+ export function verifyFreeze(freeze, currentHash) {
347
+ if (!freeze || typeof freeze !== "object") return { ok: false, reason: "没有可用的盲审冻结记录" };
348
+ if (!freeze.scriptHash) return { ok: false, reason: "盲审冻结记录缺少稿子哈希" };
349
+ if (freeze.scriptHash !== currentHash) {
350
+ return { ok: false, reason: "稿件在盲审之后被改过,旧盲审结论已失效——请重跑盲审再对照" };
351
+ }
352
+ return { ok: true, reason: "" };
353
+ }
354
+
355
+ const cell = (value, max = 220) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n+/g, " ").slice(0, max);
356
+
357
+ /** 写进 review.experts.md 的报告:冻结的盲审 → 覆盖矩阵 → 三方差分 → 结论。 */
358
+ export function renderProtocolMarkdown({ deckTitle, generatedAt, scriptHash, reviewers, mergedBlind, coverage, gaps, deckGap, deleteCandidates, failed = [] }) {
359
+ const byVerdict = { 是: 0, 部分: 0, 否: 0 };
360
+ for (const gap of gaps) byVerdict[gap.verdict] = (byVerdict[gap.verdict] ?? 0) + 1;
361
+ const blindRows = mergedBlind.length
362
+ ? mergedBlind
363
+ .map((row) => {
364
+ const first = row.answers[0] ?? {};
365
+ return `| ${row.page} | ${cell(first.restate)} | ${cell(first.relation, 20)} | ${cell(first.doubt, 160)} | ${cell(first.next_action, 80)} |`;
366
+ })
367
+ .join("\n")
368
+ : "| — | — | — | — | — |";
369
+ const gapRows = gaps.length
370
+ ? gaps
371
+ .map(
372
+ (gap) =>
373
+ `| ${gap.verdict} | ${gap.page} | ${cell(gap.x, 140)} | ${cell(gap.y, 140)} | ${cell(gap.evidence, 120)} | ${cell(gap.fix, 200)} |`,
374
+ )
375
+ .join("\n")
376
+ : "| — | — | — | — | — | — |";
377
+ return [
378
+ "---",
379
+ `title: ${deckTitle ?? ""} · 听众评审`,
380
+ "status: draft",
381
+ "---",
382
+ "",
383
+ "# 听众评审(盲审 → 对照)",
384
+ "",
385
+ `> 生成时间:${generatedAt} · 稿件哈希:\`${scriptHash}\``,
386
+ "> 只审不改:评审不直接改 script;改稿仍走提案 + 用户确认。",
387
+ "",
388
+ "## 评审人(按 intent 听众生成)",
389
+ reviewers.length ? reviewers.map((reviewer) => `- ${reviewer.label}`).join("\n") : "- (无)",
390
+ ...(failed.length ? ["", `失效评审:${failed.map((item) => item.label).join(" / ")}`] : []),
391
+ "",
392
+ "## 第一轮:盲审(不知道意图)",
393
+ "",
394
+ `覆盖:${coverage.answered}/${coverage.expected} 页${(coverage.missing ?? []).length ? `;**缺答:${coverage.missing.join("、")}**` : ""}${(coverage.extra ?? []).length ? `;多答:${coverage.extra.join("、")}` : ""}`,
395
+ "",
396
+ "| 页 | 复述(这页想让人记住什么) | 承接 | 追问 | 下一步动作 |",
397
+ "|---|---|---|---|---|",
398
+ blindRows,
399
+ "",
400
+ "## 第二轮:对照(揭示意图后的差异)",
401
+ "",
402
+ `是 ${byVerdict.是} · 部分 ${byVerdict.部分} · 否 ${byVerdict.否}`,
403
+ ...(deckGap ? ["", `整本差异:${deckGap}`] : []),
404
+ "",
405
+ "| 判定 | 页 | 读者读出 X | 意图要的 Y | 原文证据 | 改法 |",
406
+ "|---|---|---|---|---|---|",
407
+ gapRows,
408
+ "",
409
+ ...(deleteCandidates?.length ? ["## 可删 / 可合并", "", ...deleteCandidates.map((item) => `- ${item}`), ""] : []),
410
+ ].join("\n");
411
+ }