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
package/lib/tools.js CHANGED
@@ -9,23 +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";
22
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";
23
47
  import { densityLine } from "./content-density.js";
24
48
  import { faqIndex, searchFaq } from "./faq.js";
25
49
  import { WATERMARK_POSITIONS, WATERMARK_POSITION_LABELS, findAsset, listAssets, positionLine, readPngFile, watermarkPng } from "./watermark.js";
26
50
  import { PLUGIN_DIR, repoRootFrom } from "./config.js";
27
51
  import { buildIntentMarkdown } from "./deck-templates.js";
28
- 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";
29
54
 
30
55
  /** 本次调用的 sessionId(非 agent 环境——冒烟测试——为 null)。 */
31
56
  function sessionIdOf(exec) {
@@ -139,7 +164,33 @@ const strEnum = (values) => ({ type: "string", enum: values });
139
164
  * @param {import('./config.js').mergeConfig} services.config
140
165
  * @param {NodeJS.ProcessEnv} [services.env]
141
166
  */
142
- export function defineDeckTools({ store, workspace, adapters, config, env = process.env, resolveRender = null, isLocalDeck = null, repoRoot = null, subagents = 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 }) {
143
194
  const resolveSubagents = () => (typeof subagents === "function" ? subagents() : subagents);
144
195
  const designs = createDesignRegistry({ dataRoot: config.dataRoot });
145
196
  const decksRegistryFile = join(config.dataRoot, "decks-registry.json");
@@ -300,6 +351,11 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
300
351
  const density = workspace.manifest(adapter.id)?.contentDensity ?? "auto";
301
352
  // F-158:写 script 前先看到 intent 的听众 / 目的(新 deck 的 deb 规则里也会带,但 overview 是第一个调用)。
302
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 : ["核心论证", "骨架类型", "页序角色"] };
303
359
  return {
304
360
  deckId: adapter.id,
305
361
  label: adapter.label,
@@ -321,13 +377,17 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
321
377
  ...(rules.warning ? { warning: rules.warning } : {}),
322
378
  },
323
379
  _rulesText: renderDeckRules(rules),
324
- _briefText: brief.audience || brief.takeaway || brief.success
325
- ? `受众 / 目的:${[brief.audience, brief.takeaway || brief.success].filter(Boolean).join(" · ")}`
326
- : "",
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,
327
387
  };
328
388
  });
329
389
  return {
330
- decks: decks.map(({ _rulesText, _briefText, ...deck }) => deck),
390
+ decks: decks.map(({ _rulesText, _briefText, _skeletonText, ...deck }) => deck),
331
391
  deckId: null,
332
392
  text: decks
333
393
  .map((deck) =>
@@ -442,6 +502,11 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
442
502
  "目标": "成功标准", goal: "成功标准", objective: "成功标准",
443
503
  "不讲的": "不讲的", "不讲": "不讲的", scopeOut: "不讲的", scope: "不讲的",
444
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 页",
445
510
  };
446
511
  const DOC_DRAFT_PATH = (dir, docId) => (docId === "research" ? join(dir, "docs", "research", "index.md") : join(dir, "docs", `${docId}.md`));
447
512
  const DOC_TITLES = { materials: "支撑材料", research: "调研", feedback: "反馈" };
@@ -509,7 +574,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
509
574
  if (!value) continue;
510
575
  merged.push([fieldName, value]);
511
576
  }
512
- if (!merged.length && !storyline) throw new Error("intent 至少要给一个字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的)或故事线正文");
577
+ if (!merged.length && !storyline) throw new Error("intent 至少要给一个字段(听众 / 主题 / 带走 / 场合·时长 / 成功标准 / 不讲的 / 核心论证 / 骨架类型 / 页序角色 / 只留 3 页)或故事线正文");
513
578
  const lines = current.split("\n");
514
579
  const lineRe = /^(\s*-\s*\*\*)([^*]+)(\*\*\s*[::]\s*)(.*)$/;
515
580
  let lastFieldIndex = -1;
@@ -637,12 +702,37 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
637
702
  return Boolean(proposal.before) && !text.includes(proposal.before);
638
703
  };
639
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) ?? {});
640
710
  const lines = slides.map((slide) => {
641
711
  const brief = briefOf(slide);
642
712
  const briefStyle = slide.visualBriefDesign ? `(写于 ${slide.visualBriefDesign} 风格)` : "(未记录写作风格)";
643
713
  const briefText = brief ? `${briefStyle}${brief.slice(0, 120)}${brief.length > 120 ? "…" : ""}` : "(空 · 这一页还不能出图)";
644
- 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 || "—"}`;
645
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);
646
736
  const linked = linkedDesignStyle(deck.id);
647
737
  const styleLine = linked
648
738
  ? `\n关联风格:${linked.abbr} ${linked.name}(出图时自动带上它的 styleBrief;visualBrief 只写这页具体画面,不要另起一套视觉语言)\n styleBrief:${linked.brief.slice(0, 200)}${linked.brief.length > 200 ? "…" : ""}${linked.constraints ? `\n 不要什么:${linked.constraints}` : ""}`
@@ -651,10 +741,15 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
651
741
  const densityLineText = densityLine(deck.contentDensity);
652
742
  // F-158:写 script 前把 intent 的听众 / 目的和文案纪律一起读给 Agent,不然它会按「通用文案」写。
653
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 || "论证骨架还没写"}`;
654
748
  const briefLine = [
655
749
  brief.audience ? `听众:${brief.audience}(术语密度 / 例子 / 取舍按这个来)` : "听众:intent 里没写——先问用户,或按最小假设写明再写正文",
656
750
  brief.takeaway || brief.success ? `目的 / 成功标准:${brief.takeaway || brief.success}` : "",
657
751
  "文案纪律:标题≤16 字给判断(比喻 / 对仗 / 问句 / 「不是…而是…」都不要);副标题一句话说这页回答什么;正文先结论后证据,按听众调术语密度;不写 AI 黑话(赋能 / 闭环 / 抓手 / 生态位 / 底层逻辑 / 范式)。",
752
+ skeletonLine,
658
753
  ].filter(Boolean).join("\n");
659
754
  const pendingText = pending.length
660
755
  ? pending.map((proposal) => ` ${proposal.proposalId} · ${proposal.pageId} · ${proposal.field}${staleOf(proposal) ? " · 已过期(工作副本已变,需重新提案)" : ""}`).join("\n")
@@ -683,7 +778,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
683
778
  count: slides.length,
684
779
  slideIds: slides.map((slide) => slide.id),
685
780
  pendingProposals: pending.map((proposal) => ({ proposalId: proposal.proposalId, pageId: proposal.pageId, field: proposal.field, stale: staleOf(proposal) })),
686
- 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} · 只读)`,
687
782
  outOfSyncPages: outOfSync.map((slide) => slide.id),
688
783
  };
689
784
  }),
@@ -694,7 +789,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
694
789
  name: "deck_script_propose_edit",
695
790
  description:
696
791
  "提出一页的修改提案:生成 unified diff 并进入 waiting-user,等用户在 Chat 里确认(或在工作台点确认)。" +
697
- "可改字段:title(标题)/ question(这页回答)/ body(正文,整页 3-5 句)/ claim(一句话判断,老 deck 兼容)/ visualBrief(画面要求,仅 manifest)。" +
792
+ "可改字段:title(标题)/ question(这页回答)/ body(正文,整页 3-5 句)/ claim(一句话判断,老 deck 兼容)/ visualBrief(画面要求,仅 manifest);逐页契约另可用 角色 / 受众变化 / 关系 / 证据 四个字段(script 里的 `**角色**:…` 行,缺行时自动插在标题后)。" +
698
793
  "写整页内容时 title+question+body 一起提;多页 / 整本重写用 deck_script_propose_edits 一次提交;" +
699
794
  "严禁在用户确认前调用 deck_revision_confirm。",
700
795
  parameters: {
@@ -702,9 +797,13 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
702
797
  properties: {
703
798
  deckId: { type: "string" },
704
799
  page: { type: "number", description: "页号(script 里的 N)" },
705
- field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
800
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), ...CONTRACT_FIELD_KEYS, "body", "visualBrief"]),
706
801
  newValue: { type: "string", description: "新值全文(不是增量)" },
707
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 字)" },
708
807
  },
709
808
  required: ["deckId", "page", "field", "newValue"],
710
809
  additionalProperties: false,
@@ -765,6 +864,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
765
864
  value: String(args.newValue ?? "").trim(),
766
865
  diff,
767
866
  reason: String(args.reason ?? ""),
867
+ ...reviewMetaOf(args),
768
868
  status: "waiting-user",
769
869
  createdAt: proposalNow(),
770
870
  sessionId: sessionIdOf(exec),
@@ -994,6 +1094,56 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
994
1094
  });
995
1095
 
996
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
+
997
1147
  // ── 5. deck_render_page ────────────────────────────────────────────────────
998
1148
  tools.push({
999
1149
  name: "deck_render_page",
@@ -1594,6 +1744,97 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
1594
1744
  }),
1595
1745
  });
1596
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
+
1597
1838
  // ── N+1. deck_design_sample ────────────────────────────────────────────────
1598
1839
  tools.push({
1599
1840
  name: "deck_design_sample",
@@ -2019,20 +2260,28 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2019
2260
  }),
2020
2261
  });
2021
2262
 
2022
- // ── 4.5 deck_script_review(F-159)────────────────────────────────────────
2263
+ // ── 4.5 deck_script_review(F-159 听众评审;F-172 两段式盲审 → 冻结 → 对照)──
2023
2264
  tools.push({
2024
2265
  name: "deck_script_review",
2025
2266
  description:
2026
- "听众评审(动态 persona):按 intent 的「听众」生成独立审稿人(每个子 agent 只从该听众视角读整份 script)," +
2027
- "返回 P0/P1/P2 问题 + 原文证据 + 可直接落版的改法,写进 docs/review.experts.md。" +
2028
- "只审不改:评审结果不改 script,改稿仍走 deck_script_propose_edits + 用户确认。" +
2029
- "第一次整份 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 类(原文找不到证据)不许进提案。",
2030
2273
  parameters: {
2031
2274
  type: "object",
2032
2275
  properties: {
2033
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
+ },
2034
2282
  maxReviewers: { type: "number", description: `最多几个审稿人(1–${HARD_MAX_REVIEWERS},默认按 profile 配置,通常 2–3)` },
2035
- 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 时只出意见不打分)" },
2036
2285
  },
2037
2286
  required: ["deckId"],
2038
2287
  additionalProperties: false,
@@ -2051,7 +2300,10 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2051
2300
  const sessionId = sessionIdOf(exec);
2052
2301
  const brief = readDeckBrief(adapter.sourceRoot);
2053
2302
  const script = workspace.scriptCurrent(deck.id) ?? "";
2054
- 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);
2055
2307
  const runtime = resolveSubagents();
2056
2308
  const maxReviewers = Math.min(
2057
2309
  HARD_MAX_REVIEWERS,
@@ -2104,7 +2356,7 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2104
2356
  .join("\n")
2105
2357
  .trim();
2106
2358
  };
2107
- const runReviewer = async (reviewer) => {
2359
+ const runOne = async (reviewer, promptText, parseFn, validate, maxTokens) => {
2108
2360
  const controller = new AbortController();
2109
2361
  const onAbort = () => controller.abort();
2110
2362
  if (exec.signal?.aborted) controller.abort();
@@ -2126,15 +2378,10 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2126
2378
  prompt: [
2127
2379
  {
2128
2380
  type: "text",
2129
- text: reviewerPrompt({
2130
- reviewer,
2131
- brief,
2132
- deckTitle: deck.title,
2133
- script,
2134
- rulesText: String(rules.content ?? "").slice(0, 4000),
2135
- }),
2381
+ text: promptText,
2136
2382
  },
2137
2383
  ],
2384
+ ...(Number.isSafeInteger(maxTokens) && maxTokens > 0 ? { agentOptions: { maxTokens } } : {}),
2138
2385
  parent: exec.agent,
2139
2386
  },
2140
2387
  signal: controller.signal,
@@ -2144,11 +2391,9 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2144
2391
  if (controller.signal.aborted) throw new Error("听众评审已取消或超时");
2145
2392
  if (result?.stopReason !== "completed") throw new Error(`审稿人未正常完成(${result?.stopReason ?? "unknown"})`);
2146
2393
  const text = activationResultText(result);
2147
- const parsed = parseReviewerOutput(text);
2148
- if (parsed.unparsed) throw new Error("审稿人未返回符合契约的 JSON 意见");
2149
- if (parsed.issues.some((issue) => !script.includes(issue.evidence))) {
2150
- throw new Error("审稿意见的证据不是稿件原文,已拒绝采纳");
2151
- }
2394
+ const parsed = parseFn(text);
2395
+ if (parsed.unparsed) throw new Error("审稿人未返回符合契约的 JSON");
2396
+ if (validate) validate(parsed);
2152
2397
  Object.assign(outcome, { parsed, text, stopReason: result.stopReason });
2153
2398
  } catch (error) {
2154
2399
  outcome.error = error instanceof Error ? error.message : String(error);
@@ -2164,38 +2409,357 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2164
2409
  return outcome;
2165
2410
  };
2166
2411
 
2167
- // 审稿人并发跑:每个都是一次独立子 agent,互不影响;失败一个不拖垮整份评审。
2168
- const outcomes = await Promise.all(derived.reviewers.map((reviewer) => runReviewer(reviewer)));
2169
- const ok = outcomes.filter((outcome) => !outcome.error);
2170
- const failed = outcomes.filter((outcome) => outcome.error).map((outcome) => ({ label: outcome.label, error: outcome.error }));
2171
- const results = ok.map((outcome) => ({
2172
- reviewer: outcome.parsed.reviewer || outcome.reviewer.label,
2173
- label: outcome.reviewer.label,
2174
- issues: outcome.parsed.issues,
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;
2493
+ }
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 };
2501
+ };
2502
+
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 ?? []))],
2175
2611
  }));
2176
- 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) }]);
2177
2683
  const bySeverity = { P0: 0, P1: 0, P2: 0 };
2178
2684
  for (const issue of issues) bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
2179
- const generatedAt = new Date().toISOString();
2180
- const target = join(deckDir, "docs", "review.experts.md");
2181
- let written = false;
2182
- if (args.write !== false && !exec.signal?.aborted) {
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) {
2183
2691
  mkdirSync(dirname(target), { recursive: true });
2184
- const body = [
2185
- renderReviewMarkdown({
2186
- deckTitle: deck.title,
2187
- brief,
2188
- reviewers: derived.reviewers,
2189
- issues,
2190
- generatedAt,
2191
- failed,
2192
- }),
2193
- ].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
+ });
2194
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
+ );
2195
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");
2196
2760
  appendChangelogNote({
2197
2761
  deckDir,
2198
- 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.否})`,
2199
2763
  actor: "agent",
2200
2764
  });
2201
2765
  }
@@ -2204,30 +2768,64 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2204
2768
  deckId: deck.id,
2205
2769
  sessionId,
2206
2770
  actor: "agent",
2207
- status: failed.length === derived.reviewers.length ? "failed" : "succeeded",
2771
+ status: contrast.failed.length && contrast.outcomes.length === contrast.failed.length ? "failed" : "succeeded",
2208
2772
  payload: {
2773
+ phase,
2774
+ scriptHash,
2209
2775
  reviewers: derived.reviewers.map((reviewer) => reviewer.id),
2210
2776
  audience: brief.audience ?? "",
2777
+ coverage: { expected: blind.coverage.expected, answered: blind.coverage.answered, missing: blind.coverage.missing },
2778
+ verdicts: counts,
2211
2779
  total: issues.length,
2212
2780
  bySeverity,
2213
2781
  failed: failed.map((item) => item.label),
2214
2782
  written,
2215
2783
  },
2216
2784
  });
2217
- const lines = issues.length
2218
- ? issues.map((issue) => `${issue.severity} · ${issue.page || "—"} · ${issue.problem} → ${issue.suggestion}${issue.reviewer ? `(${issue.reviewer})` : ""}`)
2219
- : [ok.length ? "已完成评审,未发现有原文证据支持的可操作问题。" : "本次没有有效评审结果;不能据此判断稿件没有问题。"];
2220
2785
  const failureLine = failed.length
2221
2786
  ? `\n${failed.length} 位审稿人失败:${failed.map((item) => `${item.label}(${item.error})`).join(" / ")}`
2222
2787
  : "";
2223
2788
  return {
2224
2789
  deckId: deck.id,
2790
+ phase,
2791
+ scriptHash,
2225
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,
2226
2798
  issues,
2799
+ findings,
2800
+ dropped,
2801
+ citation,
2227
2802
  bySeverity,
2228
2803
  failed,
2229
2804
  written,
2230
- 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,
2231
2829
  };
2232
2830
  }),
2233
2831
  });
@@ -2268,7 +2866,18 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2268
2866
  const current = workspace.scriptCurrent(deck.id);
2269
2867
  if (current == null) throw new Error(`script 工作副本缺失:${deck.id}`);
2270
2868
  const parsed = parseSectionedPages(current);
2271
- 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
+ );
2272
2881
  const problems = [];
2273
2882
  const blocks = [];
2274
2883
  deck.slides.forEach((slide, index) => {
@@ -2331,6 +2940,15 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2331
2940
  type: "object",
2332
2941
  properties: {
2333
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 / 路径(可选)" },
2334
2952
  edits: {
2335
2953
  type: "array",
2336
2954
  minItems: 1,
@@ -2339,9 +2957,13 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2339
2957
  type: "object",
2340
2958
  properties: {
2341
2959
  page: { type: "number", description: "页号(script 里的 N)" },
2342
- field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), "body", "visualBrief"]),
2960
+ field: strEnum([...Object.keys(SCRIPT_FIELD_CELL), ...CONTRACT_FIELD_KEYS, "body", "visualBrief"]),
2343
2961
  newValue: { type: "string", description: "新值全文(不是增量)" },
2344
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: "评审原文证据(逐字)" },
2345
2967
  },
2346
2968
  required: ["page", "field", "newValue"],
2347
2969
  additionalProperties: false,
@@ -2376,32 +2998,92 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2376
2998
  if (!single) throw new Error("内部错误:找不到 deck_script_propose_edit");
2377
2999
  const created = [];
2378
3000
  const styleHints = [];
3001
+ // F-183 附带:一条坏 edit 不许拖垮整批(60 条里 1 条定位不到,另外 59 条应该照常提案)。
3002
+ // 但首稿自动落版例外——半份稿自动落库比报错更糟,所以 autoland 时任何失败都直接抛出,不落版。
3003
+ const failures = [];
2379
3004
  for (const edit of edits) {
2380
- const value = await single.execute(
2381
- {
2382
- deckId: args.deckId,
2383
- page: edit?.page,
2384
- field: edit?.field,
2385
- newValue: edit?.newValue,
2386
- reason: edit?.reason ?? "",
2387
- },
2388
- exec,
2389
- );
2390
- 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 ?? []));
2391
3032
  created.push({
2392
3033
  pageId: value?.slideIds?.[0] ?? `p${String(edit?.page).padStart(2, "0")}`,
2393
3034
  field: String(edit?.field ?? ""),
2394
3035
  proposalId: String(value?.proposalId ?? ""),
2395
3036
  });
2396
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
+ }
2397
3075
  const pendingNow = workspace.listProposals(args.deckId, "waiting-user");
2398
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
+ : "";
2399
3080
  return {
2400
3081
  deckId: args.deckId,
2401
3082
  proposals: created,
3083
+ failures,
2402
3084
  pendingCount: pendingNow.length,
2403
3085
  styleHints,
2404
- 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")}` : ""}`,
2405
3087
  };
2406
3088
  }),
2407
3089
  });
@@ -2415,6 +3097,218 @@ export function defineDeckTools({ store, workspace, adapters, config, env = proc
2415
3097
  };
2416
3098
  }
2417
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
+
2418
3312
  // ── F-125:logo 水印 ──────────────────────────────────────────────────────
2419
3313
  tools.push({
2420
3314
  name: "deck_watermark_assets",