dsh-plugin-deck-studio 0.2.5 → 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 (51) hide show
  1. package/cordis.example.yml +88 -30
  2. package/cordis.patch.yml +81 -30
  3. package/index.js +187 -28
  4. package/lib/capabilities.js +234 -0
  5. package/lib/citation-check.js +93 -0
  6. package/lib/config.js +30 -7
  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 +168 -0
  18. package/lib/review-personas.js +59 -3
  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 +983 -85
  24. package/package.json +4 -3
  25. package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
  26. package/seeds/docs/assets/screenshots/08-presentation.png +0 -0
  27. package/seeds/docs/assets/screenshots/09-style-library.png +0 -0
  28. package/seeds/docs/assets/screenshots/10-style-management.png +0 -0
  29. package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
  30. package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
  31. package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
  32. package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
  33. package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
  34. package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
  35. package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
  36. package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
  37. package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
  38. package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
  39. package/seeds/docs/compat.md +3 -3
  40. package/seeds/docs/install.en.md +29 -14
  41. package/seeds/docs/install.md +29 -15
  42. package/seeds/docs/sample-deck.en.md +1 -1
  43. package/seeds/docs/sample-deck.md +1 -1
  44. package/seeds/docs/styles.en.md +5 -4
  45. package/seeds/docs/styles.md +17 -7
  46. package/seeds/samples/DSA-01.jpg +0 -0
  47. package/seeds/samples/DSA-02.jpg +0 -0
  48. package/seeds/samples/DSA-03.jpg +0 -0
  49. package/seeds/styles/DSA.json +22 -0
  50. package/ui/workbench.css +5 -0
  51. package/ui/workbench.html +111 -7
package/lib/tools.js CHANGED
@@ -9,22 +9,48 @@
9
9
 
10
10
  import { dirname, extname, join, relative, resolve, sep } from "node:path";
11
11
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
12
- import { buildPrompt, qaCheck, nextRevision, selectRenderer, editEndpointFor, imageMime, createFakeEditRenderer, createRealEditRenderer } from "./render.js";
12
+ import { buildPrompt, qaCheck, nextRevision, selectRenderer, editEndpointFor, imageMime, readKeyFromFile, createFakeEditRenderer, createRealEditRenderer } from "./render.js";
13
13
  import { applyScriptFieldEdit, scriptDiff, newProposalId, proposalNow, SCRIPT_FIELD_CELL, scriptSectionRange } from "./deck-workspace.js";
14
14
  import { appendChangelogNote, readDeckDoc, resolveDeckDocs, scaffoldDeckDocs } from "./deck-docs.js";
15
15
  import { createDesignRegistry } from "./designs.js";
16
16
  import { appendSampleRecord, composeSamplePrompt, readSampleRecords, readStyleDefinition, writeStyleDefinition } from "./design-style.js";
17
17
  import { createLocalSourceAdapter, parseSectionedPages } from "./source-adapter.js";
18
18
  import { readDeckRules, renderDeckRules } from "./deck-rules.js";
19
- import { readDeckBrief } from "./deck-brief.js";
19
+ import { readDeckBrief, validateSkeleton } from "./deck-brief.js";
20
20
  import { scriptStyleHints } from "./script-style.js";
21
- import { DEFAULT_MAX_REVIEWERS, HARD_MAX_REVIEWERS, deriveReviewers, mergeIssues, parseReviewerOutput, renderReviewMarkdown, reviewerPrompt } from "./review-personas.js";
21
+ import { DEFAULT_MAX_REVIEWERS, HARD_MAX_REVIEWERS, deriveReviewers, mergeIssues } from "./review-personas.js";
22
+ import {
23
+ BLIND_FREEZE_FILE,
24
+ REVIEW_REPORT_FILE,
25
+ blindReviewerPrompt,
26
+ buildFindings,
27
+ contrastReviewerPrompt,
28
+ gapsToIssues,
29
+ makeFreezeRecord,
30
+ mergeBlind,
31
+ pageIdsFromScript,
32
+ parseBlindOutput,
33
+ parseContrastOutput,
34
+ renderProtocolMarkdown,
35
+ scriptContainsEvidence,
36
+ scriptSnapshotHash,
37
+ verifyFreeze,
38
+ } from "./review-protocol.js";
39
+ import { REVIEW_AGENT_PROVIDER } from "./review-agent.js";
40
+ import { plainReviewSummary, renderGapReviewMarkdown, renderReaderReviewMarkdown } from "./review-readers.js";
41
+ import { applyCitationChecks, citationCheckPrompt, parseCitationCheck } from "./citation-check.js";
42
+ import { attemptsOf, pickRenderExpectations, renderImageReviewMarkdown, runImageReview } from "./image-review.js";
43
+ import { analyzeTitleChain, renderTitleChain } from "./title-chain.js";
44
+ import { CONTRACT_FIELD_KEYS, contractTag, readPageContract, summarizeContracts, validatePageContract } from "./page-contract.js";
45
+ import { compareScores, mergeScoreRecords, parseScoreOutput, renderScoreHistoryMarkdown, renderScoreSummary, scoreHistoryEntry, scoreReviewerPrompt } from "./review-score.js";
46
+ import { analyzeFactLabels, factSummaryLine } from "./fact-labels.js";
22
47
  import { densityLine } from "./content-density.js";
23
48
  import { faqIndex, searchFaq } from "./faq.js";
24
49
  import { WATERMARK_POSITIONS, WATERMARK_POSITION_LABELS, findAsset, listAssets, positionLine, readPngFile, watermarkPng } from "./watermark.js";
25
50
  import { PLUGIN_DIR, repoRootFrom } from "./config.js";
26
51
  import { buildIntentMarkdown } from "./deck-templates.js";
27
- import { createScriptToSourceSync, decideProposal } from "./proposal-decision.js";
52
+ import { applyFirstDraftBatch, createScriptToSourceSync, decideProposal } from "./proposal-decision.js";
53
+ import { isFirstDraft, latestAutoland, rollbackFirstDraft } from "./first-draft.js";
28
54
 
29
55
  /** 本次调用的 sessionId(非 agent 环境——冒烟测试——为 null)。 */
30
56
  function sessionIdOf(exec) {
@@ -138,7 +164,33 @@ const strEnum = (values) => ({ type: "string", enum: values });
138
164
  * @param {import('./config.js').mergeConfig} services.config
139
165
  * @param {NodeJS.ProcessEnv} [services.env]
140
166
  */
141
- export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = null, reviewPreset = null }) {
167
+ /**
168
+ * 评审意见 → 提案的元数据(F-176 / 智力升级 I-18)。
169
+ * A/B 类可以进提案;C 类(原文找不到证据)一律拒绝——宁可少一条意见,也不让编造的证据进改动。
170
+ */
171
+ function reviewMetaOf(args = {}) {
172
+ const meta = {};
173
+ const reviewRef = String(args.reviewRef ?? "").trim();
174
+ if (reviewRef) meta.reviewRef = reviewRef.slice(0, 40);
175
+ const severity = String(args.severity ?? "").trim().toUpperCase();
176
+ if (severity) {
177
+ if (!["P0", "P1", "P2"].includes(severity)) throw new Error(`severity 只能是 P0 / P1 / P2(收到 ${severity})`);
178
+ meta.severity = severity;
179
+ }
180
+ const confidence = String(args.confidence ?? "").trim().toUpperCase();
181
+ if (confidence) {
182
+ if (!["A", "B", "C", "D"].includes(confidence)) throw new Error(`confidence 只能是 A / B / C / D(收到 ${confidence})`);
183
+ if (confidence === "C") {
184
+ throw new Error("C 类意见(稿件里找不到原文证据)不许进提案:先在评审里确认证据,或把它当「无法核实」告诉用户,不要照着改。");
185
+ }
186
+ meta.confidence = confidence;
187
+ }
188
+ const evidence = String(args.evidence ?? "").trim();
189
+ if (evidence) meta.evidence = evidence.slice(0, 300);
190
+ return meta;
191
+ }
192
+
193
+ export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = null, visionFetch = null, createStyle = null }) {
142
194
  const resolveSubagents = () => (typeof subagents === "function" ? subagents() : subagents);
143
195
  const designs = createDesignRegistry({ dataRoot: config.dataRoot });
144
196
  const decksRegistryFile = join(config.dataRoot, "decks-registry.json");
@@ -299,6 +351,11 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
299
351
  const density = workspace.manifest(adapter.id)?.contentDensity ?? "auto";
300
352
  // F-158:写 script 前先看到 intent 的听众 / 目的(新 deck 的 deb 规则里也会带,但 overview 是第一个调用)。
301
353
  const brief = adapter.available() ? readDeckBrief(adapter.sourceRoot) : {};
354
+ // F-180 / I-05:deck 级论证骨架(核心论证 / 骨架类型 / 页序角色)——没写就提示先写骨架再列页。
355
+ const skeleton = validateSkeleton(brief);
356
+ const skeletonState = brief.skeleton?.argument || brief.skeleton?.type || brief.skeleton?.order
357
+ ? { argument: brief.skeleton.argument, type: brief.skeleton.type, order: brief.skeleton.order, keepThree: brief.skeleton.keepThree, ok: skeleton.ok, missing: skeleton.missing }
358
+ : { ok: false, missing: skeleton.missing.length ? skeleton.missing : ["核心论证", "骨架类型", "页序角色"] };
302
359
  return {
303
360
  deckId: adapter.id,
304
361
  label: adapter.label,
@@ -320,13 +377,17 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
320
377
  ...(rules.warning ? { warning: rules.warning } : {}),
321
378
  },
322
379
  _rulesText: renderDeckRules(rules),
323
- _briefText: brief.audience || brief.takeaway || brief.success
324
- ? `受众 / 目的:${[brief.audience, brief.takeaway || brief.success].filter(Boolean).join(" · ")}`
325
- : "",
380
+ _briefText: [
381
+ brief.audience || brief.takeaway || brief.success
382
+ ? `受众 / 目的:${[brief.audience, brief.takeaway || brief.success].filter(Boolean).join(" · ")}`
383
+ : "",
384
+ skeleton.hint,
385
+ ].filter(Boolean).join("\n"),
386
+ _skeletonText: skeletonState,
326
387
  };
327
388
  });
328
389
  return {
329
- decks: decks.map(({ _rulesText, _briefText, ...deck }) => deck),
390
+ decks: decks.map(({ _rulesText, _briefText, _skeletonText, ...deck }) => deck),
330
391
  deckId: null,
331
392
  text: decks
332
393
  .map((deck) =>
@@ -441,6 +502,11 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
441
502
  "目标": "成功标准", goal: "成功标准", objective: "成功标准",
442
503
  "不讲的": "不讲的", "不讲": "不讲的", scopeOut: "不讲的", scope: "不讲的",
443
504
  "边界": "不讲的", "排除": "不讲的", "不包括": "不讲的", "nonGoals": "不讲的",
505
+ // F-180(I-05):论证骨架四行也是 intent 的结构化字段,能通过 deck_doc_update 写。
506
+ "核心论证": "核心论证", argument: "核心论证", "论点": "核心论证",
507
+ "骨架类型": "骨架类型", skeletonType: "骨架类型", "叙事结构类型": "骨架类型",
508
+ "页序角色": "页序角色", pageOrder: "页序角色", "页序": "页序角色",
509
+ "只留 3 页留哪 3 页": "只留 3 页留哪 3 页", "只留3页": "只留 3 页留哪 3 页", keepThree: "只留 3 页留哪 3 页",
444
510
  };
445
511
  const DOC_DRAFT_PATH = (dir, docId) => (docId === "research" ? join(dir, "docs", "research", "index.md") : join(dir, "docs", `${docId}.md`));
446
512
  const DOC_TITLES = { materials: "支撑材料", research: "调研", feedback: "反馈" };
@@ -508,7 +574,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
508
574
  if (!value) continue;
509
575
  merged.push([fieldName, value]);
510
576
  }
511
- if (!merged.length && !storyline) throw new Error("intent 至少要给一个字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的)或故事线正文");
577
+ if (!merged.length && !storyline) throw new Error("intent 至少要给一个字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的 / 核心论证 / 骨架类型 / 页序角色 / 只留 3 页)或故事线正文");
512
578
  const lines = current.split("\n");
513
579
  const lineRe = /^(\s*-\s*\*\*)([^*]+)(\*\*\s*[::]\s*)(.*)$/;
514
580
  let lastFieldIndex = -1;
@@ -636,12 +702,37 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
636
702
  return Boolean(proposal.before) && !text.includes(proposal.before);
637
703
  };
638
704
  const briefOf = (slide) => String(slide.visualBrief ?? "").trim();
705
+ // F-181:逐页契约(角色 / 受众变化 / 关系 / 主张 / 证据)在 script 的 `**字段**:值` 行里;
706
+ // 老 deck 没有 → 只显示「未填」,不报错、不阻断出图。
707
+ const parsedForContract = parseSectionedPages(currentScript);
708
+ const fieldsOfPage = new Map(parsedForContract.map((page) => [page.page, page.fields ?? {}]));
709
+ const contractOfSlide = (slide) => validatePageContract(fieldsOfPage.get(slide.order) ?? {});
639
710
  const lines = slides.map((slide) => {
640
711
  const brief = briefOf(slide);
641
712
  const briefStyle = slide.visualBriefDesign ? `(写于 ${slide.visualBriefDesign} 风格)` : "(未记录写作风格)";
642
713
  const briefText = brief ? `${briefStyle}${brief.slice(0, 120)}${brief.length > 120 ? "…" : ""}` : "(空 · 这一页还不能出图)";
643
- return `P${String(slide.order).padStart(2, "0")} [${slide.id}] ${slide.title}\n 回答:${slide.question || "—"}\n 主张:${slide.claim || "—"}\n 画面要求:${briefText}\n 来源:${slide.source || "—"}`;
714
+ const contract = contractOfSlide(slide);
715
+ return `P${String(slide.order).padStart(2, "0")} [${slide.id}] ${slide.title}\n 回答:${slide.question || "—"}\n 主张:${slide.claim || "—"}\n 契约:${contractTag(contract)}\n 画面要求:${briefText}\n 来源:${slide.source || "—"}`;
644
716
  });
717
+ // F-181:整本契约汇总(一页都没填时只提示一句,不刷屏)。
718
+ const contractSummary = summarizeContracts(parsedForContract);
719
+ const missingContractPages = contractSummary.perPage.filter((item) => !item.ok);
720
+ const contractLine = contractSummary.untouched
721
+ ? "逐页契约:这本 deck 还没填(老 deck 正常)——新写页时建议每页补 角色 / 受众变化 / 关系 / 证据。"
722
+ : contractSummary.complete === contractSummary.pages
723
+ ? ""
724
+ : `逐页契约:${contractSummary.pages} 页里 ${contractSummary.complete} 页齐;缺的页:${missingContractPages.slice(0, 5).map((item) => `P${String(item.page).padStart(2, "0")}(${item.missing.join("、") || "有弱项"})`).join(";")}${missingContractPages.length > 5 ? ` …等 ${missingContractPages.length} 页` : ""}`;
725
+ // F-182(I-07):正文里的数字要么挂事实 ID、要么标「示意数据」;没标注只提示,不阻断。
726
+ const factAnalysis = analyzeFactLabels(
727
+ parsedForContract.map((page) => ({
728
+ page: page.page,
729
+ // 正文取 script 现场内容(manifest 的 body 只在提案落版时同步,读稿要按 script 判事实)。
730
+ body: page.fields?.["正文"] || page.content || "",
731
+ claim: page.fields?.["主张"] || page.claim || "",
732
+ evidence: readPageContract(page.fields ?? {}).证据,
733
+ })),
734
+ );
735
+ const factLine = factSummaryLine(factAnalysis);
645
736
  const linked = linkedDesignStyle(deck.id);
646
737
  const styleLine = linked
647
738
  ? `\n关联风格:${linked.abbr} ${linked.name}(出图时自动带上它的 styleBrief;visualBrief 只写这页具体画面,不要另起一套视觉语言)\n styleBrief:${linked.brief.slice(0, 200)}${linked.brief.length > 200 ? "…" : ""}${linked.constraints ? `\n 不要什么:${linked.constraints}` : ""}`
@@ -650,10 +741,15 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
650
741
  const densityLineText = densityLine(deck.contentDensity);
651
742
  // F-158:写 script 前把 intent 的听众 / 目的和文案纪律一起读给 Agent,不然它会按「通用文案」写。
652
743
  const brief = readDeckBrief(adapter.sourceRoot);
744
+ const skeleton = validateSkeleton(brief);
745
+ const skeletonLine = brief.skeleton?.argument || brief.skeleton?.type || brief.skeleton?.order
746
+ ? `论证骨架:${[brief.skeleton.argument, brief.skeleton.type, brief.skeleton.order].filter(Boolean).join(" | ")}${brief.skeleton.keepThree ? `\n只留 3 页:${brief.skeleton.keepThree}` : ""}${skeleton.hint ? `\n⚠ ${skeleton.hint}` : ""}`
747
+ : `⚠ ${skeleton.hint || "论证骨架还没写"}`;
653
748
  const briefLine = [
654
749
  brief.audience ? `听众:${brief.audience}(术语密度 / 例子 / 取舍按这个来)` : "听众:intent 里没写——先问用户,或按最小假设写明再写正文",
655
750
  brief.takeaway || brief.success ? `目的 / 成功标准:${brief.takeaway || brief.success}` : "",
656
751
  "文案纪律:标题≤16 字给判断(比喻 / 对仗 / 问句 / 「不是…而是…」都不要);副标题一句话说这页回答什么;正文先结论后证据,按听众调术语密度;不写 AI 黑话(赋能 / 闭环 / 抓手 / 生态位 / 底层逻辑 / 范式)。",
752
+ skeletonLine,
657
753
  ].filter(Boolean).join("\n");
658
754
  const pendingText = pending.length
659
755
  ? pending.map((proposal) => ` ${proposal.proposalId} · ${proposal.pageId} · ${proposal.field}${staleOf(proposal) ? " · 已过期(工作副本已变,需重新提案)" : ""}`).join("\n")
@@ -682,7 +778,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
682
778
  count: slides.length,
683
779
  slideIds: slides.map((slide) => slide.id),
684
780
  pendingProposals: pending.map((proposal) => ({ proposalId: proposal.proposalId, pageId: proposal.pageId, field: proposal.field, stale: staleOf(proposal) })),
685
- text: `deck ${deck.id}(${deck.title})· 正文 ${slides.length} 页${styleLine}\n${densityLineText}\n${briefLine}${syncLine}${longClaimLine}\n${lines.join("\n")}${appendixText}\n待确认提案(${pending.length}):\n${pendingText}\n(源:${adapter.sourceRoot} · 只读)`,
781
+ text: `deck ${deck.id}(${deck.title})· 正文 ${slides.length} 页${styleLine}\n${densityLineText}\n${briefLine}${contractLine ? `\n${contractLine}` : ""}${factLine ? `\n${factLine}` : ""}${syncLine}${longClaimLine}\n${lines.join("\n")}${appendixText}\n待确认提案(${pending.length}):\n${pendingText}\n(源:${adapter.sourceRoot} · 只读)`,
686
782
  outOfSyncPages: outOfSync.map((slide) => slide.id),
687
783
  };
688
784
  }),
@@ -693,7 +789,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
693
789
  name: "deck_script_propose_edit",
694
790
  description:
695
791
  "提出一页的修改提案:生成 unified diff 并进入 waiting-user,等用户在 Chat 里确认(或在工作台点确认)。" +
696
- "可改字段:title(标题)/ question(这页回答)/ body(正文,整页 3-5 句)/ claim(一句话判断,老 deck 兼容)/ visualBrief(画面要求,仅 manifest)。" +
792
+ "可改字段:title(标题)/ question(这页回答)/ body(正文,整页 3-5 句)/ claim(一句话判断,老 deck 兼容)/ visualBrief(画面要求,仅 manifest);逐页契约另可用 角色 / 受众变化 / 关系 / 证据 四个字段(script 里的 `**角色**:…` 行,缺行时自动插在标题后)。" +
697
793
  "写整页内容时 title+question+body 一起提;多页 / 整本重写用 deck_script_propose_edits 一次提交;" +
698
794
  "严禁在用户确认前调用 deck_revision_confirm。",
699
795
  parameters: {
@@ -701,9 +797,13 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
701
797
  properties: {
702
798
  deckId: { type: "string" },
703
799
  page: { type: "number", description: "页号(script 里的 N)" },
704
- field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
800
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), ...CONTRACT_FIELD_KEYS, "body", "visualBrief"]),
705
801
  newValue: { type: "string", description: "新值全文(不是增量)" },
706
802
  reason: { type: "string", description: "为什么改(写进 trace)" },
803
+ reviewRef: { type: "string", description: "这条改动对应评审清单里的哪一条(如 R03;修订确认用)" },
804
+ severity: { type: "string", description: "评审严重度 P0 / P1 / P2" },
805
+ confidence: { type: "string", description: "证据置信度 A / B / C / D;C(原文找不到)不许提" },
806
+ evidence: { type: "string", description: "评审给的原文证据(逐字,≥8 字)" },
707
807
  },
708
808
  required: ["deckId", "page", "field", "newValue"],
709
809
  additionalProperties: false,
@@ -764,6 +864,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
764
864
  value: String(args.newValue ?? "").trim(),
765
865
  diff,
766
866
  reason: String(args.reason ?? ""),
867
+ ...reviewMetaOf(args),
767
868
  status: "waiting-user",
768
869
  createdAt: proposalNow(),
769
870
  sessionId: sessionIdOf(exec),
@@ -993,6 +1094,56 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
993
1094
  });
994
1095
 
995
1096
 
1097
+ // ── 4.5 deck_draft_rollback(F-173 首稿自动落版的安全阀)──────────────────
1098
+ tools.push({
1099
+ name: "deck_draft_rollback",
1100
+ description:
1101
+ "回滚首稿:把稿子恢复到「首稿自动落版之前」的内容(追加一条新 revision,旧版全部保留)。" +
1102
+ "只在这份 deck 的最新 revision 是自动落版时可用;正式修订(用户确认过的版本)不适用,走提案流程。" +
1103
+ "用户说「这首稿不要了 / 回到之前 / 重新起草」时用它,然后报告回到了哪一版。",
1104
+ parameters: {
1105
+ type: "object",
1106
+ properties: {
1107
+ deckId: { type: "string" },
1108
+ note: { type: "string", description: "回滚原因(记进 revision 说明与事件,可选)" },
1109
+ },
1110
+ required: ["deckId"],
1111
+ additionalProperties: false,
1112
+ },
1113
+ output: { schema: { type: "object", properties: { deckId: { type: "string" }, revision: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
1114
+ execute: withToolEvent(store, "deck_draft_rollback", async (args, exec) => {
1115
+ const deckId = String(args.deckId ?? "").trim();
1116
+ if (!deckId) throw new Error("缺少 deckId");
1117
+ const deck = workspace.manifest(deckId);
1118
+ if (!deck) throw new Error(`未知 deck:${deckId}`);
1119
+ const latest = latestAutoland(workspace, deckId);
1120
+ if (!latest) throw new Error(`没有可回滚的首稿:${deckId} 最新 revision 不是自动落版(或已经回滚过)。正式修订请走提案流程。`);
1121
+ const result = rollbackFirstDraft({ workspace, deckId, note: String(args.note ?? "") });
1122
+ const current = workspace.scriptCurrent(deckId) ?? "";
1123
+ const sourceSync = typeof syncScriptToSource === "function" ? syncScriptToSource(deckId, current, null) : null;
1124
+ store.recordEvent({
1125
+ type: "deck.edit",
1126
+ sessionId: sessionIdOf(exec),
1127
+ taskId: null,
1128
+ deckId,
1129
+ slideIds: [],
1130
+ actor: "user",
1131
+ status: "succeeded",
1132
+ payload: { outcome: "autoland-rollback", fromRevision: result.beforeRevision, revision: result.revision, note: String(args.note ?? "") },
1133
+ });
1134
+ return {
1135
+ deckId,
1136
+ revision: result.revision,
1137
+ beforeRevision: result.beforeRevision,
1138
+ ...(sourceSync ? { sourceSync } : {}),
1139
+ text:
1140
+ `首稿已回滚:${result.beforeRevision} 的自动落版作废,稿子恢复到落版前内容,新 revision ${result.revision}(旧版都保留,可再回看)。` +
1141
+ `${sourceSync ? (sourceSync.ok ? "源 script.md 已回写并提交。" : `源 script.md 已回写,git 未提交:${sourceSync.error}。`) : ""}` +
1142
+ `\n接下来按用户要的方向重新起草;重新落版时仍走首稿自动落版(此时又回到「没有已落版稿子」的状态)。`,
1143
+ };
1144
+ }),
1145
+ });
1146
+
996
1147
  // ── 5. deck_render_page ────────────────────────────────────────────────────
997
1148
  tools.push({
998
1149
  name: "deck_render_page",
@@ -1593,6 +1744,97 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1593
1744
  }),
1594
1745
  });
1595
1746
 
1747
+ /**
1748
+ * F-186:普通 deck 会话里的「风格登记」——没先选风格,deck 做完后把这套视觉语言沉淀成 design。
1749
+ * 与 Designs 页「+添加风格」同一个创建路径(同一份样式卡 / 画布 / 注册表),
1750
+ * 区别:缩写可省略(自动生成),并可直接把当前 deck 已出图的页面复制成样张(不重新出图、不花额度)。
1751
+ */
1752
+ tools.push({
1753
+ name: "deck_style_create",
1754
+ description:
1755
+ "把一套视觉语言登记成风格(design):给名称 + 风格提示词(画面语言),并传当前 deck 的已出图页面当样张(直接复制成品图,不重新出图)。" +
1756
+ "默认挑 3 张(有封面 / 信息页 / 数据对比页最好,三张才能说明风格在各类版面上都成立);" +
1757
+ "用在普通 deck 会话里:用户没先选风格、或做完后说「这个风格好看 / 帮我登记 / 以后想复用」时调它;" +
1758
+ "不要回答「没有接口 / 只能去界面建」。登记后用户可在新建 deck 时选这个风格,也可以用 deck_design_bind 把它关联到当前 deck。" +
1759
+ "给已有风格补样张:传 designId(或缩写)+ deckId + pages,不传 name/styleBrief。" +
1760
+ "不需要用户先想缩写(不传 abbr 会自动生成);不要用它改风格提示词(改已有风格用 deck_style_update)。",
1761
+ parameters: {
1762
+ type: "object",
1763
+ properties: {
1764
+ designId: { type: "string", description: "已有风格 id 或缩写(只在「补样张」时传;传了就不新建,也不改名称/提示词)" },
1765
+ name: { type: "string", description: "风格名称(给人看的,如「深海琥珀」;新建时必填)" },
1766
+ styleBrief: { type: "string", description: "风格提示词本体:颜色 / 材质 / 字体 / 版式等精确画面语言(新建时必填;出图时用它)" },
1767
+ abbr: { type: "string", description: "风格缩写(2–6 位字母/数字,可省略,自动生成)" },
1768
+ description: { type: "string", description: "给人看的画面描述(可选,默认同 styleBrief)" },
1769
+ constraints: { type: "string", description: "不要什么(可选)" },
1770
+ reference: { type: "string", description: "参考(可选;只给人看,不进出图 prompt)" },
1771
+ deckId: { type: "string", description: "从哪个 deck 取样张(省略=当前会话绑定的 deck)" },
1772
+ pages: { type: "array", items: { type: "string" }, description: '用哪几页的成品图当样张(如 ["p01","p02","p03"];省略=该 deck 前 3 页有图的页)' },
1773
+ },
1774
+ required: [],
1775
+ additionalProperties: false,
1776
+ },
1777
+ output: {
1778
+ schema: {
1779
+ type: "object",
1780
+ properties: {
1781
+ designId: { type: "string" },
1782
+ abbr: { type: "string" },
1783
+ samples: { type: "array", items: { type: "object" } },
1784
+ },
1785
+ },
1786
+ render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
1787
+ },
1788
+ execute: withToolEvent(store, "deck_style_create", async (args, exec) => {
1789
+ if (typeof createStyle !== "function") throw new Error("当前挂载不支持创建风格(只在启用工具的 preset 会话里可用)");
1790
+ const designKey = String(args?.designId ?? "").trim();
1791
+ const name = String(args?.name ?? "").trim();
1792
+ const styleBrief = String(args?.styleBrief ?? "").trim();
1793
+ if (!designKey) {
1794
+ if (!name) throw new Error("缺少 name:先给风格起个名字(给已有风格补样张请传 designId)");
1795
+ if (!styleBrief) throw new Error("缺少 styleBrief:先写清这套风格的画面语言(颜色 / 材质 / 字体 / 版式)");
1796
+ }
1797
+ const sessionId = sessionIdOf(exec);
1798
+ const binding = sessionId ? designs.listBindings().find((item) => item.sessionId === sessionId) : null;
1799
+ const deckId = String(args?.deckId ?? "").trim()
1800
+ || (binding?.kind === "deck" ? String(binding.id) : "");
1801
+ const pages = Array.isArray(args?.pages) ? args.pages.map((page) => String(page)) : [];
1802
+ const created = createStyle({
1803
+ designId: designKey,
1804
+ name,
1805
+ abbr: String(args?.abbr ?? "").trim(),
1806
+ styleBrief,
1807
+ description: String(args?.description ?? "").trim(),
1808
+ constraints: String(args?.constraints ?? "").trim(),
1809
+ reference: String(args?.reference ?? "").trim(),
1810
+ deckId,
1811
+ pages,
1812
+ });
1813
+ const sampleLines = created.samples?.length
1814
+ ? created.samples.map((item) => `- ${item.pageId} ${item.revision} → designs/${created.abbr}/${item.file}(${(item.sizeBytes / 1024 / 1024).toFixed(1)} MB,复制成品图,未重新出图)`)
1815
+ : ["(这次没带样张;可以之后用 deck_design_sample 出,或在界面样张图片区补)"];
1816
+ const headline = created.appended
1817
+ ? `已给风格 ${created.abbr} · ${created.name} 补样张(designId: ${created.designId})`
1818
+ : `已登记风格:${created.abbr} · ${created.name}(designId: ${created.designId})`;
1819
+ const text = [
1820
+ headline,
1821
+ created.appended ? `现有样张合计 ${created.totalSamples ?? created.samples?.length ?? 0} 张` : `风格提示词:${styleBrief.slice(0, 120)}${styleBrief.length > 120 ? "…" : ""}`,
1822
+ "",
1823
+ `本次样张(${created.samples?.length ?? 0} 张):`,
1824
+ ...sampleLines,
1825
+ "",
1826
+ "以后新建 deck 时可以在「添加风格 / 新建 Deck」里选它;也可以把当前 deck 关联到它(deck_design_bind),之后出图会带这套 styleBrief。",
1827
+ "想验证这套风格换个版面是否稳定,用 deck_design_sample 出 1–2 张不同版面的样张再给用户看。",
1828
+ ].join("\n");
1829
+ return {
1830
+ designId: created.designId,
1831
+ abbr: created.abbr,
1832
+ samples: created.samples ?? [],
1833
+ text,
1834
+ };
1835
+ }),
1836
+ });
1837
+
1596
1838
  // ── N+1. deck_design_sample ────────────────────────────────────────────────
1597
1839
  tools.push({
1598
1840
  name: "deck_design_sample",
@@ -2018,20 +2260,28 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2018
2260
  }),
2019
2261
  });
2020
2262
 
2021
- // ── 4.5 deck_script_review(F-159)────────────────────────────────────────
2263
+ // ── 4.5 deck_script_review(F-159 听众评审;F-172 两段式盲审 → 冻结 → 对照)──
2022
2264
  tools.push({
2023
2265
  name: "deck_script_review",
2024
2266
  description:
2025
- "听众评审(动态 persona):按 intent 的「听众」生成独立审稿人(每个子 agent 只从该听众视角读整份 script)," +
2026
- "返回 P0/P1/P2 问题 + 原文证据 + 可直接落版的改法,写进 docs/review.experts.md。" +
2027
- "只审不改:评审结果不改 script,改稿仍走 deck_script_propose_edits + 用户确认。" +
2028
- "第一次整份 script 写完落版后跑一次;之后用户说「再审一次 / 再评一次」时再跑。",
2267
+ "听众评审(两段式):按 intent 的「听众」生成独立审稿人。第一段盲审——只给听众身份和稿件,不给意图," +
2268
+ "逐页四问(复述 / 承接 / 追问 / 下一步动作)后把结论冻结到 docs/review.blind.json;" +
2269
+ "第二段对照——把冻结结论和 intent 一起给出,逐页判「是 / 部分 / 否」并做读者读出 X / 稿件 S / 意图 Y 的差异。" +
2270
+ "phase=full(默认)跑完整两段;blind 只冻结;contrast 复用冻结记录(稿件改过会拒绝,需重跑盲审)。" +
2271
+ "写 docs/review.experts.md(覆盖矩阵 + 差异表)+ docs/review.findings.json(可采纳清单)。只审不改:不改 script;" +
2272
+ "改稿走 deck_script_propose_edits 并给每条 edit 带 reviewRef/severity/confidence/evidence,C 类(原文找不到证据)不许进提案。",
2029
2273
  parameters: {
2030
2274
  type: "object",
2031
2275
  properties: {
2032
2276
  deckId: { type: "string", description: "deck id,见 deck_source_overview" },
2277
+ phase: {
2278
+ type: "string",
2279
+ enum: ["full", "blind", "contrast"],
2280
+ description: "full(默认)= 盲审 + 冻结 + 对照;blind = 只跑盲审并冻结;contrast = 用已有冻结记录跑对照",
2281
+ },
2033
2282
  maxReviewers: { type: "number", description: `最多几个审稿人(1–${HARD_MAX_REVIEWERS},默认按 profile 配置,通常 2–3)` },
2034
- write: { type: "boolean", description: "是否写 docs/review.experts.md(默认 true;dry run 传 false)" },
2283
+ write: { type: "boolean", description: "是否写 docs/review.experts.md 与 docs/review.blind.json(默认 true;dry run 传 false)" },
2284
+ scores: { type: "boolean", description: "是否顺手打四维分(表意清晰 / 证据充分 / 结构连贯 / 意图达成,默认 true;false 时只出意见不打分)" },
2035
2285
  },
2036
2286
  required: ["deckId"],
2037
2287
  additionalProperties: false,
@@ -2050,7 +2300,10 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2050
2300
  const sessionId = sessionIdOf(exec);
2051
2301
  const brief = readDeckBrief(adapter.sourceRoot);
2052
2302
  const script = workspace.scriptCurrent(deck.id) ?? "";
2053
- const rules = readDeckRules(adapter.sourceRoot);
2303
+ const pages = pageIdsFromScript(script);
2304
+ const scriptHash = scriptSnapshotHash(script);
2305
+ const phase = ["full", "blind", "contrast"].includes(String(args.phase ?? "")) ? String(args.phase) : "full";
2306
+ const freezePath = join(deckDir, "docs", BLIND_FREEZE_FILE);
2054
2307
  const runtime = resolveSubagents();
2055
2308
  const maxReviewers = Math.min(
2056
2309
  HARD_MAX_REVIEWERS,
@@ -2085,8 +2338,8 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2085
2338
  reviewers: derived.reviewers,
2086
2339
  issues: [],
2087
2340
  text: textOnly(
2088
- "当前 profile 没有挂 subagent 服务(@deepseek-ai/dsh-subagent + dsh-subagent-spawn-in-process),无法起独立审稿人。\n" +
2089
- "在 profile 里挂上这三行后重试:dsh-subagent / dsh-subagent-spawn-in-process / dsh-tool-subagent。",
2341
+ "当前 profile 没有挂 subagent 服务(@deepseek-ai/dsh-subagent),无法启动插件内置审稿人。\n" +
2342
+ "请启用 DSH 子 Agent 服务后重试,不需要选择或安装审稿模式。",
2090
2343
  ),
2091
2344
  };
2092
2345
  }
@@ -2103,13 +2356,14 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2103
2356
  .join("\n")
2104
2357
  .trim();
2105
2358
  };
2106
- const runReviewer = async (reviewer) => {
2359
+ const runOne = async (reviewer, promptText, parseFn, validate, maxTokens) => {
2107
2360
  const controller = new AbortController();
2108
2361
  const onAbort = () => controller.abort();
2109
2362
  if (exec.signal?.aborted) controller.abort();
2110
2363
  else exec.signal?.addEventListener?.("abort", onAbort, { once: true });
2111
2364
  let timer;
2112
2365
  let activation;
2366
+ const outcome = { reviewer, label: reviewer.label };
2113
2367
  const timeout = new Promise((_resolve, reject) => {
2114
2368
  timer = setTimeout(() => {
2115
2369
  controller.abort();
@@ -2118,81 +2372,394 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2118
2372
  });
2119
2373
  try {
2120
2374
  activation = await runtime.startActivation({
2121
- provider: "spawn",
2375
+ provider: REVIEW_AGENT_PROVIDER,
2122
2376
  label: `deck-review:${deck.id}:${reviewer.id}`,
2123
2377
  request: {
2124
2378
  prompt: [
2125
2379
  {
2126
2380
  type: "text",
2127
- text: reviewerPrompt({
2128
- reviewer,
2129
- brief,
2130
- deckTitle: deck.title,
2131
- script,
2132
- rulesText: String(rules.content ?? "").slice(0, 4000),
2133
- }),
2381
+ text: promptText,
2134
2382
  },
2135
2383
  ],
2384
+ ...(Number.isSafeInteger(maxTokens) && maxTokens > 0 ? { agentOptions: { maxTokens } } : {}),
2136
2385
  parent: exec.agent,
2137
- ...(reviewPreset || config.review?.presetId ? { agentPreset: reviewPreset || config.review?.presetId } : {}),
2138
2386
  },
2139
2387
  signal: controller.signal,
2140
2388
  delivery: "caller",
2141
2389
  });
2142
2390
  const result = await Promise.race([activation.result, timeout]);
2391
+ if (controller.signal.aborted) throw new Error("听众评审已取消或超时");
2392
+ if (result?.stopReason !== "completed") throw new Error(`审稿人未正常完成(${result?.stopReason ?? "unknown"})`);
2143
2393
  const text = activationResultText(result);
2144
- const parsed = parseReviewerOutput(text);
2145
- return { reviewer, label: reviewer.label, parsed, text, stopReason: result?.stopReason ?? "" };
2394
+ const parsed = parseFn(text);
2395
+ if (parsed.unparsed) throw new Error("审稿人未返回符合契约的 JSON");
2396
+ if (validate) validate(parsed);
2397
+ Object.assign(outcome, { parsed, text, stopReason: result.stopReason });
2146
2398
  } catch (error) {
2147
- return { reviewer, label: reviewer.label, error: error instanceof Error ? error.message : String(error) };
2399
+ outcome.error = error instanceof Error ? error.message : String(error);
2148
2400
  } finally {
2149
2401
  clearTimeout(timer);
2150
2402
  exec.signal?.removeEventListener?.("abort", onAbort);
2151
2403
  try {
2152
2404
  await activation?.dispose?.();
2153
2405
  } catch {
2154
- /* 清理失败不影响评审结果 */
2406
+ outcome.error = "审稿执行清理失败,请检查 DSH 子任务状态";
2407
+ }
2408
+ }
2409
+ return outcome;
2410
+ };
2411
+
2412
+ // ── 第一段:盲审(只给身份与稿件,不给意图)→ 冻结 ──────────────────
2413
+ const runBlindPhase = async () => {
2414
+ const outcomes = await Promise.all(
2415
+ derived.reviewers.map((reviewer) =>
2416
+ runOne(
2417
+ reviewer,
2418
+ blindReviewerPrompt({ reviewer, brief, deckTitle: deck.title, script }),
2419
+ parseBlindOutput,
2420
+ undefined,
2421
+ Math.min(32_000, 8192 + pages.length * 1200),
2422
+ ),
2423
+ ),
2424
+ );
2425
+ const ok = outcomes.filter((outcome) => !outcome.error);
2426
+ const failed = outcomes.filter((outcome) => outcome.error).map((outcome) => ({ label: outcome.label, error: outcome.error }));
2427
+ const reviewers = ok.map((outcome) => ({
2428
+ label: outcome.reviewer.label,
2429
+ reviewer: outcome.parsed.blind.reviewer,
2430
+ whole: outcome.parsed.blind.whole,
2431
+ per_page: outcome.parsed.blind.per_page,
2432
+ }));
2433
+ const answered = new Set(reviewers.flatMap((item) => item.per_page.map((row) => row.page)));
2434
+ const missing = pages.filter((page) => !answered.has(page));
2435
+ const partial = pages.filter((page) => {
2436
+ const hits = reviewers.filter((item) => item.per_page.some((row) => row.page === page)).length;
2437
+ return hits > 0 && hits < reviewers.length;
2438
+ });
2439
+ const coverage = { expected: pages.length, answered: answered.size, missing, partial, complete: missing.length === 0 };
2440
+ return { outcomes, failed, reviewers, coverage };
2441
+ };
2442
+
2443
+ // ── 第二段:对照(冻结结论 + 意图,做 X / S / Y 三方差分)────────────
2444
+ const runContrastPhase = async (frozenRecord) => {
2445
+ const byLabel = new Map((frozenRecord?.reviewers ?? []).map((item) => [item.label, item]));
2446
+ const outcomes = await Promise.all(
2447
+ derived.reviewers.map((reviewer) => {
2448
+ const prev = byLabel.get(reviewer.label) ?? frozenRecord?.reviewers?.[0];
2449
+ if (!prev) {
2450
+ return Promise.resolve({ reviewer, label: reviewer.label, error: "冻结记录里没有这位审稿人的盲审结论" });
2451
+ }
2452
+ return runOne(
2453
+ reviewer,
2454
+ contrastReviewerPrompt({
2455
+ reviewer,
2456
+ blind: { reviewer: prev.reviewer ?? prev.label, whole: prev.whole, per_page: prev.per_page },
2457
+ brief,
2458
+ deckTitle: deck.title,
2459
+ script,
2460
+ }),
2461
+ parseContrastOutput,
2462
+ (parsed) => {
2463
+ const bad = parsed.contrast.gaps.find((gap) => gap.evidence && !scriptContainsEvidence(script, gap.evidence));
2464
+ if (bad) throw new Error(`对照意见的证据不是稿件原文(${bad.page}),已拒绝采纳`);
2465
+ },
2466
+ Math.min(24_000, 6000 + pages.length * 900),
2467
+ );
2468
+ }),
2469
+ );
2470
+ const ok = outcomes.filter((outcome) => !outcome.error);
2471
+ const failed = outcomes.filter((outcome) => outcome.error).map((outcome) => ({ label: outcome.label, error: outcome.error }));
2472
+ // 每页一条差距:多位审稿人对同一页的判定合并(悲观取值 否 > 部分 > 是),
2473
+ // 读者读出 / 改法取第一条非空,来源记全部审稿人——报告里一页一行,不按审稿人刷屏。
2474
+ const rank = { 是: 0, 部分: 1, 否: 2 };
2475
+ const byPage = new Map();
2476
+ for (const outcome of ok) {
2477
+ for (const gap of outcome.parsed.contrast.gaps) {
2478
+ const label = outcome.reviewer.label;
2479
+ const prev = byPage.get(gap.page);
2480
+ if (!prev) {
2481
+ byPage.set(gap.page, { ...gap, reviewer: label, from: [label] });
2482
+ continue;
2483
+ }
2484
+ if ((rank[gap.verdict] ?? -1) > (rank[prev.verdict] ?? -1)) {
2485
+ byPage.set(gap.page, { ...gap, reviewer: label, from: [...prev.from, label] });
2486
+ continue;
2487
+ }
2488
+ prev.from.push(label);
2489
+ prev.x = prev.x || gap.x;
2490
+ prev.y = prev.y || gap.y;
2491
+ prev.fix = prev.fix || gap.fix;
2492
+ prev.evidence = prev.evidence || gap.evidence;
2155
2493
  }
2156
2494
  }
2495
+ const gaps = [...byPage.values()]
2496
+ .map((gap) => ({ ...gap, reviewer: gap.from.join(" / ") }))
2497
+ .sort((a, b) => a.page.localeCompare(b.page));
2498
+ const deckGap = ok.map((outcome) => outcome.parsed.contrast.deck_gap).filter(Boolean).join(" / ");
2499
+ const deleteCandidates = [...new Set(ok.flatMap((outcome) => outcome.parsed.contrast.delete_candidates))];
2500
+ return { outcomes, failed, gaps, deckGap, deleteCandidates };
2157
2501
  };
2158
2502
 
2159
- // 审稿人并发跑:每个都是一次独立子 agent,互不影响;失败一个不拖垮整份评审。
2160
- const outcomes = await Promise.all(derived.reviewers.map((reviewer) => runReviewer(reviewer)));
2161
- const ok = outcomes.filter((outcome) => !outcome.error);
2162
- const failed = outcomes.filter((outcome) => outcome.error).map((outcome) => ({ label: outcome.label, error: outcome.error }));
2163
- const results = ok.map((outcome) => ({
2164
- reviewer: outcome.parsed.reviewer || outcome.reviewer.label,
2165
- label: outcome.reviewer.label,
2166
- issues: outcome.parsed.issues,
2503
+ const generatedAt = new Date().toISOString();
2504
+ const target = join(deckDir, "docs", REVIEW_REPORT_FILE);
2505
+ const writeFiles = args.write !== false && !exec.signal?.aborted;
2506
+ const labels = derived.reviewers.map((reviewer) => reviewer.label).join(" / ");
2507
+ let written = false;
2508
+ let frozen = null;
2509
+
2510
+ // 只跑对照:必须用当轮稿件哈希匹配的冻结记录,稿子变了就拒绝(防止拿旧结论评新稿)。
2511
+ if (phase === "contrast") {
2512
+ if (!existsSync(freezePath)) {
2513
+ return {
2514
+ deckId: deck.id,
2515
+ phase,
2516
+ reviewers: [],
2517
+ issues: [],
2518
+ text: `还没有盲审冻结记录(docs/${BLIND_FREEZE_FILE}):先跑 phase=blind(或 full)再对照。`,
2519
+ };
2520
+ }
2521
+ try {
2522
+ frozen = JSON.parse(readFileSync(freezePath, "utf8"));
2523
+ } catch {
2524
+ frozen = null;
2525
+ }
2526
+ const check = verifyFreeze(frozen, scriptHash);
2527
+ if (!check.ok) {
2528
+ return {
2529
+ deckId: deck.id,
2530
+ phase,
2531
+ scriptHash,
2532
+ reviewers: [],
2533
+ issues: [],
2534
+ text: `${check.reason}(冻结 ${frozen?.scriptHash ?? "无"} / 当前 ${scriptHash})`,
2535
+ };
2536
+ }
2537
+ }
2538
+
2539
+ // 盲审(full 也走这里):先冻结再对照,顺序不能反。
2540
+ let blind = null;
2541
+ if (phase === "full" || phase === "blind") {
2542
+ blind = await runBlindPhase();
2543
+ if (writeFiles && blind.reviewers.length) {
2544
+ mkdirSync(join(deckDir, "docs"), { recursive: true });
2545
+ frozen = makeFreezeRecord({ deckId: deck.id, scriptHash, pages, reviewers: blind.reviewers, generatedAt });
2546
+ writeFileSync(freezePath, `${JSON.stringify(frozen, null, 2)}\n`, "utf8");
2547
+ }
2548
+ const coverLine = `覆盖 ${blind.coverage.answered}/${blind.coverage.expected} 页${blind.coverage.missing.length ? `(缺答 ${blind.coverage.missing.join("、")})` : ""}`;
2549
+ const failLine = blind.failed.length
2550
+ ? `\n${blind.failed.length} 位审稿人失败:${blind.failed.map((item) => `${item.label}(${item.error})`).join(" / ")}`
2551
+ : "";
2552
+ if (phase === "blind") {
2553
+ return {
2554
+ deckId: deck.id,
2555
+ phase,
2556
+ scriptHash,
2557
+ reviewers: derived.reviewers.map((reviewer) => ({ id: reviewer.id, label: reviewer.label })),
2558
+ coverage: blind.coverage,
2559
+ issues: [],
2560
+ failed: blind.failed,
2561
+ written: Boolean(frozen),
2562
+ text: `盲审${frozen ? "已冻结" : "完成(dry run 未冻结)"}:${coverLine}${failLine}\n下一步跑 phase=contrast 才知道读者读出和意图差在哪。`,
2563
+ };
2564
+ }
2565
+ if (!blind.reviewers.length) {
2566
+ return {
2567
+ deckId: deck.id,
2568
+ phase,
2569
+ scriptHash,
2570
+ reviewers: derived.reviewers.map((reviewer) => ({ id: reviewer.id, label: reviewer.label })),
2571
+ coverage: blind.coverage,
2572
+ issues: [],
2573
+ failed: blind.failed,
2574
+ written: false,
2575
+ text: `盲审没有有效结果,不能进入对照(也不据此判断稿件没有问题):${coverLine}${failLine}`,
2576
+ };
2577
+ }
2578
+ frozen = frozen ?? makeFreezeRecord({ deckId: deck.id, scriptHash, pages, reviewers: blind.reviewers, generatedAt });
2579
+ }
2580
+
2581
+ if (!blind) {
2582
+ // 只跑对照:从冻结记录里重建盲审视图(冻结记录就是第一段的事实来源)。
2583
+ const frozenReviewers = (frozen?.reviewers ?? []).map((item) => ({
2584
+ label: item.label,
2585
+ reviewer: item.reviewer,
2586
+ whole: item.whole,
2587
+ per_page: item.per_page ?? [],
2588
+ }));
2589
+ const answered = new Set(frozenReviewers.flatMap((item) => item.per_page.map((row) => row.page)));
2590
+ blind = {
2591
+ outcomes: [],
2592
+ failed: [],
2593
+ reviewers: frozenReviewers,
2594
+ coverage: {
2595
+ expected: (frozen?.pages ?? pages).length,
2596
+ answered: answered.size,
2597
+ missing: (frozen?.pages ?? pages).filter((page) => !answered.has(page)),
2598
+ partial: [],
2599
+ complete: true,
2600
+ },
2601
+ };
2602
+ }
2603
+
2604
+ const contrast = await runContrastPhase(frozen);
2605
+ // F-176:可采纳清单 + G1 逐字匹配(B6 四道闸的第一道,程序判、不花模型)。
2606
+ const built = buildFindings(script, contrast.gaps);
2607
+ // F-177 / B6 G4:同一页被 ≥2 位独立审稿人命中 → 交叉确认,置信度不再只是单人口径。
2608
+ let findings = built.findings.map((finding) => ({
2609
+ ...finding,
2610
+ reviewers: [...new Set(contrast.gaps.filter((gap) => gap.page === finding.page).flatMap((gap) => gap.from ?? []))],
2167
2611
  }));
2168
- const issues = mergeIssues(results);
2612
+ findings = findings.map((finding) => ({ ...finding, crossReviewed: finding.reviewers.length >= 2, reviewers: finding.reviewers.length }));
2613
+ let dropped = built.dropped;
2614
+ let citation = { checked: 0, citationChecked: false, failed: "" };
2615
+ // F-177 / B6 G3:引用核查——一次独立小调用,校对员角色(不是评审员);
2616
+ // 只核对 A 类条目,把 paraphrase 降到 B、unsupported 降到 D(D 仍列出但只作参考)。
2617
+ if (findings.length && args.citationCheck !== false && exec?.agent) {
2618
+ try {
2619
+ const promptText = citationCheckPrompt({
2620
+ items: findings.map((finding) => ({ id: finding.id, page: finding.page, evidence: finding.evidence, problem: finding.fix })),
2621
+ script,
2622
+ deckTitle: deck.title,
2623
+ });
2624
+ const verifier = { id: "citation", label: "引用核查" };
2625
+ // runOne 是评审员的通用入口(解析 + 校验 + 超时);这里复用同一套隔离与预算。
2626
+ const outcome = await runOne(
2627
+ verifier,
2628
+ promptText,
2629
+ (text) => parseCitationCheck(text),
2630
+ null,
2631
+ Math.min(8000, 1200 + findings.length * 120),
2632
+ );
2633
+ if (outcome.error) throw new Error(outcome.error);
2634
+ const applied = applyCitationChecks(findings, outcome.parsed.checks);
2635
+ findings = applied.findings;
2636
+ citation = { checked: applied.checked, citationChecked: applied.citationChecked, failed: "" };
2637
+ } catch (error) {
2638
+ citation = { checked: 0, citationChecked: false, failed: String(error?.message ?? error) };
2639
+ }
2640
+ }
2641
+ // F-183(I-08):四维评分——对照之后只打分、不出新意见;默认开,args.scores === false 可跳过。
2642
+ // 分数带 scriptHash / revision 落盘,两次评审可对照(「改完是不是更好了」不用凭感觉)。
2643
+ let scoreMean = null;
2644
+ let scoreCompare = null;
2645
+ let scoreEntry = null;
2646
+ const scoreFailed = [];
2647
+ if (phase === "full" && args.scores !== false && exec?.agent && derived.reviewers.length) {
2648
+ const records = [];
2649
+ for (const reviewer of derived.reviewers) {
2650
+ try {
2651
+ const outcome = await runOne(
2652
+ reviewer,
2653
+ scoreReviewerPrompt({ reviewer, brief, deckTitle: deck.title, script }),
2654
+ (text) => parseScoreOutput(text),
2655
+ null,
2656
+ 2000,
2657
+ );
2658
+ if (outcome.error) throw new Error(outcome.error);
2659
+ if (!outcome.parsed?.record) throw new Error("打分输出缺维度或分数越界");
2660
+ records.push(outcome.parsed.record);
2661
+ } catch (error) {
2662
+ scoreFailed.push(`${reviewer.label}(${error?.message ?? error})`);
2663
+ }
2664
+ }
2665
+ if (records.length) {
2666
+ scoreMean = mergeScoreRecords(records);
2667
+ const scoresFile = join(deckDir, "docs", "review.scores.json");
2668
+ const history = existsSync(scoresFile) ? JSON.parse(readFileSync(scoresFile, "utf8")) : [];
2669
+ const historyList = Array.isArray(history) ? history : [];
2670
+ const previous = historyList[historyList.length - 1] ?? null;
2671
+ scoreCompare = compareScores(previous ? { dimensions: previous.scores, average: previous.average } : null, scoreMean);
2672
+ const latestRevision = workspace.listScriptRevisions(deck.id)[0]?.revision ?? "";
2673
+ scoreEntry = scoreHistoryEntry({ at: generatedAt, scriptHash, revision: latestRevision, mean: scoreMean, compare: scoreCompare });
2674
+ if (writeFiles) {
2675
+ historyList.push(scoreEntry);
2676
+ const kept = historyList.slice(-50);
2677
+ writeFileSync(scoresFile, `${JSON.stringify(kept, null, 2)}\n`, "utf8");
2678
+ writeFileSync(join(deckDir, "docs", "review.scores.md"), renderScoreHistoryMarkdown(kept, { deckTitle: deck.title }), "utf8");
2679
+ }
2680
+ }
2681
+ }
2682
+ const issues = mergeIssues([{ label: "", issues: gapsToIssues(contrast.gaps) }]);
2169
2683
  const bySeverity = { P0: 0, P1: 0, P2: 0 };
2170
2684
  for (const issue of issues) bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
2171
- const generatedAt = new Date().toISOString();
2172
- const target = join(deckDir, "docs", "review.experts.md");
2173
- let written = false;
2174
- if (args.write !== false) {
2685
+ const mergedBlind = mergeBlind(blind.reviewers.map((item) => ({ label: item.label, blind: item })));
2686
+ const failed = [...blind.failed, ...contrast.failed];
2687
+ const counts = { 是: 0, 部分: 0, 否: 0 };
2688
+ for (const gap of contrast.gaps) counts[gap.verdict] = (counts[gap.verdict] ?? 0) + 1;
2689
+
2690
+ if (writeFiles) {
2175
2691
  mkdirSync(dirname(target), { recursive: true });
2176
- const unparsed = ok
2177
- .filter((outcome) => outcome.parsed.unparsed)
2178
- .map((outcome) => `### ${outcome.label}\n\n${outcome.text.slice(0, 4000)}`)
2179
- .join("\n\n");
2180
- const body = [
2181
- renderReviewMarkdown({
2182
- deckTitle: deck.title,
2183
- brief,
2184
- reviewers: derived.reviewers,
2185
- issues,
2186
- generatedAt,
2187
- failed,
2188
- }),
2189
- ...(unparsed ? ["", "## 未按 JSON 输出、原文保留", "", unparsed] : []),
2190
- ].join("\n");
2692
+ const body = renderProtocolMarkdown({
2693
+ deckTitle: deck.title,
2694
+ generatedAt,
2695
+ scriptHash,
2696
+ reviewers: derived.reviewers,
2697
+ mergedBlind,
2698
+ coverage: blind.coverage,
2699
+ gaps: contrast.gaps,
2700
+ deckGap: contrast.deckGap,
2701
+ deleteCandidates: contrast.deleteCandidates,
2702
+ failed,
2703
+ });
2191
2704
  writeFileSync(target, body.endsWith("\n") ? body : `${body}\n`, "utf8");
2705
+ // 工作台「评审意见清单」直接读这份清单(不依赖 Agent 再复述一遍),C 类留在 dropped 里可审计。
2706
+ writeFileSync(
2707
+ join(deckDir, "docs", "review.findings.json"),
2708
+ `${JSON.stringify({ deckId: deck.id, generatedAt, scriptHash, findings, dropped }, null, 2)}\n`,
2709
+ "utf8",
2710
+ );
2192
2711
  written = true;
2712
+ // F-186:人话版两本——听众视角的读后感 + 负责人视角的意图差距。
2713
+ // 数据全部来自上面的盲审 / 对照 / 清单,不额外调模型;改法建议按页取「最重的一条」。
2714
+ const fixSuggestions = [...contrast.gaps]
2715
+ .filter((gap) => gap.verdict !== "是" && gap.fix)
2716
+ .sort((a, b) => (a.verdict === "否" ? -1 : 0) - (b.verdict === "否" ? -1 : 0));
2717
+ const readersPath = "docs/review.readers.md";
2718
+ const gapPath = "docs/review.gap.md";
2719
+ let titleChain = null;
2720
+ try {
2721
+ const pageItems = pageIdsFromScript(script).map((page, index) => ({
2722
+ page,
2723
+ title: script.split(/\n(?=##\s|P\d+\s)/)[index]?.split("\n")[0]?.replace(/^#+\s*/, "").trim() ?? "",
2724
+ claim: "",
2725
+ }));
2726
+ titleChain = analyzeTitleChain(pageItems);
2727
+ } catch {
2728
+ titleChain = null;
2729
+ }
2730
+ const titleChainLine = titleChain ? `${titleChain.verdict}${titleChain.stats ? `(${Object.entries(titleChain.stats).map(([key, value]) => `${key} ${value}`).join(" · ")})` : ""}` : null;
2731
+ // 页标题给听众文档用(盲审结果里没有标题,只有页号)
2732
+ const pageTitles = Object.fromEntries([...script.matchAll(/^###\s+(P\d+)\s*·\s*(.+)$/gm)].map((match) => [match[1], match[2].trim()]));
2733
+ const readersBody = renderReaderReviewMarkdown({
2734
+ deckTitle: deck.title,
2735
+ generatedAt,
2736
+ audience: brief.audience ?? "",
2737
+ gaps: contrast.gaps,
2738
+ mergedBlind,
2739
+ deckGap: contrast.deckGap,
2740
+ fixSuggestions,
2741
+ pageTitles,
2742
+ });
2743
+ const gapBody = renderGapReviewMarkdown({
2744
+ deckTitle: deck.title,
2745
+ generatedAt,
2746
+ scriptHash,
2747
+ audience: brief.audience ?? "",
2748
+ intentText: [brief.audience, brief.takeaway].filter(Boolean).join(" · "),
2749
+ gaps: contrast.gaps,
2750
+ mergedBlind,
2751
+ deckGap: contrast.deckGap,
2752
+ findings,
2753
+ dropped,
2754
+ scores: scoreMean ? { average: scoreMean.average, dimensions: scoreMean.dimensions, compare: scoreCompare } : null,
2755
+ titleChain: titleChainLine ? { verdict: titleChainLine } : null,
2756
+ fixSuggestions,
2757
+ });
2758
+ writeFileSync(join(deckDir, "docs", "review.readers.md"), readersBody, "utf8");
2759
+ writeFileSync(join(deckDir, "docs", "review.gap.md"), gapBody, "utf8");
2193
2760
  appendChangelogNote({
2194
2761
  deckDir,
2195
- note: `听众评审(${derived.reviewers.map((reviewer) => reviewer.label).join(" / ")}):P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2}`,
2762
+ note: `听众评审(两段式:盲审 ${blind.coverage.answered}/${blind.coverage.expected} 页 → 对照 是${counts.是}/部分${counts.部分}/否${counts.否})`,
2196
2763
  actor: "agent",
2197
2764
  });
2198
2765
  }
@@ -2201,29 +2768,64 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2201
2768
  deckId: deck.id,
2202
2769
  sessionId,
2203
2770
  actor: "agent",
2204
- status: failed.length === derived.reviewers.length ? "failed" : "succeeded",
2771
+ status: contrast.failed.length && contrast.outcomes.length === contrast.failed.length ? "failed" : "succeeded",
2205
2772
  payload: {
2773
+ phase,
2774
+ scriptHash,
2206
2775
  reviewers: derived.reviewers.map((reviewer) => reviewer.id),
2207
2776
  audience: brief.audience ?? "",
2777
+ coverage: { expected: blind.coverage.expected, answered: blind.coverage.answered, missing: blind.coverage.missing },
2778
+ verdicts: counts,
2208
2779
  total: issues.length,
2209
2780
  bySeverity,
2210
2781
  failed: failed.map((item) => item.label),
2211
2782
  written,
2212
2783
  },
2213
2784
  });
2214
- const lines = issues.length
2215
- ? issues.map((issue) => `${issue.severity} · ${issue.page || "—"} · ${issue.problem} → ${issue.suggestion}${issue.reviewer ? `(${issue.reviewer})` : ""}`)
2216
- : ["没有可操作的问题(可能听众视角下确实没问题,也可能审稿人没读懂——先看 docs/review.experts.md 的原始评审)"];
2217
2785
  const failureLine = failed.length
2218
2786
  ? `\n${failed.length} 位审稿人失败:${failed.map((item) => `${item.label}(${item.error})`).join(" / ")}`
2219
2787
  : "";
2220
2788
  return {
2221
2789
  deckId: deck.id,
2790
+ phase,
2791
+ scriptHash,
2222
2792
  reviewers: derived.reviewers.map((reviewer) => ({ id: reviewer.id, label: reviewer.label, audience: reviewer.audience })),
2793
+ coverage: blind.coverage,
2794
+ blind: mergedBlind,
2795
+ gaps: contrast.gaps,
2796
+ deckGap: contrast.deckGap,
2797
+ deleteCandidates: contrast.deleteCandidates,
2223
2798
  issues,
2799
+ findings,
2800
+ dropped,
2801
+ citation,
2224
2802
  bySeverity,
2803
+ failed,
2225
2804
  written,
2226
- text: `听众评审(${derived.reviewers.map((reviewer) => reviewer.label).join(" / ")}):P0 ${bySeverity.P0} / P1 ${bySeverity.P1} / P2 ${bySeverity.P2}${failureLine}\n${lines.join("\n")}${written ? `\n已写入:${target}` : "\n(dry run,未写文件)"}\n这些是评审意见,不是已改稿:按 P0/P1 用 deck_script_propose_edits 提案,等用户确认后再落版。`,
2805
+ text:
2806
+ // F-186:先讲人话(听众读到了什么 / 卡在哪 / 先改哪几页),内部口径压到括号里一行。
2807
+ plainReviewSummary({
2808
+ deckTitle: deck.title,
2809
+ gaps: contrast.gaps,
2810
+ mergedBlind,
2811
+ deckGap: contrast.deckGap,
2812
+ readersDoc: written ? "docs/review.readers.md" : "",
2813
+ gapDoc: written ? "docs/review.gap.md" : "",
2814
+ missing: failed.map((item) => item.label),
2815
+ }) +
2816
+ `\n\n(内部口径:审稿人 ${labels};盲审覆盖 ${blind.coverage.answered}/${blind.coverage.expected} 页;对照 是 ${counts.是} / 部分 ${counts.部分} / 否 ${counts.否}${scoreMean ? `;四维均分 ${scoreMean.average}` : ""}${failureLine.replace(/\n/g, " ")})` +
2817
+ `${written ? `\n已写入:${target} · docs/review.readers.md · docs/review.gap.md(冻结:docs/${BLIND_FREEZE_FILE})` : "\n(dry run,未写文件)"}` +
2818
+ `\n给谁念:先按上面人话讲给用户听(不要念 P0/P1/置信度),再问要不要按「先改哪几页」逐条提案。\n` +
2819
+ `可采纳清单(证据在稿件里逐字命中,A 可定位 / B 转述或支撑偏弱 / D 参考):${findings.length ? findings.map((item) => `${item.id} · ${item.page} · ${item.severity} · ${item.confidence}${item.crossReviewed ? " · 交叉确认" : ""}`).join(" / ") : "无"}` +
2820
+ `${citation.citationChecked ? `\n引用核查:${citation.checked} 条已由独立校对核对(G3)` : citation.failed ? `\n引用核查未完成:${citation.failed}(A 类按 G1 结果保留,回复里要说明没做 G3)` : ""}` +
2821
+ `${dropped.length ? `\n无法核实(C 类,禁止据此改稿,只告诉用户):${dropped.map((item) => `${item.id} · ${item.page}`).join(" / ")}` : ""}` +
2822
+ "\n改稿时按清单逐条提案:deck_script_propose_edits 的每条 edit 带上 reviewRef(如 R01)+ severity + confidence + evidence;C 类一条都不要提。\n" +
2823
+ "第二轮用的是第一段冻结的盲审结论,不是重新评审;工作台脚本页会把这批意见列成清单,用户勾选后点「接受并更新」合成一条 revision。" +
2824
+ (scoreMean ? `\n${renderScoreSummary({ mean: scoreMean, compare: scoreCompare })}` : "") +
2825
+ (scoreFailed.length ? `\n评分未完成:${scoreFailed.join(" / ")}` : ""),
2826
+ scores: scoreMean,
2827
+ scoreCompare,
2828
+ scoreFailed,
2227
2829
  };
2228
2830
  }),
2229
2831
  });
@@ -2264,7 +2866,18 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2264
2866
  const current = workspace.scriptCurrent(deck.id);
2265
2867
  if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
2266
2868
  const parsed = parseSectionedPages(current);
2267
- const byTitle = new Map(parsed.map((page) => [String(page.title ?? "").trim(), page]));
2869
+ // 只拿「唯一标题」做标题匹配:新建 deck 的占位稿 6 页标题可能全是「待定」,
2870
+ // 用标题建 Map 会互相覆盖,把对得上的页全报成错位(S22 真机暴露的假阳性)。
2871
+ const titleCounts = new Map();
2872
+ for (const page of parsed) {
2873
+ const title = String(page.title ?? "").trim();
2874
+ titleCounts.set(title, (titleCounts.get(title) ?? 0) + 1);
2875
+ }
2876
+ const byTitle = new Map(
2877
+ parsed
2878
+ .filter((page) => titleCounts.get(String(page.title ?? "").trim()) === 1)
2879
+ .map((page) => [String(page.title ?? "").trim(), page]),
2880
+ );
2268
2881
  const problems = [];
2269
2882
  const blocks = [];
2270
2883
  deck.slides.forEach((slide, index) => {
@@ -2327,6 +2940,15 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2327
2940
  type: "object",
2328
2941
  properties: {
2329
2942
  deckId: { type: "string" },
2943
+ autoland: {
2944
+ type: "boolean",
2945
+ description:
2946
+ "首稿自动落版:仅当这份 deck 一次都没落过 revision(首稿)时可用——提交的这批修改立即落成 revision,不再逐条等用户确认。" +
2947
+ "有历史版本的 deck 会被工具拒绝(走普通提案 + 用户确认)。自改轮次 / 摘要请一起传,落版会记进 autoland 元数据。",
2948
+ },
2949
+ selfReviewRounds: { type: "number", description: "首稿自改跑了几轮(0–3;自动落版记录用)" },
2950
+ selfReviewSummary: { type: "string", description: "自改摘要:主要改了哪几类(≤80 字,落版后要原样告诉用户)" },
2951
+ reviewId: { type: "string", description: "这次自改依据的评审记录 id / 路径(可选)" },
2330
2952
  edits: {
2331
2953
  type: "array",
2332
2954
  minItems: 1,
@@ -2335,9 +2957,13 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2335
2957
  type: "object",
2336
2958
  properties: {
2337
2959
  page: { type: "number", description: "页号(script 里的 N)" },
2338
- field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
2960
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), ...CONTRACT_FIELD_KEYS, "body", "visualBrief"]),
2339
2961
  newValue: { type: "string", description: "新值全文(不是增量)" },
2340
2962
  reason: { type: "string" },
2963
+ reviewRef: { type: "string", description: "评审清单条目号(如 R03)" },
2964
+ severity: { type: "string", description: "P0 / P1 / P2" },
2965
+ confidence: { type: "string", description: "A / B / C / D;C 不许提" },
2966
+ evidence: { type: "string", description: "评审原文证据(逐字)" },
2341
2967
  },
2342
2968
  required: ["page", "field", "newValue"],
2343
2969
  additionalProperties: false,
@@ -2372,32 +2998,92 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2372
2998
  if (!single) throw new Error("内部错误:找不到 deck_script_propose_edit");
2373
2999
  const created = [];
2374
3000
  const styleHints = [];
3001
+ // F-183 附带:一条坏 edit 不许拖垮整批(60 条里 1 条定位不到,另外 59 条应该照常提案)。
3002
+ // 但首稿自动落版例外——半份稿自动落库比报错更糟,所以 autoland 时任何失败都直接抛出,不落版。
3003
+ const failures = [];
2375
3004
  for (const edit of edits) {
2376
- const value = await single.execute(
2377
- {
2378
- deckId: args.deckId,
2379
- page: edit?.page,
2380
- field: edit?.field,
2381
- newValue: edit?.newValue,
2382
- reason: edit?.reason ?? "",
2383
- },
2384
- exec,
2385
- );
2386
- if (Array.isArray(value?.styleHints)) styleHints.push(...value.styleHints);
3005
+ let value;
3006
+ try {
3007
+ value = await single.execute(
3008
+ {
3009
+ deckId: args.deckId,
3010
+ page: edit?.page,
3011
+ field: edit?.field,
3012
+ newValue: edit?.newValue,
3013
+ reason: edit?.reason ?? "",
3014
+ reviewRef: edit?.reviewRef,
3015
+ severity: edit?.severity,
3016
+ confidence: edit?.confidence,
3017
+ evidence: edit?.evidence,
3018
+ },
3019
+ exec,
3020
+ );
3021
+ } catch (error) {
3022
+ failures.push({ page: edit?.page, field: String(edit?.field ?? ""), error: String(error?.message ?? error) });
3023
+ if (args.autoland === true) {
3024
+ throw new Error(
3025
+ `首稿自动落版中止:${failures.length} 条修改没能应用(${failures.map((item) => `${item.page}·${item.field}:${item.error}`).join(" / ")})。` +
3026
+ "没有落版,改完这几条后整批重提;不要分两批落首稿。",
3027
+ );
3028
+ }
3029
+ continue;
3030
+ }
3031
+ if (Array.isArray(value?.styleHints)) styleHints.push(...(value?.styleHints ?? []));
2387
3032
  created.push({
2388
3033
  pageId: value?.slideIds?.[0] ?? `p${String(edit?.page).padStart(2, "0")}`,
2389
3034
  field: String(edit?.field ?? ""),
2390
3035
  proposalId: String(value?.proposalId ?? ""),
2391
3036
  });
2392
3037
  }
3038
+ // F-173:首稿自动落版——这批修改合成一条 revision 直接落库,不再逐条等确认。
3039
+ if (args.autoland === true) {
3040
+ const proposals = created.map((item) => workspace.proposal(item.proposalId)).filter(Boolean);
3041
+ const landed = applyFirstDraftBatch({
3042
+ store,
3043
+ workspace,
3044
+ proposals,
3045
+ autoland: {
3046
+ draftRound: 1,
3047
+ selfReview: {
3048
+ rounds: Number(args.selfReviewRounds ?? 0),
3049
+ summary: String(args.selfReviewSummary ?? ""),
3050
+ reviewId: String(args.reviewId ?? ""),
3051
+ },
3052
+ },
3053
+ sessionId: sessionIdOf(exec),
3054
+ syncScriptToSource,
3055
+ });
3056
+ const pending = workspace.listProposals(args.deckId, "waiting-user");
3057
+ const record = landed.autoland ?? {};
3058
+ return {
3059
+ deckId: args.deckId,
3060
+ proposals: created,
3061
+ autoland: { revision: landed.revision, rolledInto: created.length, selfReview: record.selfReview ?? {} },
3062
+ pendingCount: pending.length,
3063
+ styleHints,
3064
+ text:
3065
+ `首稿自动落版:${created.length} 条修改已合成 revision ${landed.revision}(旧版保留,可回滚)。\n` +
3066
+ `自改 ${record.selfReview?.rounds ?? 0} 轮${record.selfReview?.summary ? `,主要改了「${record.selfReview.summary}」` : ""}。\n` +
3067
+ `这是首稿、未经用户逐条确认;落版后要主动告诉用户「自改摘要 + 可回滚」,回滚用 deck_draft_rollback。` +
3068
+ `${landed.sourceSync ? (landed.sourceSync.ok ? "源 script.md 已回写并提交。" : `源 script.md 已回写,git 未提交:${landed.sourceSync.error}。`) : ""}` +
3069
+ `${styleHints.length ? `\n文案提醒(不阻断,建议下轮改):\n${styleHints.map((hint) => `- ${hint}`).join("\n")}` : ""}`,
3070
+ };
3071
+ }
3072
+ if (!created.length) {
3073
+ throw new Error(`${failures.length} 条修改都没能应用:${failures.map((item) => `${item.page}·${item.field}:${item.error}`).join(" / ")}`);
3074
+ }
2393
3075
  const pendingNow = workspace.listProposals(args.deckId, "waiting-user");
2394
3076
  const lines = created.map((item) => `${item.pageId} · ${item.field} → ${item.proposalId}`);
3077
+ const failedLine = failures.length
3078
+ ? `\n${failures.length} 条没能应用(其余已提案,这几条要重新定位后补提):${failures.map((item) => `${item.page}·${item.field}:${item.error}`).join(" / ")}`
3079
+ : "";
2395
3080
  return {
2396
3081
  deckId: args.deckId,
2397
3082
  proposals: created,
3083
+ failures,
2398
3084
  pendingCount: pendingNow.length,
2399
3085
  styleHints,
2400
- text: `已提交 ${created.length} 条提案(waiting-user):\n${lines.join("\n")}\n队列:整个 deck ${pendingNow.length} 条待确认。可以逐条确认,或在工作台点「全部确认落版」;确认前不要渲染。${styleHints.length ? `\n文案提醒(不阻断提案,建议先改):\n${styleHints.map((hint) => `- ${hint}`).join("\n")}` : ""}`,
3086
+ text: `已提交 ${created.length} 条提案(waiting-user):\n${lines.join("\n")}${failedLine}\n队列:整个 deck ${pendingNow.length} 条待确认。可以逐条确认,或在工作台点「全部确认落版」;确认前不要渲染。${styleHints.length ? `\n文案提醒(不阻断提案,建议先改):\n${styleHints.map((hint) => `- ${hint}`).join("\n")}` : ""}`,
2401
3087
  };
2402
3088
  }),
2403
3089
  });
@@ -2411,6 +3097,218 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2411
3097
  };
2412
3098
  }
2413
3099
 
3100
+ // ── 4.6 deck_image_review(F-178 图片评审:保真转写×2 → 表意盲述 + 对照 → 五维打分)──
3101
+ tools.push({
3102
+ name: "deck_image_review",
3103
+ description:
3104
+ "审已出图的页:图有没有如实呈现这页(保真)、有没有扭曲或超载意思(表意)、贴合与承接如何(五维打分)。" +
3105
+ "做法是视觉模型只做逐字转写(两次),判定全由程序做 diff;不引入 OCR。" +
3106
+ "出图后自动/按需调用;保真不过=错字错数,必须重出该页;同一页 3 次不过转人工。" +
3107
+ "结果写进该版本 qa.json 的 review 字段 + docs/review.images.md。",
3108
+ parameters: {
3109
+ type: "object",
3110
+ properties: {
3111
+ deckId: { type: "string", description: "deck id" },
3112
+ page: { type: "string", description: "页码,p01 或 1 都行" },
3113
+ image: { type: "string", description: "指定图片路径(可选;缺省用该页最新版本的图)" },
3114
+ intent: { type: "string", description: "本页在整份 deck 里的意图(可选;缺省用 intent 文档的听众 / 目的)" },
3115
+ skipBlind: { type: "boolean", description: "跳过盲述(省一次调用,但表意只能靠对照;一般不跳过)" },
3116
+ },
3117
+ required: ["deckId", "page"],
3118
+ additionalProperties: false,
3119
+ },
3120
+ output: { schema: { type: "object", properties: { deckId: { type: "string" }, pageId: { type: "string" }, gate: { type: "boolean" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
3121
+ execute: withToolEvent(store, "deck_image_review", async (args) => {
3122
+ const adapter = adapterFor(args.deckId);
3123
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
3124
+ const rawPage = String(args.page ?? "").trim();
3125
+ const pageId = /^p\d+$/i.test(rawPage) ? rawPage.toLowerCase() : `p${String(Number(rawPage) || 0).padStart(2, "0")}`;
3126
+ const slide = deck.slides.find((candidate) => candidate.id === pageId);
3127
+ if (!slide) throw new Error(`${deck.id} 没有 ${pageId}(现有页:${deck.slides.map((item) => item.id).join(" / ")})`);
3128
+
3129
+ const deckDir = workspace.deckDir(deck.id);
3130
+ const resolveImage = () => {
3131
+ if (args.image) {
3132
+ const candidate = resolve(String(args.image));
3133
+ if (!existsSync(candidate)) throw new Error(`指定的图不存在:${candidate}`);
3134
+ return candidate;
3135
+ }
3136
+ const versionsDir = join(deckDir, "slides", pageId, "versions");
3137
+ if (!existsSync(versionsDir)) throw new Error(`${pageId} 还没出过图(没有 versions/),先出图再评审`);
3138
+ const versions = readdirSync(versionsDir).filter((name) => /^v\d+$/.test(name)).sort();
3139
+ for (let index = versions.length - 1; index >= 0; index -= 1) {
3140
+ for (const name of ["image.png", "image.jpg", "image.jpeg", "image.webp"]) {
3141
+ const candidate = join(versionsDir, versions[index], name);
3142
+ if (existsSync(candidate)) return candidate;
3143
+ }
3144
+ }
3145
+ throw new Error(`${pageId} 的最新版本里没有图片文件,先出图再评审`);
3146
+ };
3147
+ const imagePath = resolveImage();
3148
+
3149
+ // C1 的对照基准 = 出这张图时用的 prompt(不是当前 script:图可能是旧稿出的)。
3150
+ const versionDirs = readdirSync(join(deckDir, "slides", pageId, "versions")).filter((name) => /^v\d+$/.test(name)).sort();
3151
+ const versionDir = join(deckDir, "slides", pageId, "versions", versionDirs[versionDirs.length - 1]);
3152
+ const prompts = [...versionDirs].reverse()
3153
+ .map((name) => join(deckDir, "slides", pageId, "versions", name, "prompt.txt"))
3154
+ .filter((file) => existsSync(file))
3155
+ .map((file) => readFileSync(file, "utf8"));
3156
+ const expected = pickRenderExpectations(prompts) ?? { title: slide.title, question: slide.question, claim: slide.claim, body: "", quoted: [], numbers: [] };
3157
+ const renderPrompt = prompts.find((prompt) => (pickRenderExpectations([prompt])?.title || "") !== "") ?? "";
3158
+ // 图是不是旧稿出的:prompt 里的标题 vs 当前 script 标题(不同就提示重出,而不是当「错字」)。
3159
+ const script = workspace.scriptCurrent(deck.id) ?? "";
3160
+ const pageNo = Number(pageId.replace(/^p/, ""));
3161
+ const pageData = parseSectionedPages(script).find((page) => page.page === pageNo);
3162
+ const scriptTitle = pageData?.title || slide.title;
3163
+ const drift = Boolean(expected.title && scriptTitle && expected.title !== scriptTitle)
3164
+ ? `图是按旧稿出的:图上标题「${expected.title}」,当前稿件标题「${scriptTitle}」——先确认是不是要重出这页`
3165
+ : "";
3166
+ const pageText = pageData
3167
+ ? [`标题:${pageData.title}`, `副标题:${pageData.question}`, `主张:${pageData.claim}`, pageData.content].filter(Boolean).join("\n")
3168
+ : `标题:${slide.title}\n副标题:${slide.question}\n主张:${slide.claim}`;
3169
+ const brief = readDeckBrief(adapter.sourceRoot);
3170
+ const intentText = args.intent || [brief.audience ? `听众:${brief.audience}` : "", brief.topic ? `主题:${brief.topic}` : "", brief.intent ? `目的:${brief.intent}` : ""].filter(Boolean).join(";");
3171
+ const linked = linkedDesignStyle(deck.id);
3172
+ const prevSlide = deck.slides.find((item) => item.order === slide.order - 1);
3173
+
3174
+ const settings = config.imageReview ?? {};
3175
+ const apiKey = env[settings.apiKeyEnv] || (settings.apiKeyFile ? readKeyFromFile(settings.apiKeyFile, settings.apiKeyEnv) : "");
3176
+ // C1 + C2 + C3 + C4:编排在 lib/image-review.js(verify 脚本共用同一条路径)。
3177
+ // 封面页:署名 / 场合 / 日期是版式要求,判「超载」时要放过(C2 校正,见 normalizeContrastForPage)。
3178
+ const isCover = slide.order === 1 || /封面/.test(String(slide.fields?.["角色"] ?? ""));
3179
+ const outcome = await runImageReview({
3180
+ imagePath,
3181
+ pageId,
3182
+ expected,
3183
+ pageText,
3184
+ intentText,
3185
+ styleAnchor: linked ? `${linked.abbr} ${linked.name}:${linked.brief}` : config.styleBrief,
3186
+ prevPageText: prevSlide ? `${prevSlide.id} ${prevSlide.title}:${prevSlide.claim || prevSlide.question || ""}` : "",
3187
+ visualBrief: String(slide.visualBrief ?? "").trim(),
3188
+ cover: isCover,
3189
+ settings: { ...settings, apiKey },
3190
+ skipBlind: args.skipBlind === true,
3191
+ fetchImpl: visionFetch ?? fetch,
3192
+ });
3193
+ let { fidelity, dual, meaning, contrast, review, advice, blind } = outcome;
3194
+ const { transcription, blindError } = outcome;
3195
+ // 图是按旧稿出的:差异来自「稿件改了、图没重出」,不是图写错字 / 说反话。
3196
+ // 单独标「旧稿」,不把版本差异判成扭曲(否则用户会去改 prompt,其实该做的是确认要不要重出)。
3197
+ if (drift) {
3198
+ meaning = { verdict: "旧稿", severity: "", score: 3 };
3199
+ review = { ...review, gate: false, blockers: ["图基于旧稿(与当前稿件不一致):先确认是否重出这页", ...review.blockers.filter((line) => !/表意/.test(line))] };
3200
+ }
3201
+
3202
+ // 落盘:qa.json 的 review 字段(保留历史 attempts,3 次阈值靠它累计)。
3203
+ const qaFile = join(versionDir, "qa.json");
3204
+ const previousQa = existsSync(qaFile) ? JSON.parse(readFileSync(qaFile, "utf8")) : {};
3205
+ const attempts = attemptsOf(previousQa) + 1;
3206
+ const mustHuman = review.mustHuman || (!review.gate && attempts >= 3);
3207
+ const record = {
3208
+ page: pageId,
3209
+ revision: versionDirs[versionDirs.length - 1],
3210
+ image: relative(deckDir, imagePath),
3211
+ verdict: mustHuman && !review.gate ? "转人工" : meaning.verdict === "通过" && fidelity.verdict === "通过" ? "通过" : "需重出",
3212
+ gate: review.gate,
3213
+ scores: review.scores,
3214
+ total: review.total,
3215
+ blockers: review.blockers,
3216
+ attempts,
3217
+ mustHuman,
3218
+ fidelity: { verdict: fidelity.verdict, unstable: fidelity.unstable, diffs: fidelity.blockers },
3219
+ meaning,
3220
+ contrast,
3221
+ blind,
3222
+ transcription,
3223
+ dual,
3224
+ advice,
3225
+ model: settings.model || "",
3226
+ drift,
3227
+ checkedAt: new Date().toISOString(),
3228
+ };
3229
+ const nextQa = { ...previousQa, review: record };
3230
+ writeFileSync(qaFile, `${JSON.stringify(nextQa, null, 2)}\n`, "utf8");
3231
+
3232
+ // 整本汇总:把每页最新版本的 review 拍平成 review.images.md。
3233
+ const rows = [];
3234
+ for (const item of deck.slides) {
3235
+ const dir = join(deckDir, "slides", item.id, "versions");
3236
+ if (!existsSync(dir)) continue;
3237
+ const stamps = readdirSync(dir).filter((name) => /^v\d+$/.test(name)).sort();
3238
+ for (let index = stamps.length - 1; index >= 0; index -= 1) {
3239
+ const file = join(dir, stamps[index], "qa.json");
3240
+ if (!existsSync(file)) continue;
3241
+ try {
3242
+ const qa = JSON.parse(readFileSync(file, "utf8"));
3243
+ if (qa?.review?.scores) rows.push({ ...qa.review, page: item.id });
3244
+ } catch { /* 坏 qa 不影响汇总 */ }
3245
+ break;
3246
+ }
3247
+ }
3248
+ const docsDir = join(deckDir, "docs");
3249
+ mkdirSync(docsDir, { recursive: true });
3250
+ writeFileSync(join(docsDir, "review.images.md"), renderImageReviewMarkdown({ deckTitle: deck.title, generatedAt: new Date().toISOString(), rows }), "utf8");
3251
+
3252
+ const scoreLine = Object.entries(review.scores).map(([key, value]) => `${key} ${value}`).join(" · ");
3253
+ const diffLines = (fidelity.blockers ?? []).slice(0, 6).map((diff) => {
3254
+ if (diff.type === "missing") return `✗ 缺 ${diff.field}:应出现「${String(diff.expected).slice(0, 40)}」`;
3255
+ if (diff.type === "extra") return `✗ 多出 ${diff.field}:「${String(diff.actual).slice(0, 40)}」`;
3256
+ return `✗ 错 ${diff.field}:应为「${String(diff.expected).slice(0, 40)}」,图上「${String(diff.actual).slice(0, 40)}」${diff.ratio != null ? `(差异 ${(diff.ratio * 100).toFixed(1)}%)` : ""}`;
3257
+ });
3258
+ const text = [
3259
+ `${pageId}(${versionDirs[versionDirs.length - 1]})图片评审:${record.verdict} · 门禁${record.gate ? "过" : "不过"}`,
3260
+ `五维:${scoreLine}(总分 ${review.total}/25)`,
3261
+ "",
3262
+ `保真(转写两次${dual.stable ? "一致" : `不一致:${dual.unstable.join(" / ")}`}):${fidelity.verdict}`,
3263
+ ...(diffLines.length ? diffLines : ["✓ 标题 / 数字 / 正文逐段对得上"]),
3264
+ "",
3265
+ `表意:${meaning.verdict}${meaning.severity ? `(${meaning.severity})` : ""}`,
3266
+ blind ? `盲述:${blind.what}|想让我:${blind.ask}${blind.confusing && blind.confusing !== "无" ? `|费解:${blind.confusing}` : ""}` : "盲述:已跳过(只做了对照)",
3267
+ contrast.contradiction ? `矛盾:${contrast.contradiction}` : contrast.extra.length ? `多出:${contrast.extra.join(";")}` : "对照:与主张一致,无多出信息",
3268
+ "",
3269
+ ...(advice.length ? ["怎么改(按 C5 顺序):", ...advice.map((item) => `· ${item.failure} → 先${item.firstChange};重出范围:${item.scope}`)] : ["可交付:保真与表意都过(其余维度只记录,不做门禁)。"]),
3270
+ ...(drift ? [`⚠ ${drift}`] : []),
3271
+ ...(record.blockers.length ? [`门禁未过:${record.blockers.join(";")}`] : []),
3272
+ mustHuman ? `⚠ 同一页第 ${attempts} 次不过 → 转人工(记录在 qa.json 的 review.attempts;不要无限重试)。` : "",
3273
+ `已写入:${relative(deckDir, qaFile)}(review 字段)· docs/review.images.md`,
3274
+ ].filter(Boolean).join("\n");
3275
+
3276
+ return { deckId: deck.id, pageId, revision: versionDirs[versionDirs.length - 1], gate: review.gate, mustHuman, attempts, scores: review.scores, drift, text };
3277
+ }),
3278
+ });
3279
+
3280
+ // ── 4.7 deck_title_chain(F-179 标题串读:只看标题能不能推出故事线)──────────
3281
+ tools.push({
3282
+ name: "deck_title_chain",
3283
+ description:
3284
+ "标题串读测试:把稿件的页标题按顺序串起来,判断「只看标题能不能推出故事线」。" +
3285
+ "只查机器能查的:占位标题 / 重复标题 / 相邻标题断链 / 有没有收束页。确定性、不花模型。" +
3286
+ "写或大改 script 前、用户问「故事线通不通」时先跑它;推不出就先修标题链再往下写。",
3287
+ parameters: {
3288
+ type: "object",
3289
+ properties: { deckId: { type: "string", description: "deck id" } },
3290
+ required: ["deckId"],
3291
+ additionalProperties: false,
3292
+ },
3293
+ output: { schema: { type: "object", properties: { deckId: { type: "string" }, verdict: { type: "string" } } }, render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }] },
3294
+ execute: withToolEvent(store, "deck_title_chain", async (args) => {
3295
+ const adapter = adapterFor(args.deckId);
3296
+ const deck = workspace.ensureDeck(adapter, { styleBrief: config.styleBrief });
3297
+ const script = workspace.scriptCurrent(deck.id) ?? "";
3298
+ const pages = parseSectionedPages(script);
3299
+ if (!pages.length) {
3300
+ return { deckId: deck.id, verdict: "推不出", text: "稿件里还没有可读的页标题(script 工作副本为空)。先写 P01 起的分节标题,再跑标题串读。" };
3301
+ }
3302
+ const analysis = analyzeTitleChain(pages.map((page) => ({ page: page.page, title: page.title, claim: page.claim })));
3303
+ return {
3304
+ deckId: deck.id,
3305
+ verdict: analysis.verdict,
3306
+ stats: analysis.stats,
3307
+ text: `${renderTitleChain({ deckTitle: deck.title, analysis })}\n(这份报告是确定性判断:只查占位 / 重复 / 断链 / 收束,不评价标题文采。)`,
3308
+ };
3309
+ }),
3310
+ });
3311
+
2414
3312
  // ── F-125:logo 水印 ──────────────────────────────────────────────────────
2415
3313
  tools.push({
2416
3314
  name: "deck_watermark_assets",