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.
- package/cordis.example.yml +85 -7
- package/cordis.patch.yml +78 -7
- package/index.js +176 -25
- package/lib/capabilities.js +234 -0
- package/lib/citation-check.js +93 -0
- package/lib/config.js +27 -0
- 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 +1 -1
- package/lib/review-personas.js +49 -2
- 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 +971 -77
- package/package.json +2 -1
- 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/install.en.md +13 -3
- package/seeds/docs/install.md +13 -4
- package/seeds/docs/sample-deck.en.md +1 -1
- package/seeds/docs/sample-deck.md +1 -1
- package/seeds/docs/styles.en.md +2 -1
- package/seeds/docs/styles.md +14 -4
- 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 +98 -6
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片评审(智力升级 C 层 / F-178):图出来后到底对不对。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则(docs/intelligence-upgrade.md §C):
|
|
5
|
+
* - 视觉模型只做**转写**(低自由度,逐字照抄,看到错字照抄错字),判定交给程序(字符串 diff)。
|
|
6
|
+
* - 同一张图转写两次,按字段比对;不一致的字段标「不确定」送人工,不参与自动判定。
|
|
7
|
+
* - 表意层先盲后对照:先只给图问「这图像在说什么」,再给本页主张判「一致 / 矛盾 / 多出」。
|
|
8
|
+
* - 五维打分只记录(保真 / 完整 / 表意 / 贴合 / 承接),硬门禁只有「保真 = 5 且 表意 ≥ 4」。
|
|
9
|
+
* - 不引入 OCR:直接用视觉输入(docs/vision-input.md 已实测边界)。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只做纯逻辑 + 一次 HTTP 调用;落盘(qa.json / review.images.md)在 tools.js。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { extname } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** 逐字转写 prompt:不让模型概括、纠错、美化。 */
|
|
18
|
+
export function transcribePrompt({ page }) {
|
|
19
|
+
return `任务:把这张幻灯片图片里**所有可见文字逐字照抄**下来,用于和稿件做比对。
|
|
20
|
+
|
|
21
|
+
规则(很重要):
|
|
22
|
+
1. 只照抄,不概括、不纠错、不补全、不美化。看到错字就抄错字,看到缺字就留缺。
|
|
23
|
+
2. 看不清的字写 "?",不要猜。
|
|
24
|
+
3. 不要描述画面、颜色、构图——只要文字。
|
|
25
|
+
4. 按区块归类:标题 / 副标题 / 正文 / 数字(含单位)/ 标注(标签、角标、页脚、来源行)。
|
|
26
|
+
|
|
27
|
+
只输出 JSON(不要 markdown 代码块):
|
|
28
|
+
{"title":"...","subtitle":"...","body":["段落 1","段落 2"],"numbers":["数字或含数字的短句"],"labels":["标签/角标/页脚"],"unreadable":["看不清的片段"]}
|
|
29
|
+
|
|
30
|
+
页码:${page || "(未指定)"}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 盲述 prompt:不给稿子、不给意图,只问看图感受(C2a)。 */
|
|
34
|
+
export function blindPrompt({ page }) {
|
|
35
|
+
return `只看这一张幻灯片图片,不要参考任何稿件,回答三个问题:
|
|
36
|
+
1. 这图像在说什么?(一句话)
|
|
37
|
+
2. 它想让我做什么 / 记住什么?(一句话)
|
|
38
|
+
3. 你觉得它哪里最费解?(一句话;没有就写 "无")
|
|
39
|
+
|
|
40
|
+
只输出 JSON(不要 markdown 代码块):{"what":"...","ask":"...","confusing":"..."}
|
|
41
|
+
|
|
42
|
+
页码:${page || "(未指定)"}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 对照 prompt:给本页主张 + 受众 + 风格锚点,判一致 / 矛盾 / 多出,并打贴合与承接分。 */
|
|
46
|
+
export function contrastPrompt({ page, pageText, intent = "", styleAnchor = "", prevPage = "", visualBrief = "", cover = false }) {
|
|
47
|
+
const coverRule = cover
|
|
48
|
+
? `\n封面页例外:作者署名 / 主讲人 / 场合 / 日期 / 品牌角标属于封面版式要求(「这页的画面要求」里通常已写明),不算超载。`
|
|
49
|
+
: "";
|
|
50
|
+
return `你是版面评审。先读「这页该讲什么」,再看图,判断三件事:
|
|
51
|
+
|
|
52
|
+
A. 图表达的意思和主张是否一致?
|
|
53
|
+
- "一致":图在支持这条主张
|
|
54
|
+
- "矛盾":图说了相反的 / 让人得出相反结论 → 这是扭曲(P0)
|
|
55
|
+
- "多出":图里出现稿件没要求的信息(自造数据、额外结论、无关装饰性文字)→ 超载(P1)${coverRule}
|
|
56
|
+
B. 贴合:颜色角色 / 画法 / 信息密度是否命中风格锚点?1–5 分(5=全部命中,3=一处漂移,1=换了风格)
|
|
57
|
+
C. 承接:和上一页的视觉语言是否连续(同一套卡片 / 线条 / 构图习惯)?1–5 分(没有上一页就给 4)
|
|
58
|
+
|
|
59
|
+
只输出 JSON(不要 markdown 代码块):
|
|
60
|
+
{"verdict":"一致|矛盾|多出","contradiction":"矛盾在哪(没有就空)","extra":["多出来的信息"],"fit":1,"continuity":1,"notes":"≤60 字"}
|
|
61
|
+
|
|
62
|
+
页码:${page || "(未指定)"}
|
|
63
|
+
受众 / 意图:${intent || "(未提供)"}
|
|
64
|
+
风格锚点:${styleAnchor || "(未提供)"}
|
|
65
|
+
上一页:${prevPage || "(无)"}
|
|
66
|
+
|
|
67
|
+
这页该讲什么(稿件原文):
|
|
68
|
+
"""
|
|
69
|
+
${pageText}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
这页的画面要求(visualBrief;图应照此呈现,不是稿件正文):
|
|
73
|
+
"""
|
|
74
|
+
${visualBrief || "(未写)"}
|
|
75
|
+
"""`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 封面版式允许出现的元信息:署名 / 场合 / 日期 / 品牌角标不算「多出」。 */
|
|
79
|
+
const COVER_META_RE = /(署名|作者|主讲|演讲|讲者|分享人|场合|会议|分享会|内部分享|日期|时间|地点|公司|团队|版|版权|©|logo|角标|20\d{2}\s*[-年./]\s*\d)/i;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 按页角色校正对照结论(C2):封面页里属于版式要求的元信息,不该被算成超载。
|
|
83
|
+
*
|
|
84
|
+
* 为什么要程序兜底:判超载的是视觉模型,它看不到版式约定,容易把「Yuna · 公司内部分享 · 2026-10」
|
|
85
|
+
* 这种封面署名判成「多出来的信息」→ 表意 3 分、门禁不过、转人工。规则是确定性的,就不交给模型猜。
|
|
86
|
+
* 只对封面生效;正文页多出信息仍然算超载(这条不能松)。
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeContrastForPage(contrast = {}, { cover = false } = {}) {
|
|
89
|
+
const extras = Array.isArray(contrast.extra) ? contrast.extra : [];
|
|
90
|
+
if (!cover || extras.length === 0 || contrast.verdict === "矛盾") return { ...contrast, ignoredExtras: [] };
|
|
91
|
+
const ignored = extras.filter((item) => COVER_META_RE.test(String(item ?? "")));
|
|
92
|
+
const kept = extras.filter((item) => !COVER_META_RE.test(String(item ?? "")));
|
|
93
|
+
if (ignored.length === 0) return { ...contrast, ignoredExtras: [] };
|
|
94
|
+
return {
|
|
95
|
+
...contrast,
|
|
96
|
+
verdict: kept.length ? "多出" : "一致",
|
|
97
|
+
extra: kept,
|
|
98
|
+
ignoredExtras: ignored,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const FENCE = /^\s*```[a-z]*\s*/i;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 挑出「整页出图」的 prompt(修图版本的 prompt.txt 只有编辑指令,没有页面标题,不能当基准)。
|
|
106
|
+
* @param {string[]} prompts - 从新到旧排列的 prompt 文本
|
|
107
|
+
*/
|
|
108
|
+
export function pickRenderExpectations(prompts = []) {
|
|
109
|
+
for (const prompt of prompts) {
|
|
110
|
+
const expected = expectationsOfPrompt(prompt);
|
|
111
|
+
if (expected.title || expected.question || expected.claim) return expected;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 从模型输出里取最外层 JSON 对象;取不到返回 null。 */
|
|
117
|
+
export function jsonOf(text) {
|
|
118
|
+
const raw = String(text ?? "").replace(FENCE, "").replace(/\s*```\s*$/, "");
|
|
119
|
+
const start = raw.indexOf("{");
|
|
120
|
+
const end = raw.lastIndexOf("}");
|
|
121
|
+
if (start < 0 || end <= start) return null;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseTranscription(text) {
|
|
130
|
+
const parsed = jsonOf(text);
|
|
131
|
+
if (!parsed) return { transcription: null, unparsed: true };
|
|
132
|
+
const list = (value) => (Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : []);
|
|
133
|
+
return {
|
|
134
|
+
unparsed: false,
|
|
135
|
+
transcription: {
|
|
136
|
+
title: String(parsed.title ?? "").trim(),
|
|
137
|
+
subtitle: String(parsed.subtitle ?? "").trim(),
|
|
138
|
+
body: list(parsed.body),
|
|
139
|
+
numbers: list(parsed.numbers),
|
|
140
|
+
labels: list(parsed.labels),
|
|
141
|
+
unreadable: list(parsed.unreadable),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseBlind(text) {
|
|
147
|
+
const parsed = jsonOf(text);
|
|
148
|
+
if (!parsed) return { blind: null, unparsed: true };
|
|
149
|
+
return {
|
|
150
|
+
unparsed: false,
|
|
151
|
+
blind: {
|
|
152
|
+
what: String(parsed.what ?? "").trim(),
|
|
153
|
+
ask: String(parsed.ask ?? "").trim(),
|
|
154
|
+
confusing: String(parsed.confusing ?? "").trim(),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseContrast(text) {
|
|
160
|
+
const parsed = jsonOf(text);
|
|
161
|
+
if (!parsed) return { contrast: null, unparsed: true };
|
|
162
|
+
const verdict = ["一致", "矛盾", "多出"].includes(String(parsed.verdict ?? "").trim())
|
|
163
|
+
? String(parsed.verdict).trim()
|
|
164
|
+
: "一致";
|
|
165
|
+
const score = (value) => {
|
|
166
|
+
const number = Number(value);
|
|
167
|
+
if (!Number.isFinite(number)) return 4;
|
|
168
|
+
return Math.min(5, Math.max(1, Math.round(number)));
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
unparsed: false,
|
|
172
|
+
contrast: {
|
|
173
|
+
verdict,
|
|
174
|
+
contradiction: String(parsed.contradiction ?? "").trim(),
|
|
175
|
+
extra: Array.isArray(parsed.extra) ? parsed.extra.map((item) => String(item ?? "").trim()).filter(Boolean).slice(0, 8) : [],
|
|
176
|
+
fit: score(parsed.fit),
|
|
177
|
+
continuity: score(parsed.continuity),
|
|
178
|
+
notes: String(parsed.notes ?? "").trim().slice(0, 120),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 出图 prompt → 图上**应该出现**的文字(C1 的对照基准)。
|
|
185
|
+
*
|
|
186
|
+
* 为什么不用当前 script 当基准:图可能是旧稿出的(script 后来改过),拿新稿比旧图会把
|
|
187
|
+
* 「图是旧的」误判成「图写错了字」。基准取这张图自己的 prompt,再单独查「图是否基于旧稿」。
|
|
188
|
+
*/
|
|
189
|
+
export function expectationsOfPrompt(promptText = "") {
|
|
190
|
+
const lines = String(promptText ?? "").split("\n");
|
|
191
|
+
// 封面页的 prompt 用的是「主标题 / 副标题」,正文页是「页面标题 / 这页回答 / 主张」,两种都要认。
|
|
192
|
+
const field = (...labels) => {
|
|
193
|
+
for (const label of labels) {
|
|
194
|
+
const line = lines.find((item) => item.trim().startsWith(`${label}:`) || item.trim().startsWith(`${label}:`));
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
const cut = line.indexOf(":") >= 0 ? line.indexOf(":") + 1 : line.indexOf(":") + 1;
|
|
197
|
+
const value = line.slice(cut).trim();
|
|
198
|
+
if (value) return value;
|
|
199
|
+
}
|
|
200
|
+
return "";
|
|
201
|
+
};
|
|
202
|
+
const title = field("页面标题", "主标题", "标题");
|
|
203
|
+
const question = field("这页回答", "副标题");
|
|
204
|
+
const claim = field("主张");
|
|
205
|
+
const quoted = [...String(promptText ?? "").matchAll(/[「“"]([^」”"]{2,60})[」”"]/g)].map((match) => match[1].trim()).filter(Boolean);
|
|
206
|
+
return {
|
|
207
|
+
title,
|
|
208
|
+
question,
|
|
209
|
+
claim,
|
|
210
|
+
body: "",
|
|
211
|
+
quoted,
|
|
212
|
+
numbers: numbersIn([title, question, claim, ...quoted].join("\n")),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 规范化:去空白 / 全角空格 / 常见引号,用于逐字比对。 */
|
|
217
|
+
export function squash(text) {
|
|
218
|
+
return String(text ?? "").replace(/[\s\u3000]+/g, "").replace(/[“”"「」『』'’]/g, "").replace(/[,。、;:!?,.;:!?·—–\-]/g, "");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 从稿件里抽数字(含小数 / 百分号 / 单位),用于「数字必须逐字对」的硬判据。 */
|
|
222
|
+
export function numbersIn(text) {
|
|
223
|
+
const found = String(text ?? "").match(/\d+(?:[.,]\d+)?%?/g) ?? [];
|
|
224
|
+
return [...new Set(found)].slice(0, 20);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 最长公共子序列长度(字符级),用于正文相似度。 */
|
|
228
|
+
export function lcsLength(a, b) {
|
|
229
|
+
const left = [...String(a ?? "")];
|
|
230
|
+
const right = [...String(b ?? "")];
|
|
231
|
+
if (!left.length || !right.length) return 0;
|
|
232
|
+
let previous = new Array(right.length + 1).fill(0);
|
|
233
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
234
|
+
const current = new Array(right.length + 1).fill(0);
|
|
235
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
236
|
+
current[j] = left[i - 1] === right[j - 1] ? previous[j - 1] + 1 : Math.max(previous[j], current[j - 1]);
|
|
237
|
+
}
|
|
238
|
+
previous = current;
|
|
239
|
+
}
|
|
240
|
+
return previous[right.length];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 转写 vs 稿件逐段 diff(C1 第 3 步)。
|
|
245
|
+
* 判据:标题逐字相等;数字 / 术语逐字相等;正文允许 ≤2% 字符差异。
|
|
246
|
+
*/
|
|
247
|
+
export function diffTranscription({ expected = {}, transcription = {} }) {
|
|
248
|
+
const diffs = [];
|
|
249
|
+
const wantTitle = String(expected.title ?? "").trim();
|
|
250
|
+
const gotTitle = String(transcription.title ?? "").trim();
|
|
251
|
+
if (wantTitle) {
|
|
252
|
+
if (!gotTitle) diffs.push({ type: "missing", field: "title", expected: wantTitle, actual: "" });
|
|
253
|
+
else if (squash(wantTitle) !== squash(gotTitle)) {
|
|
254
|
+
diffs.push({ type: "wrong", field: "title", expected: wantTitle, actual: gotTitle });
|
|
255
|
+
}
|
|
256
|
+
} else if (gotTitle) {
|
|
257
|
+
diffs.push({ type: "extra", field: "title", expected: "", actual: gotTitle });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const gotAll = squash([transcription.title, transcription.subtitle, ...(transcription.body ?? []), ...(transcription.numbers ?? []), ...(transcription.labels ?? [])].join(""));
|
|
261
|
+
// 这页回答 / 主张:图上通常印成副标题或首段,找不到就算软差异(不判硬失败,但说明「没印出来」)。
|
|
262
|
+
// 但画面要求里引号内的文案是设计师写进画面的替代说法,命中它就不算缺失。
|
|
263
|
+
const quotedHit = (expected.quoted ?? []).some((quote) => {
|
|
264
|
+
const needle = squash(quote).slice(0, 8);
|
|
265
|
+
return needle.length >= 4 && gotAll.includes(needle);
|
|
266
|
+
});
|
|
267
|
+
for (const [field, want] of [["subtitle", expected.question], ["claim", expected.claim]]) {
|
|
268
|
+
const text = String(want ?? "").trim();
|
|
269
|
+
if (text.length < 6) continue;
|
|
270
|
+
const needle = squash(text).slice(0, 24);
|
|
271
|
+
if (needle && !gotAll.includes(needle) && !quotedHit) diffs.push({ type: "missing", field, expected: text.slice(0, 80), actual: "" });
|
|
272
|
+
}
|
|
273
|
+
const wantNumbers = numbersIn([expected.title, expected.question, expected.claim, expected.body].filter(Boolean).join("\n"));
|
|
274
|
+
const gotNumbers = new Set((transcription.numbers ?? []).flatMap((item) => numbersIn(item)));
|
|
275
|
+
const bodySquashed = squash([...((transcription.body ?? []))].join(""));
|
|
276
|
+
for (const number of wantNumbers) {
|
|
277
|
+
if (!gotNumbers.has(number) && !bodySquashed.includes(squash(number)) && !gotAll.includes(squash(number))) {
|
|
278
|
+
diffs.push({ type: "missing", field: "number", expected: number, actual: "" });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const wantBody = String(expected.body ?? "").trim();
|
|
283
|
+
const gotBody = (transcription.body ?? []).join("");
|
|
284
|
+
if (wantBody) {
|
|
285
|
+
const wanted = squash(wantBody);
|
|
286
|
+
const got = squash(gotBody);
|
|
287
|
+
const same = lcsLength(wanted, got);
|
|
288
|
+
const ratio = wanted.length ? 1 - same / wanted.length : 0;
|
|
289
|
+
if (!got) diffs.push({ type: "missing", field: "body", expected: wantBody.slice(0, 80), actual: "" });
|
|
290
|
+
else if (ratio > 0.02) diffs.push({ type: "wrong", field: "body", expected: wantBody.slice(0, 80), actual: gotBody.slice(0, 80), ratio: Number(ratio.toFixed(3)) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 多出来的正文段落:稿件里查不到、且长度 ≥12 字(排除页码 / 角标这类版面元素)
|
|
294
|
+
const expectedAll = squash([expected.title, expected.question, expected.claim, expected.body].filter(Boolean).join(""));
|
|
295
|
+
for (const paragraph of transcription.body ?? []) {
|
|
296
|
+
const text = String(paragraph ?? "").trim();
|
|
297
|
+
if (text.length < 12) continue;
|
|
298
|
+
if (!expectedAll.includes(squash(text).slice(0, 12))) {
|
|
299
|
+
diffs.push({ type: "extra", field: "body", expected: "", actual: text.slice(0, 80) });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return diffs;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 双转写一致性(C1 第 2 步):两次结果按字段比对。
|
|
307
|
+
* 只对「两次都有内容」的字段判;任一为空也算不稳(模型没读全)。
|
|
308
|
+
*/
|
|
309
|
+
export function mergeDualTranscription(first = {}, second = {}) {
|
|
310
|
+
const unstable = [];
|
|
311
|
+
const fields = ["title", "subtitle"];
|
|
312
|
+
for (const field of fields) {
|
|
313
|
+
const a = squash(first[field]);
|
|
314
|
+
const b = squash(second[field]);
|
|
315
|
+
if (a !== b) unstable.push(field);
|
|
316
|
+
}
|
|
317
|
+
const bodyA = [...(first.body ?? [])].map(squash).join("|");
|
|
318
|
+
const bodyB = [...(second.body ?? [])].map(squash).join("|");
|
|
319
|
+
if (bodyA !== bodyB) unstable.push("body");
|
|
320
|
+
const numbersA = [...(first.numbers ?? [])].map(squash).sort().join("|");
|
|
321
|
+
const numbersB = [...(second.numbers ?? [])].map(squash).sort().join("|");
|
|
322
|
+
if (numbersA !== numbersB) unstable.push("numbers");
|
|
323
|
+
return { unstable, stable: unstable.length === 0 };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 保真判定(C1 第 4 步):标题逐字、数字逐字是硬失败;正文 >2% 差异算失败。
|
|
328
|
+
* 双转写不一致的字段不算自动判定(送人工),但如果差异字段本身就是标题/数字,直接判「不确定」。
|
|
329
|
+
*/
|
|
330
|
+
export function judgeFidelity({ diffs = [], unstable = [] } = {}) {
|
|
331
|
+
const hard = diffs.filter((diff) => diff.field === "title" || diff.field === "number");
|
|
332
|
+
const soft = diffs.filter((diff) => diff.field === "body" || diff.field === "subtitle" || diff.field === "claim");
|
|
333
|
+
const unreadable = diffs.filter((diff) => diff.field === "unreadable");
|
|
334
|
+
// 双转写任何字段不一致都标「不确定」送人工(不参与自动判定)——C1 第 2 步的硬要求。
|
|
335
|
+
if (unstable.length) {
|
|
336
|
+
return { verdict: "不确定", score: 3, mustHuman: true, blockers: [...hard, ...soft], unstable };
|
|
337
|
+
}
|
|
338
|
+
if (hard.length) return { verdict: "失败", score: 1, mustHuman: false, blockers: hard, unstable };
|
|
339
|
+
if (soft.length) return { verdict: "失败", score: 2, mustHuman: false, blockers: soft, unstable };
|
|
340
|
+
if (unreadable.length) return { verdict: "不确定", score: 3, mustHuman: true, blockers: unreadable, unstable };
|
|
341
|
+
return { verdict: "通过", score: 5, mustHuman: false, blockers: [], unstable };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 表意判定(C2b):矛盾 = 扭曲(P0);多出 = 超载(P1)。 */
|
|
345
|
+
export function judgeMeaning(contrast = {}) {
|
|
346
|
+
if (contrast.verdict === "矛盾") return { verdict: "扭曲", severity: "P0", score: 1 };
|
|
347
|
+
if (contrast.verdict === "多出") return { verdict: "超载", severity: "P1", score: 3 };
|
|
348
|
+
return { verdict: "通过", severity: "", score: 5 };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 五维打分(C4):保真 / 完整 / 表意 / 贴合 / 承接。
|
|
353
|
+
* 可交付门槛:保真 = 5 且 表意 ≥ 4,其余 ≥ 3;总分只记录不做门禁。
|
|
354
|
+
*/
|
|
355
|
+
export function composeReviewScore({ fidelity, meaning, complete = 5, fit = 4, continuity = 4 } = {}) {
|
|
356
|
+
const scores = {
|
|
357
|
+
保真: Number(fidelity?.score ?? 1),
|
|
358
|
+
完整: Math.min(5, Math.max(1, Number(complete))),
|
|
359
|
+
表意: Number(meaning?.score ?? 1),
|
|
360
|
+
贴合: Math.min(5, Math.max(1, Number(fit))),
|
|
361
|
+
承接: Math.min(5, Math.max(1, Number(continuity))),
|
|
362
|
+
};
|
|
363
|
+
const total = Object.values(scores).reduce((sum, value) => sum + value, 0);
|
|
364
|
+
const blockers = [];
|
|
365
|
+
if (scores.保真 !== 5) blockers.push(`保真 ${scores.保真}/5(错字 / 错数 / 缺字是硬失败)`);
|
|
366
|
+
if (scores.表意 < 4) blockers.push(`表意 ${scores.表意}/5(没看懂 / 扭曲)`);
|
|
367
|
+
for (const dim of ["完整", "贴合", "承接"]) {
|
|
368
|
+
if (scores[dim] < 3) blockers.push(`${dim} ${scores[dim]}/5(低于 3)`);
|
|
369
|
+
}
|
|
370
|
+
return { scores, total, gate: blockers.length === 0, blockers, mustHuman: Boolean(fidelity?.mustHuman) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** C5:失败类型 → 先改什么 + 重出范围。 */
|
|
374
|
+
export function rebuildAdvice({ fidelity, meaning } = {}) {
|
|
375
|
+
const rows = [];
|
|
376
|
+
if (fidelity?.verdict === "失败") {
|
|
377
|
+
const hasHard = (fidelity.blockers ?? []).some((diff) => diff.field === "title" || diff.field === "number");
|
|
378
|
+
rows.push({
|
|
379
|
+
failure: hasHard ? "保真失败(错字 / 错数)" : "保真失败(正文缺字 / 多字)",
|
|
380
|
+
firstChange: "只改 prompt:强化逐字照排、禁止模型改字或加字",
|
|
381
|
+
scope: "该页",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (fidelity?.verdict === "不确定") rows.push({ failure: "转写不确定(两张转写不一致)", firstChange: "人工看一眼原图,或再转写一次", scope: "该页(人工)" });
|
|
385
|
+
if (meaning?.verdict === "扭曲") rows.push({ failure: "表意扭曲(说了别的 / 说反了)", firstChange: "先改 script 的主张或画面要求,再出图", scope: "该页" });
|
|
386
|
+
if (meaning?.verdict === "超载") rows.push({ failure: "超载(图里多出信息)", firstChange: "删 script 里多余的要求", scope: "该页" });
|
|
387
|
+
return rows;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 每页结果汇总成 review.images.md。 */
|
|
391
|
+
export function renderImageReviewMarkdown({ deckTitle = "", generatedAt = "", rows = [] } = {}) {
|
|
392
|
+
const lines = [
|
|
393
|
+
`# 图片评审 · ${deckTitle || "(未命名 deck)"}`,
|
|
394
|
+
"",
|
|
395
|
+
`生成时间:${generatedAt || "(未记录)"};视觉模型只做转写,判定由程序做;不引入 OCR。`,
|
|
396
|
+
"",
|
|
397
|
+
"| 页 | 保真 | 完整 | 表意 | 贴合 | 承接 | 门禁 | 结论 |",
|
|
398
|
+
"|---|---|---|---|---|---|---|---|",
|
|
399
|
+
];
|
|
400
|
+
for (const row of rows) {
|
|
401
|
+
const score = row.scores ?? {};
|
|
402
|
+
const verdict = row.gate ? "可交付" : row.mustHuman ? "转人工" : (row.blockers ?? []).join(";") || "需重出";
|
|
403
|
+
lines.push(
|
|
404
|
+
`| ${row.page} | ${score.保真 ?? "-"} | ${score.完整 ?? "-"} | ${score.表意 ?? "-"} | ${score.贴合 ?? "-"} | ${score.承接 ?? "-"} | ${row.gate ? "过" : "不过"} | ${String(verdict).slice(0, 80)} |`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
lines.push("");
|
|
408
|
+
lines.push("门槛:保真 = 5 且 表意 ≥ 4,其余 ≥ 3;同一页 3 次不过转人工(C5)。");
|
|
409
|
+
return `${lines.join("\n")}\n`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 完整一页评审(C1 + C2 + C3 + C4):两次转写 → diff → 盲述 → 对照 → 五维打分。
|
|
414
|
+
* 纯编排,不落盘(落盘在 tools.js);verify 脚本与工具共用这一条路径。
|
|
415
|
+
*/
|
|
416
|
+
export async function runImageReview({
|
|
417
|
+
imagePath,
|
|
418
|
+
pageId,
|
|
419
|
+
expected = {},
|
|
420
|
+
pageText = "",
|
|
421
|
+
intentText = "",
|
|
422
|
+
styleAnchor = "",
|
|
423
|
+
prevPageText = "",
|
|
424
|
+
visualBrief = "",
|
|
425
|
+
cover = false,
|
|
426
|
+
settings = {},
|
|
427
|
+
skipBlind = false,
|
|
428
|
+
fetchImpl = fetch,
|
|
429
|
+
}) {
|
|
430
|
+
const call = async (prompt, maxTokens) => {
|
|
431
|
+
const result = await askVisionModel({
|
|
432
|
+
imagePath,
|
|
433
|
+
prompt,
|
|
434
|
+
baseURL: settings.baseURL,
|
|
435
|
+
apiKey: settings.apiKey,
|
|
436
|
+
model: settings.model,
|
|
437
|
+
maxTokens: maxTokens ?? settings.maxTokens,
|
|
438
|
+
timeoutMs: settings.timeoutMs,
|
|
439
|
+
fetchImpl,
|
|
440
|
+
});
|
|
441
|
+
return result.content;
|
|
442
|
+
};
|
|
443
|
+
const transcribeOnce = async () => {
|
|
444
|
+
let parsed = parseTranscription(await call(transcribePrompt({ page: pageId }), settings.maxTokens));
|
|
445
|
+
if (parsed.unparsed) parsed = parseTranscription(await call(transcribePrompt({ page: pageId }), Math.round((settings.maxTokens ?? 3000) * 1.5)));
|
|
446
|
+
if (parsed.unparsed) throw new Error("转写两次都没返回约定 JSON,无法评审(不要猜;重试或换视觉模型)");
|
|
447
|
+
return parsed.transcription;
|
|
448
|
+
};
|
|
449
|
+
// 双转写:同一张图、同一 prompt、两次独立调用(固定温度由服务端默认;不一致就标「不确定」)
|
|
450
|
+
const [first, second] = await Promise.all([transcribeOnce(), transcribeOnce()]);
|
|
451
|
+
const dual = mergeDualTranscription(first, second);
|
|
452
|
+
const diffs = diffTranscription({ expected, transcription: first });
|
|
453
|
+
const fidelity = judgeFidelity({ diffs, unstable: dual.unstable });
|
|
454
|
+
|
|
455
|
+
let blind = null;
|
|
456
|
+
let blindError = "";
|
|
457
|
+
if (!skipBlind) {
|
|
458
|
+
try {
|
|
459
|
+
const parsed = parseBlind(await call(blindPrompt({ page: pageId }), Math.min(1200, settings.maxTokens ?? 3000)));
|
|
460
|
+
if (parsed.unparsed) blindError = "盲述没有返回约定 JSON";
|
|
461
|
+
else blind = parsed.blind;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
blindError = String(error?.message ?? error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const contrastParsed = parseContrast(
|
|
468
|
+
await call(
|
|
469
|
+
contrastPrompt({ page: pageId, pageText, intent: intentText, styleAnchor, prevPage: prevPageText, visualBrief, cover }),
|
|
470
|
+
Math.min(2000, settings.maxTokens ?? 3000),
|
|
471
|
+
),
|
|
472
|
+
);
|
|
473
|
+
if (contrastParsed.unparsed) throw new Error("对照评审没有返回约定 JSON(不要猜结论;重试)");
|
|
474
|
+
const contrast = normalizeContrastForPage(contrastParsed.contrast, { cover });
|
|
475
|
+
const meaning = judgeMeaning(contrast);
|
|
476
|
+
const review = composeReviewScore({ fidelity, meaning, fit: contrast.fit, continuity: contrast.continuity });
|
|
477
|
+
const advice = rebuildAdvice({ fidelity, meaning });
|
|
478
|
+
return { transcription: first, second, dual, diffs, fidelity, blind, blindError, contrast, meaning, review, advice };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 最近一次评审记录(按页)→ 用于累计重出次数与 3 次阈值。 */
|
|
482
|
+
export function attemptsOf(qa = {}) {
|
|
483
|
+
const attempts = Number(qa?.review?.attempts ?? qa?.review?.attempt ?? 0);
|
|
484
|
+
return Number.isFinite(attempts) && attempts > 0 ? Math.floor(attempts) : 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 一次视觉调用(OpenAI 兼容 chat/completions,image_url 传 base64;不用 OCR)。 */
|
|
488
|
+
export async function askVisionModel({ imagePath, prompt, baseURL, apiKey, model, maxTokens = 3000, timeoutMs = 180_000, fetchImpl = fetch }) {
|
|
489
|
+
if (!baseURL) throw new Error("视觉评审没有配置 baseURL:在 profile 的 deckStudio.imageReview 里设 baseURL,或设 env DSH_DECK_VISION_BASE_URL");
|
|
490
|
+
if (!model) throw new Error("视觉评审没有配置 model:在 profile 的 deckStudio.imageReview 里设 model,或设 env DSH_DECK_VISION_MODEL");
|
|
491
|
+
if (!apiKey) throw new Error("视觉评审没有 API key:设 imageReview.apiKeyEnv 指向的环境变量(默认 WOCHENG_API_KEY)");
|
|
492
|
+
const bytes = readFileSync(imagePath);
|
|
493
|
+
const ext = extname(imagePath).toLowerCase();
|
|
494
|
+
const mediaType = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".webp" ? "image/webp" : "image/png";
|
|
495
|
+
const body = {
|
|
496
|
+
model,
|
|
497
|
+
messages: [
|
|
498
|
+
{
|
|
499
|
+
role: "user",
|
|
500
|
+
content: [
|
|
501
|
+
{ type: "text", text: prompt },
|
|
502
|
+
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${bytes.toString("base64")}` } },
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
max_tokens: maxTokens,
|
|
507
|
+
};
|
|
508
|
+
const response = await fetchImpl(`${String(baseURL).replace(/\/$/, "")}/chat/completions`, {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
511
|
+
body: JSON.stringify(body),
|
|
512
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
513
|
+
});
|
|
514
|
+
const text = await response.text();
|
|
515
|
+
if (!response.ok) throw new Error(`视觉调用失败 ${response.status}:${text.slice(0, 300)}`);
|
|
516
|
+
const payload = JSON.parse(text);
|
|
517
|
+
const content = payload.choices?.[0]?.message?.content;
|
|
518
|
+
const finish = payload.choices?.[0]?.finish_reason ?? "?";
|
|
519
|
+
if (typeof content !== "string" || !content.trim()) {
|
|
520
|
+
throw new Error(`视觉调用返回没有正文(finish_reason=${finish})——多半是思考 token 用光,调大 maxTokens 重试`);
|
|
521
|
+
}
|
|
522
|
+
return { content, finish, usage: payload.usage ?? null };
|
|
523
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 逐页契约(智力升级 A1 / F-181):每页写之前先填 5 项,缺一项不进入写作。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是 5 项:一页 slide 讲不清,多半不是句子问题,而是「这页在论证里干什么」没定。
|
|
5
|
+
* - 角色:承上 / 结论 / 证据 / 对比 / 机制 / 风险 / 请求 —— 决定位置;
|
|
6
|
+
* - 受众变化:从 A 状态 → B 状态(可观察);「让听众理解」不算变化,没法判定;
|
|
7
|
+
* - 关系:与上一页 递进 / 并列 / 因果 / 转折 —— 答不出就是断链;
|
|
8
|
+
* - 主张:一句话,≤ 60 字,可判真假;
|
|
9
|
+
* - 证据:事实 ID / 示意数据 / 无需 —— 决定可信度(事实标注见 I-07)。
|
|
10
|
+
*
|
|
11
|
+
* 这里是纯函数:解析出来的字段 → 缺什么 / 哪项不合规 / 给 Agent 的下一步提示。
|
|
12
|
+
* 老 deck 没有这些字段 → 只提示、不报错、不阻断出图。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const CONTRACT_ROLES = ["承上", "结论", "证据", "对比", "机制", "风险", "请求"];
|
|
16
|
+
export const CONTRACT_RELATIONS = ["递进", "并列", "因果", "转折", "开场", "收束"];
|
|
17
|
+
|
|
18
|
+
/** 5 项契约:key 是给人看的字段名,aliases 是 script 里可能出现的写法。 */
|
|
19
|
+
export const CONTRACT_FIELDS = [
|
|
20
|
+
{ key: "角色", aliases: ["角色"], hint: `承上 / 结论 / 证据 / 对比 / 机制 / 风险 / 请求 之一` },
|
|
21
|
+
{ key: "受众变化", aliases: ["受众变化", "听众变化"], hint: `写成「从 X → Y」的可观察变化;「让听众理解」不算` },
|
|
22
|
+
{ key: "关系", aliases: ["关系"], hint: `与上一页:${CONTRACT_RELATIONS.join(" / ")}` },
|
|
23
|
+
{ key: "主张", aliases: ["主张"], hint: "一句话,≤ 60 字,可判真假" },
|
|
24
|
+
{ key: "证据", aliases: ["证据"], hint: "事实 ID / 示意数据 / 无需" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** propose_edit 可写的契约字段名(工具 enum 用)。 */
|
|
28
|
+
export const CONTRACT_FIELD_KEYS = CONTRACT_FIELDS.map((field) => field.key);
|
|
29
|
+
|
|
30
|
+
const pick = (fields = {}, aliases = []) => {
|
|
31
|
+
for (const alias of aliases) {
|
|
32
|
+
const value = fields[alias];
|
|
33
|
+
if (value != null && String(value).trim()) return String(value).trim();
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 从 script 字段表里读 5 项契约。
|
|
40
|
+
* @param {Record<string,string>} fields - parseSectionedPages 产出的 fields
|
|
41
|
+
*/
|
|
42
|
+
export function readPageContract(fields = {}) {
|
|
43
|
+
return {
|
|
44
|
+
角色: pick(fields, ["角色"]),
|
|
45
|
+
受众变化: pick(fields, ["受众变化", "听众变化"]),
|
|
46
|
+
关系: pick(fields, ["关系"]),
|
|
47
|
+
主张: pick(fields, ["主张"]),
|
|
48
|
+
证据: pick(fields, ["证据"]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 契约体检:缺项 + 弱项(写了但不可判定)+ 下一步提示。
|
|
54
|
+
* @param {Record<string,string>} fields
|
|
55
|
+
*/
|
|
56
|
+
export function validatePageContract(fields = {}) {
|
|
57
|
+
const contract = readPageContract(fields);
|
|
58
|
+
const missing = CONTRACT_FIELDS.filter((field) => !contract[field.key]).map((field) => field.key);
|
|
59
|
+
const weak = [];
|
|
60
|
+
if (contract.角色 && !CONTRACT_ROLES.some((role) => contract.角色.includes(role))) {
|
|
61
|
+
weak.push(`角色「${contract.角色}」不在 ${CONTRACT_ROLES.join(" / ")} 里——它在论证里处在什么位置?`);
|
|
62
|
+
}
|
|
63
|
+
if (contract.关系 && !CONTRACT_RELATIONS.some((relation) => contract.关系.includes(relation))) {
|
|
64
|
+
weak.push(`关系「${contract.关系}」不在 ${CONTRACT_RELATIONS.join(" / ")} 里——和上一页是递进、并列、因果还是转折?`);
|
|
65
|
+
}
|
|
66
|
+
if (contract.受众变化 && /^(让|使|帮助)?(听众|用户|大家|观众)?(理解|了解|知道|明白|感受|意识到|认识)/.test(contract.受众变化)) {
|
|
67
|
+
weak.push(`受众变化「${contract.受众变化}」不可观察——写成「从 A → B」(听完能说出 / 能判断 / 能做出什么)`);
|
|
68
|
+
}
|
|
69
|
+
if (contract.主张 && [...contract.主张].length > 60) {
|
|
70
|
+
weak.push(`主张 ${[...contract.主张].length} 字,超过 60 字——一页一主张,长的部分挪进正文`);
|
|
71
|
+
}
|
|
72
|
+
const ok = missing.length === 0 && weak.length === 0;
|
|
73
|
+
const hint = missing.length
|
|
74
|
+
? `这页还缺契约:${missing.join(" / ")}。写正文之前先补齐(提案时 field 用这些字段名);老 deck 不强制,但缺了就别怪「这页讲不清」。`
|
|
75
|
+
: weak.length
|
|
76
|
+
? `契约有弱项:${weak.join(";")}`
|
|
77
|
+
: "";
|
|
78
|
+
return { ok, contract, missing, weak, hint };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 整本契约汇总:哪几页齐、哪几页缺哪项。
|
|
83
|
+
* @param {{page:number, fields?:Record<string,string>}[]} pages - parseSectionedPages 产出
|
|
84
|
+
*/
|
|
85
|
+
export function summarizeContracts(pages = []) {
|
|
86
|
+
const perPage = (Array.isArray(pages) ? pages : [])
|
|
87
|
+
.map((page) => {
|
|
88
|
+
const result = validatePageContract(page?.fields ?? {});
|
|
89
|
+
return { page: Number(page?.page ?? 0), ok: result.ok, missing: result.missing, weak: result.weak };
|
|
90
|
+
})
|
|
91
|
+
.sort((left, right) => left.page - right.page);
|
|
92
|
+
const complete = perPage.filter((item) => item.ok).length;
|
|
93
|
+
return {
|
|
94
|
+
pages: perPage.length,
|
|
95
|
+
complete,
|
|
96
|
+
// 一页都没填(老 deck)→ 不刷屏,只给一句话提示。
|
|
97
|
+
untouched: perPage.length > 0 && complete === 0 && perPage.every((item) => item.missing.length === CONTRACT_FIELDS.length),
|
|
98
|
+
perPage,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 脚本页面上的一行状态,如「契约 4/5(缺 证据)」。 */
|
|
103
|
+
export function contractTag(result) {
|
|
104
|
+
const total = CONTRACT_FIELDS.length;
|
|
105
|
+
const done = total - result.missing.length;
|
|
106
|
+
if (result.ok) return "契约 5/5";
|
|
107
|
+
if (done === 0) return "契约 0/5(未填)";
|
|
108
|
+
const parts = [];
|
|
109
|
+
if (result.missing.length) parts.push(`缺 ${result.missing.join("、")}`);
|
|
110
|
+
if (result.weak.length) parts.push("有弱项");
|
|
111
|
+
return `契约 ${done}/${total}(${parts.join(";")})`;
|
|
112
|
+
}
|