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.
- package/cordis.example.yml +88 -30
- package/cordis.patch.yml +81 -30
- package/index.js +187 -28
- package/lib/capabilities.js +234 -0
- package/lib/citation-check.js +93 -0
- package/lib/config.js +30 -7
- package/lib/deck-brief.js +35 -1
- package/lib/deck-docs.js +45 -7
- package/lib/deck-templates.js +8 -1
- package/lib/deck-workspace.js +27 -1
- package/lib/designs.js +15 -0
- package/lib/fact-labels.js +81 -0
- package/lib/first-draft.js +119 -0
- package/lib/image-review.js +523 -0
- package/lib/page-contract.js +112 -0
- package/lib/proposal-decision.js +249 -75
- package/lib/review-agent.js +168 -0
- package/lib/review-personas.js +59 -3
- package/lib/review-protocol.js +411 -0
- package/lib/review-readers.js +300 -0
- package/lib/review-score.js +198 -0
- package/lib/title-chain.js +122 -0
- package/lib/tools.js +983 -85
- package/package.json +4 -3
- package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
- package/seeds/docs/assets/screenshots/08-presentation.png +0 -0
- package/seeds/docs/assets/screenshots/09-style-library.png +0 -0
- package/seeds/docs/assets/screenshots/10-style-management.png +0 -0
- package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
- package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
- package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
- package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
- package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
- package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
- package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
- package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
- package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
- package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
- package/seeds/docs/compat.md +3 -3
- package/seeds/docs/install.en.md +29 -14
- package/seeds/docs/install.md +29 -15
- package/seeds/docs/sample-deck.en.md +1 -1
- package/seeds/docs/sample-deck.md +1 -1
- package/seeds/docs/styles.en.md +5 -4
- package/seeds/docs/styles.md +17 -7
- package/seeds/samples/DSA-01.jpg +0 -0
- package/seeds/samples/DSA-02.jpg +0 -0
- package/seeds/samples/DSA-03.jpg +0 -0
- package/seeds/styles/DSA.json +22 -0
- package/ui/workbench.css +5 -0
- 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
|
|
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
|
-
|
|
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:
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
2026
|
-
"
|
|
2027
|
-
"
|
|
2028
|
-
"
|
|
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
|
|
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
|
|
2089
|
-
"
|
|
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
|
|
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:
|
|
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:
|
|
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 =
|
|
2145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2160
|
-
const
|
|
2161
|
-
const
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
-
|
|
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
|
|
2172
|
-
const
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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:
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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",
|