dsh-plugin-deck-studio 0.2.5 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/cordis.example.yml +88 -30
  2. package/cordis.patch.yml +81 -30
  3. package/index.js +187 -28
  4. package/lib/capabilities.js +234 -0
  5. package/lib/citation-check.js +93 -0
  6. package/lib/config.js +30 -7
  7. package/lib/deck-brief.js +35 -1
  8. package/lib/deck-docs.js +45 -7
  9. package/lib/deck-templates.js +8 -1
  10. package/lib/deck-workspace.js +27 -1
  11. package/lib/designs.js +15 -0
  12. package/lib/fact-labels.js +81 -0
  13. package/lib/first-draft.js +119 -0
  14. package/lib/image-review.js +523 -0
  15. package/lib/page-contract.js +112 -0
  16. package/lib/proposal-decision.js +249 -75
  17. package/lib/review-agent.js +168 -0
  18. package/lib/review-personas.js +59 -3
  19. package/lib/review-protocol.js +411 -0
  20. package/lib/review-readers.js +300 -0
  21. package/lib/review-score.js +198 -0
  22. package/lib/title-chain.js +122 -0
  23. package/lib/tools.js +983 -85
  24. package/package.json +4 -3
  25. package/seeds/docs/assets/screenshots/07-new-deck.png +0 -0
  26. package/seeds/docs/assets/screenshots/08-presentation.png +0 -0
  27. package/seeds/docs/assets/screenshots/09-style-library.png +0 -0
  28. package/seeds/docs/assets/screenshots/10-style-management.png +0 -0
  29. package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
  30. package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
  31. package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
  32. package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
  33. package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
  34. package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
  35. package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
  36. package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
  37. package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
  38. package/seeds/docs/assets/screenshots/manual/07-new-deck.png +0 -0
  39. package/seeds/docs/compat.md +3 -3
  40. package/seeds/docs/install.en.md +29 -14
  41. package/seeds/docs/install.md +29 -15
  42. package/seeds/docs/sample-deck.en.md +1 -1
  43. package/seeds/docs/sample-deck.md +1 -1
  44. package/seeds/docs/styles.en.md +5 -4
  45. package/seeds/docs/styles.md +17 -7
  46. package/seeds/samples/DSA-01.jpg +0 -0
  47. package/seeds/samples/DSA-02.jpg +0 -0
  48. package/seeds/samples/DSA-03.jpg +0 -0
  49. package/seeds/styles/DSA.json +22 -0
  50. package/ui/workbench.css +5 -0
  51. package/ui/workbench.html +111 -7
@@ -0,0 +1,300 @@
1
+ /**
2
+ * 听众评审的「人话版」报告(F-186)。
3
+ *
4
+ * 背景:`review.experts.md` 是工程口径(覆盖矩阵 / 判定表 / 证据 / P0-P1),负责人和听众都读不进去。
5
+ * 这里**不产生新判断**,只把已有的盲审 + 对照结果翻译成两本人能读的文档:
6
+ *
7
+ * - `docs/review.readers.md`:听众视角——读完记住什么、连贯吗、结构清楚吗、哪里不信、还想知道什么;
8
+ * - `docs/review.gap.md`:负责人视角——意图要什么 vs 听众读到什么、差距、先改哪三页、哪几件要拍板。
9
+ *
10
+ * 纪律:读者文档里不出现 P0/P1、置信度 A/B/C、交叉确认这类内部词——这些留在 gap 文档的「附:内部口径」。
11
+ * 所有句子都由输入模板拼出来(不调模型),所以同一份评审结果每次渲染一致、可复算。
12
+ *
13
+ * @module dsh-plugin-deck-studio/review-readers
14
+ */
15
+
16
+ const s = (value) => String(value ?? "").trim();
17
+ /** 引号内再套句号会变成「…。」。」,渲染前先削掉末尾标点。 */
18
+ const trimTail = (value) => s(value).replace(/[。;;,,、\s]+$/u, "");
19
+ const firstSentence = (text, limit = 40) => {
20
+ const clean = s(text).replace(/\s+/g, " ");
21
+ if (!clean) return "";
22
+ const cut = clean.match(/^.*?[。!?!?;;]/);
23
+ const body = cut ? cut[0] : clean;
24
+ return body.length > limit ? `${body.slice(0, limit)}…` : body;
25
+ };
26
+
27
+ const severityRank = { 否: 0, 部分: 1, 是: 2 };
28
+
29
+ /** 分数可能是 number 或 {mean, n}(合并后的四维分)——渲染时统一取均值。 */
30
+ const meanOf = (value) => {
31
+ if (typeof value === "number") return Number.isInteger(value) ? value : Number(value.toFixed(2));
32
+ if (value && typeof value === "object" && typeof value.mean === "number") {
33
+ return Number.isInteger(value.mean) ? value.mean : Number(value.mean.toFixed(2));
34
+ }
35
+ return "—";
36
+ };
37
+
38
+ /** 两位听众常问同一件事:按前 20 字归一化后去重,避免「还想知道」念两遍。 */
39
+ const dedupeBySimilarity = (items = [], limit = 3) => {
40
+ const seen = [];
41
+ const out = [];
42
+ for (const raw of items) {
43
+ const text = s(raw);
44
+ if (!text) continue;
45
+ const key = text.replace(/[\s\u3000「」『』“”"',。;!?、—…]/g, "").slice(0, 20);
46
+ if (seen.some((item) => item === key || item.startsWith(key) || key.startsWith(item))) continue;
47
+ seen.push(key);
48
+ out.push(text);
49
+ if (out.length >= limit) break;
50
+ }
51
+ return out;
52
+ };
53
+
54
+ /** 整本一句话:听众记住了什么(用第一页的复述 + 全本读者自述)。 */
55
+ export function readerHeadline({ gaps = [], mergedBlind = [], deckGap = "" } = {}) {
56
+ const pages = new Map(mergedBlind.map((row) => [row.page, row.answers?.[0] ?? {}]));
57
+ const firstPage = [...pages.keys()].sort((a, b) => a.localeCompare(b))[0];
58
+ const remembered = firstSentence(pages.get(firstPage)?.restate ?? "", 60);
59
+ const failed = gaps.filter((gap) => gap.verdict !== "是");
60
+ const halfPages = failed.filter((gap) => gap.verdict === "部分").length;
61
+ const missed = failed.filter((gap) => gap.verdict === "否").length;
62
+ const worst = [...failed].sort((a, b) => (severityRank[a.verdict] ?? 3) - (severityRank[b.verdict] ?? 3))[0];
63
+ const whole = firstSentence(deckGap, 80);
64
+ const parts = [];
65
+ if (remembered) parts.push(`听众读完记住的是「${trimTail(remembered)}」`);
66
+ if (failed.length) {
67
+ const detail = [halfPages ? `${halfPages} 页只接住一半` : "", missed ? `${missed} 页没接住` : ""].filter(Boolean).join("、");
68
+ parts.push(`${gaps.length} 页里,${detail}${worst ? `,最要紧的是第 ${worst.page.replace(/^P0*/, "")} 页` : ""}`);
69
+ } else if (gaps.length) {
70
+ parts.push("每页都被接住了");
71
+ }
72
+ if (whole) parts.push(`听众自己的总结是:「${whole}」`);
73
+ return `${parts.join(";")}。`;
74
+ }
75
+
76
+ /** 听众读后感的五问五答(全部来自盲审 + 对照,不新增判断)。 */
77
+ export function readerImpression({ gaps = [], mergedBlind = [], deckGap = "" } = {}) {
78
+ const answers = mergedBlind.flatMap((row) => (row.answers ?? []).map((answer) => ({ ...answer, from: answer.from ?? "" })));
79
+ const pages = mergedBlind.map((row) => row.page);
80
+ const relations = new Map();
81
+ for (const row of mergedBlind) {
82
+ for (const answer of row.answers ?? []) {
83
+ if (!relations.has(row.page)) relations.set(row.page, answer.relation);
84
+ }
85
+ }
86
+ const relationChain = pages.map((page) => relations.get(page)).filter(Boolean);
87
+ const broken = [...relations.entries()].filter(([, relation]) => /无|没有|说不清|不知道|断/.test(s(relation))).map(([page]) => page);
88
+ const doubts = [...new Set(answers.map((answer) => firstSentence(answer.doubt, 60)).filter(Boolean))].slice(0, 3);
89
+ const knownPages = mergedBlind.filter((row) => (row.answers ?? []).some((answer) => /知道|清楚/.test(s(answer.next_action)))).length;
90
+ const conflicts = gaps.filter((gap) => gap.verdict === "否");
91
+ const halfPages = gaps.filter((gap) => gap.verdict === "部分");
92
+
93
+ const learned = firstSentence([...new Set(answers.map((answer) => firstSentence(answer.restate, 50)).filter(Boolean))].join(";"), 120)
94
+ || "(听众没有留下复述,可能这页太抽象或太密)";
95
+ const coherence = broken.length
96
+ ? `第 ${broken.join(" / ").replace(/P0*/g, "")} 页和上一页的接不上:听众说不出这页和前面是什么关系。`
97
+ : relationChain.length
98
+ ? `页与页之间接得上(承接:${relationChain.slice(0, 6).join(" → ")})${relationChain.length > 6 ? " …" : ""}。`
99
+ : "没有拿到承接信息。";
100
+ const structure = gaps.length
101
+ ? `整本 ${gaps.length} 页,${gaps.filter((gap) => gap.verdict === "是").length} 页完全接住、${halfPages.length} 页只接住一半、${conflicts.length} 页没接住;${knownPages ? `${knownPages} 页听众知道下一步做什么` : "多数页听众不知道下一步做什么"}。`
102
+ : "没有对照结果,无法判断结构。";
103
+ const contradiction = conflicts.length
104
+ ? `有 ${conflicts.length} 页听众没接住——读出来的重点和讲的人要的不一样(${conflicts.map((gap) => `第 ${gap.page.replace(/^P0*/, "")} 页`).join("、")})。这不是前后矛盾,是这几页没把话说到听众能接住。`
105
+ : "没有发现听众读出与意图对不上的地方。";
106
+ const promises = dedupeBySimilarity(doubts);
107
+ const wishes = promises.length ? promises.map((doubt) => `「${doubt}」`).join(";") : "听众没有留下明确的追问。";
108
+ return { learned, coherence, structure, contradiction, wishes };
109
+ }
110
+
111
+ /** 听众视角 markdown。 */
112
+ export function renderReaderReviewMarkdown({
113
+ deckTitle = "",
114
+ generatedAt = "",
115
+ audience = "",
116
+ gaps = [],
117
+ mergedBlind = [],
118
+ deckGap = "",
119
+ fixSuggestions = [],
120
+ pageTitles = {},
121
+ } = {}) {
122
+ const headline = readerHeadline({ gaps, mergedBlind, deckGap });
123
+ const impression = readerImpression({ gaps, mergedBlind, deckGap });
124
+ const gapByPage = new Map();
125
+ for (const gap of gaps) {
126
+ if (!gapByPage.has(gap.page) || (severityRank[gap.verdict] ?? 3) < (severityRank[gapByPage.get(gap.page).verdict] ?? 3)) gapByPage.set(gap.page, gap);
127
+ }
128
+ const pageBlocks = mergedBlind.map((row) => {
129
+ const answers = row.answers ?? [];
130
+ const primary = answers[0] ?? {};
131
+ const others = answers.slice(1);
132
+ const gap = gapByPage.get(row.page);
133
+ const verdictLine = gap
134
+ ? gap.verdict === "是"
135
+ ? "被说服了:是"
136
+ : `被说服了一半:听众读出「${firstSentence(gap.x, 70)}」,你想让他们带走的是「${firstSentence(gap.y, 70)}」。`
137
+ : "这一页没有对照结论。";
138
+ const title = pageTitles[row.page] || "";
139
+ return [
140
+ `## 第 ${row.page.replace(/^P0*/, "")} 页${title ? ` · ${title}` : ""}`,
141
+ "",
142
+ `- 读者读到:${firstSentence(primary.restate, 120) || "(没有复述)"}`,
143
+ `- 和上一页的关系:${firstSentence(primary.relation, 30) || "(没答)"}`,
144
+ `- 读者想问:${firstSentence(primary.doubt, 120) || "(没有追问)"}`,
145
+ `- 下一步动作:${s(primary.next_action) || "(没答)"}`,
146
+ `- ${verdictLine}`,
147
+ gap?.fix ? `- 建议改法:${gap.fix}` : "",
148
+ others.length ? `- 另一位听众:${firstSentence(others[0].doubt || others[0].restate, 100)}` : "",
149
+ "",
150
+ ].filter((line) => line !== "" || true).join("\n");
151
+ });
152
+ const fixes = fixSuggestions.slice(0, 3);
153
+ return [
154
+ "---",
155
+ `title: ${deckTitle || "deck"} · 听众读后感`,
156
+ "status: draft",
157
+ "---",
158
+ "",
159
+ `# 听众读后感 · ${deckTitle || "(未命名 deck)"}`,
160
+ "",
161
+ `> 生成时间:${generatedAt || "(未记录)"}${audience ? ` · 听众:${audience}` : ""}`,
162
+ "> 这份文档只记录「听众读到了什么」,不改稿;改法建议在每页末尾,需要你确认后才落版。",
163
+ "",
164
+ "## 一句话",
165
+ "",
166
+ headline,
167
+ "",
168
+ "## 读完的感觉",
169
+ "",
170
+ `- **学到了什么**:${impression.learned}`,
171
+ `- **连贯吗**:${impression.coherence}`,
172
+ `- **结构清楚吗**:${impression.structure}`,
173
+ `- **有没有互相矛盾的地方**:${impression.contradiction}`,
174
+ `- **还想知道什么**:${impression.wishes}`,
175
+ "",
176
+ "## 逐页:听众读到了什么",
177
+ "",
178
+ ...pageBlocks.map((block) => block.replace(/\n{3,}/g, "\n\n")),
179
+ "## 听众建议先改的三处",
180
+ "",
181
+ ...(fixes.length
182
+ ? fixes.map((item, index) => `${index + 1}. 第 ${String(item.page ?? "").replace(/^P0*/, "")} 页:${item.fix || item.problem || "(改法未给)"}`)
183
+ : ["(本次没有需要改的地方)"]),
184
+ "",
185
+ "---",
186
+ "",
187
+ "> 内部口径(严重度、交叉确认、四维分)在 [review.gap.md](review.gap.md) 的「附:内部口径」,不用给听众看。",
188
+ "",
189
+ ].join("\n");
190
+ }
191
+
192
+ /** 负责人视角 markdown:意图 vs 听众的差距 + 需要拍板的事。 */
193
+ export function renderGapReviewMarkdown({
194
+ deckTitle = "",
195
+ generatedAt = "",
196
+ scriptHash = "",
197
+ audience = "",
198
+ intentText = "",
199
+ gaps = [],
200
+ mergedBlind = [],
201
+ deckGap = "",
202
+ findings = [],
203
+ dropped = [],
204
+ scores = null,
205
+ titleChain = null,
206
+ fixSuggestions = [],
207
+ } = {}) {
208
+ const counts = { 是: 0, 部分: 0, 否: 0 };
209
+ for (const gap of gaps) counts[gap.verdict] = (counts[gap.verdict] ?? 0) + 1;
210
+ const headline = readerHeadline({ gaps, mergedBlind, deckGap });
211
+ const impression = readerImpression({ gaps, mergedBlind, deckGap });
212
+ const gapByPage = new Map();
213
+ for (const gap of gaps) {
214
+ if (!gapByPage.has(gap.page) || (severityRank[gap.verdict] ?? 3) < (severityRank[gapByPage.get(gap.page).verdict] ?? 3)) gapByPage.set(gap.page, gap);
215
+ }
216
+ const rows = mergedBlind.map((row) => {
217
+ const gap = gapByPage.get(row.page);
218
+ const read = gap?.x || row.answers?.[0]?.restate || "";
219
+ const wanted = gap?.y || "";
220
+ const missing = gap ? (gap.verdict === "是" ? "—" : gap.verdict === "否" ? "听众读出的和意图相反或对不上" : "只讲到一半,缺「为什么/怎么做/凭什么」中的一块") : "无对照";
221
+ return `| ${row.page} | ${firstSentence(wanted, 60) || "(对照组没给)"} | ${firstSentence(read, 60) || "(没有复述)"} | ${missing} | ${firstSentence(gap?.fix, 60) || "—"} | ${gap ? (gap.verdict === "否" ? "P0" : gap.verdict === "部分" ? "P1" : "—") : "—"} |`;
222
+ });
223
+ const top = [...gapByPage.values()]
224
+ .filter((gap) => gap.verdict !== "是")
225
+ .sort((a, b) => (severityRank[a.verdict] ?? 3) - (severityRank[b.verdict] ?? 3))
226
+ .slice(0, 3);
227
+ const decisions = dedupeBySimilarity(
228
+ mergedBlind.flatMap((row) => (row.answers ?? []).map((answer) => firstSentence(answer.doubt, 70))),
229
+ 3,
230
+ );
231
+ const scoreLine = scores?.average
232
+ ? `四维均分 ${meanOf(scores.average)}(表意清晰 ${meanOf(scores.dimensions?.["表意清晰"])} / 证据充分 ${meanOf(scores.dimensions?.["证据充分"])} / 结构连贯 ${meanOf(scores.dimensions?.["结构连贯"])} / 意图达成 ${meanOf(scores.dimensions?.["意图达成"])})`
233
+ : "(本轮没有评分)";
234
+ return [
235
+ "---",
236
+ `title: ${deckTitle || "deck"} · 意图与听众差距`,
237
+ "status: draft",
238
+ "---",
239
+ "",
240
+ `# 意图 vs 听众 · 差距报告 · ${deckTitle || "(未命名 deck)"}`,
241
+ "",
242
+ `> 生成时间:${generatedAt || "(未记录)"}${scriptHash ? ` · 稿件哈希:\`${scriptHash}\`` : ""}${audience ? ` · 目标听众:${audience}` : ""}`,
243
+ "> 给负责人看的一页纸:你要他们带走什么、他们实际带走了什么、差在哪、先改哪几页。",
244
+ "",
245
+ "## 一、一页总结",
246
+ "",
247
+ `- **意图达成**:是 ${counts.是} 页 / 部分 ${counts.部分} 页 / 否 ${counts.否} 页。`,
248
+ `- **听众读后感到的差距**:${headline}`,
249
+ impression.wishes ? `- **还想知道什么**:${impression.wishes}` : "",
250
+ `- **先改哪几页**:${top.length ? top.map((gap) => `第 ${gap.page.replace(/^P0*/, "")} 页(${gap.verdict === "否" ? "听众没接住" : "只接住一半"})`).join("、") : "本轮没有需要先改的页"}`,
251
+ intentText ? `- **你的意图**:${firstSentence(intentText, 100)}` : "",
252
+ "",
253
+ "## 二、逐页差距(意图要的 vs 听众读到的)",
254
+ "",
255
+ "| 页 | 意图要的 | 听众读到的 | 差距 | 建议改法 | 内部级别 |",
256
+ "|---|---|---|---|---|---|",
257
+ ...(rows.length ? rows : ["| — | (没有对照结果) | — | — | — | — |"]),
258
+ "",
259
+ "## 三、需要你拍板",
260
+ "",
261
+ ...(decisions.length ? decisions.map((item, index) => `${index + 1}. ${item}`) : ["(本轮听众没有提出需要业务决定的问题)"]),
262
+ "",
263
+ "## 四、附:内部口径(工程/下一轮看,不用给听众)",
264
+ "",
265
+ `- ${scoreLine}${scores?.compare?.line ? `;${scores.compare.line}` : ""}`,
266
+ `- 可采纳建议 ${findings.length} 条${findings.length ? `:${findings.map((item) => `${item.id}(${item.page},${item.severity},置信度 ${item.confidence}${item.crossReviewed ? ",交叉确认" : ""})`).join("、")}` : ""}`,
267
+ dropped.length ? `- 无法核实 ${dropped.length} 条(C 类,禁止据此改稿):${dropped.map((item) => `${item.id}(${item.page})`).join("、")}` : "- 无法核实(C 类):0 条",
268
+ titleChain ? `- 标题串读:${titleChain.verdict ?? "—"}${titleChain.reasons?.length ? `(${titleChain.reasons.slice(0, 2).join(";")})` : ""}` : "",
269
+ fixSuggestions.length ? `- 建议清单(按页):${fixSuggestions.slice(0, 6).map((item) => `${item.page}:${firstSentence(item.fix, 40)}`).join(";")}` : "",
270
+ "",
271
+ ]
272
+ .join("\n")
273
+ .replace(/\n{3,}/g, "\n\n")
274
+ .replace(/\n*$/, "\n");
275
+ }
276
+
277
+ /** 工具正文用的人话摘要(3 行以内,先讲人话,再给路径)。 */
278
+ export function plainReviewSummary({ deckTitle = "", gaps = [], mergedBlind = [], deckGap = "", readersDoc = "", gapDoc = "", missing = [] } = {}) {
279
+ const headline = readerHeadline({ gaps, mergedBlind, deckGap });
280
+ const gapByPage = new Map();
281
+ for (const gap of gaps) {
282
+ if (!gapByPage.has(gap.page) || (severityRank[gap.verdict] ?? 3) < (severityRank[gapByPage.get(gap.page).verdict] ?? 3)) gapByPage.set(gap.page, gap);
283
+ }
284
+ const top = [...gapByPage.values()]
285
+ .filter((gap) => gap.verdict !== "是")
286
+ .sort((a, b) => (severityRank[a.verdict] ?? 3) - (severityRank[b.verdict] ?? 3))
287
+ .slice(0, 3);
288
+ const lines = [`一句话:${headline}`];
289
+ if (!gaps.length) lines.push("没有有效的对照结果(审稿人全失败或没跑),不能据此判断稿件没有问题。");
290
+ if (top.length) {
291
+ lines.push(
292
+ `听众卡在哪(先改这几页):${top
293
+ .map((gap) => `第 ${gap.page.replace(/^P0*/, "")} 页——听众读出「${firstSentence(gap.x, 26) || "(空)"}」,你要的是「${firstSentence(gap.y, 26) || "(空)"}」`)
294
+ .join(";")}`,
295
+ );
296
+ }
297
+ if (readersDoc) lines.push(`完整记录:听众人话版 ${readersDoc};意图差距 + 拍板项 ${gapDoc}(内部口径在后者末尾)。`);
298
+ if (missing.length) lines.push(`没跑完的审稿人:${missing.join(" / ")}(结论不完整,要说明)`);
299
+ return lines.join("\n");
300
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 审稿四维评分 + 版本记录(智力升级 B3 / F-183)。
3
+ *
4
+ * 为什么要打分:审稿意见是「这页哪里不行」,分数是「这本 deck 整体在什么水平」。
5
+ * 只有分数落盘并带上 scriptHash / revision,两轮评审才能对照——否则「改完是不是更好了」
6
+ * 只能靠感觉,这正是审稿机制最容易空转的地方。
7
+ *
8
+ * 四个维度各 1–5 分(打分发生在**对照阶段之后**,评审已看过意图;盲审阶段仍然不打分,保持听众视角的干净):
9
+ * - 表意清晰:读者能否一句话复述这页想让他记住什么;
10
+ * - 证据充分:主张有没有支撑(含「示意数据」是否讲明);
11
+ * - 结构连贯:页与页的关系是否成立(承接 / 递进 / 转折);
12
+ * - 意图达成:整本有没有做到 intent 的「带走」。
13
+ *
14
+ * 本模块是纯函数(prompt / 解析 / 合并 / 对照);落盘走 `lib/review-score-store.js` 的薄封装。
15
+ */
16
+
17
+ export const SCORE_DIMENSIONS = ["表意清晰", "证据充分", "结构连贯", "意图达成"];
18
+
19
+ /** 打分 prompt:一次只出四维分 + 每维一句理由,不做新审稿(意见已由盲审 / 对照产出)。 */
20
+ export function scoreReviewerPrompt({ reviewer, brief = {}, deckTitle = "", script = "", maxScriptChars = 60_000 }) {
21
+ const dimensions = SCORE_DIMENSIONS.map((dimension, index) => `${index + 1}. ${dimension}:1–5 分(5=最好)`).join("\n");
22
+ const intentLines = [
23
+ brief.audience ? `听众:${brief.audience}` : "",
24
+ brief.takeaway ? `要带走:${brief.takeaway}` : "",
25
+ brief.success ? `成功标准:${brief.success}` : "",
26
+ ].filter(Boolean);
27
+ const scriptText = String(script ?? "");
28
+ const clipped = scriptText.length > maxScriptChars ? `${scriptText.slice(0, maxScriptChars)}\n…(后略)` : scriptText;
29
+ return [
30
+ `你是这份分享的听众评审:${reviewer.label}`,
31
+ reviewer.focus,
32
+ "",
33
+ deckTitle ? `## 分享标题\n${deckTitle}` : "",
34
+ intentLines.length ? `## 作者意图\n${intentLines.join("\n")}` : "",
35
+ "",
36
+ "## 稿件(打分用)",
37
+ clipped,
38
+ "",
39
+ "## 你的任务:只打分,不写新意见",
40
+ "刚才你已经审过这份稿子。现在只做一件事:给这本 deck 的四维度打分。",
41
+ dimensions,
42
+ "",
43
+ "打分口径:",
44
+ "- 3 分 = 及格:能读下去,但有一两处要读者自己补;",
45
+ "- 5 分 = 好:不需要作者解释,听众当场能复述 / 能被说服;",
46
+ "- 1 分 = 明显不合格:读完不知道在说什么,或证据站不住。",
47
+ "- 每维必须给一句理由(≤30 字),理由要指向具体页或具体句子,不要写「整体不错」。",
48
+ "",
49
+ "## 输出契约",
50
+ "只输出一个 JSON 对象,不要围栏外的解释:",
51
+ "{",
52
+ ' "reviewer": "你的身份(一句话)",',
53
+ ' "scores": {"表意清晰": 4, "证据充分": 3, "结构连贯": 4, "意图达成": 4},',
54
+ ' "notes": {"表意清晰": "P05 一句话能复述", "证据充分": "P03 的 30% 没来源"}',
55
+ "}",
56
+ "规则:分数只能是 1–5 的整数;不确定就给 3 分并在理由里写「不确定」。",
57
+ ]
58
+ .filter((line) => line !== "")
59
+ .join("\n");
60
+ }
61
+
62
+ function jsonCandidate(text) {
63
+ const raw = String(text ?? "").trim();
64
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
65
+ const body = fenced ? fenced[1] : raw;
66
+ const start = body.indexOf("{");
67
+ const end = body.lastIndexOf("}");
68
+ if (start < 0 || end <= start) return null;
69
+ try {
70
+ return JSON.parse(body.slice(start, end + 1));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 解析打分输出;坏输出不炸(返回 unparsed + 缺的维度)。
78
+ * @returns {{record:{reviewer:string,scores:Record<string,number>,notes:Record<string,string>}|null, unparsed:boolean, missing:string[]}}
79
+ */
80
+ export function parseScoreOutput(text) {
81
+ const parsed = jsonCandidate(text);
82
+ if (!parsed || typeof parsed !== "object") return { record: null, unparsed: true, missing: [...SCORE_DIMENSIONS] };
83
+ const reviewer = String(parsed.reviewer ?? "").trim();
84
+ const scores = {};
85
+ const notes = {};
86
+ const missing = [];
87
+ for (const dimension of SCORE_DIMENSIONS) {
88
+ const value = Number(parsed.scores?.[dimension]);
89
+ if (!Number.isFinite(value) || value < 1 || value > 5) {
90
+ missing.push(dimension);
91
+ continue;
92
+ }
93
+ scores[dimension] = Math.round(value);
94
+ const note = String(parsed.notes?.[dimension] ?? "").trim();
95
+ if (note) notes[dimension] = note.slice(0, 60);
96
+ }
97
+ if (missing.length) return { record: null, unparsed: false, missing, reviewer };
98
+ return { record: { reviewer: reviewer || "听众", scores, notes }, unparsed: false, missing: [] };
99
+ }
100
+
101
+ /**
102
+ * 合并多个评审的打分:每维取均值(保留一位小数),并记下人数 / 最低最高 / 理由。
103
+ * @param {{reviewer:string,scores:Record<string,number>,notes?:Record<string,string>}[]} records
104
+ */
105
+ export function mergeScoreRecords(records = []) {
106
+ const list = (Array.isArray(records) ? records : []).filter((record) => record && record.scores);
107
+ const dimensions = {};
108
+ for (const dimension of SCORE_DIMENSIONS) {
109
+ const values = list.map((record) => Number(record.scores[dimension])).filter((value) => Number.isFinite(value));
110
+ dimensions[dimension] = values.length
111
+ ? {
112
+ mean: Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1)),
113
+ min: Math.min(...values),
114
+ max: Math.max(...values),
115
+ count: values.length,
116
+ }
117
+ : { mean: null, min: null, max: null, count: 0 };
118
+ }
119
+ const means = SCORE_DIMENSIONS.map((dimension) => dimensions[dimension].mean).filter((value) => value != null);
120
+ return {
121
+ dimensions,
122
+ average: means.length ? Number((means.reduce((sum, value) => sum + value, 0) / means.length).toFixed(2)) : null,
123
+ reviewers: list.map((record) => ({ reviewer: record.reviewer, scores: record.scores, notes: record.notes ?? {} })),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * 与上一次评审对照:返回每维 delta(无上次则全 null)。
129
+ * @param {object|null} previous mergeScoreRecords 产物
130
+ * @param {object} current
131
+ */
132
+ export function compareScores(previous, current) {
133
+ const deltas = {};
134
+ for (const dimension of SCORE_DIMENSIONS) {
135
+ const before = previous?.dimensions?.[dimension]?.mean;
136
+ const after = current?.dimensions?.[dimension]?.mean;
137
+ deltas[dimension] = before != null && after != null ? Number((after - before).toFixed(1)) : null;
138
+ }
139
+ const changed = SCORE_DIMENSIONS.filter((dimension) => deltas[dimension] != null && deltas[dimension] !== 0);
140
+ return {
141
+ available: Boolean(previous),
142
+ deltas,
143
+ changed,
144
+ // 一句话结论:先说变好还是变差,再点名变化最大的维度。
145
+ line: !previous
146
+ ? ""
147
+ : changed.length === 0
148
+ ? "与上次评审相比:四维分数没有变化。"
149
+ : (() => {
150
+ const biggest = [...changed].sort((left, right) => Math.abs(deltas[right]) - Math.abs(deltas[left]))[0];
151
+ const direction = deltas[biggest] > 0 ? "升" : "降";
152
+ const improved = changed.filter((dimension) => deltas[dimension] > 0).length;
153
+ const worsened = changed.filter((dimension) => deltas[dimension] < 0).length;
154
+ return `与上次评审相比:${improved} 维升、${worsened} 维降;变化最大的是「${biggest}」${direction} ${Math.abs(deltas[biggest]).toFixed(1)} 分。`;
155
+ })(),
156
+ };
157
+ }
158
+
159
+ /** 历史记录里的一条(写入 JSON 的形状)。 */
160
+ export function scoreHistoryEntry({ at, scriptHash, revision, mean, compare }) {
161
+ return {
162
+ at,
163
+ scriptHash: scriptHash ?? "",
164
+ revision: revision ?? "",
165
+ scores: mean.dimensions,
166
+ average: mean.average,
167
+ reviewers: mean.reviewers,
168
+ compare: compare?.available ? { deltas: compare.deltas, line: compare.line } : null,
169
+ };
170
+ }
171
+
172
+ /** 历史记录 → markdown 表(`docs/review.scores.md`,方便前后对照与交接)。 */
173
+ export function renderScoreHistoryMarkdown(entries = [], { deckTitle = "" } = {}) {
174
+ const list = Array.isArray(entries) ? entries : [];
175
+ const header = `| 时间 | revision | ${SCORE_DIMENSIONS.join(" | ")} | 均分 |`;
176
+ const divider = `|---|---|${SCORE_DIMENSIONS.map(() => "---").join("|")}|---|`;
177
+ const rows = list.map((entry) => {
178
+ const cells = SCORE_DIMENSIONS.map((dimension) => entry.scores?.[dimension]?.mean ?? "—");
179
+ const at = String(entry.at ?? "").replace("T", " ").slice(0, 16);
180
+ return `| ${at} | ${entry.revision || "—"} | ${cells.join(" | ")} | ${entry.average ?? "—"} |`;
181
+ });
182
+ const lines = [`# 审稿评分记录 · ${deckTitle || "(未命名 deck)"}`, "", header, divider, ...rows, ""];
183
+ const last = list[list.length - 1];
184
+ if (last?.compare?.line) lines.push(last.compare.line, "");
185
+ lines.push("打分口径:1–5;3 = 及格,5 = 听众不需要作者解释就能复述 / 被说服。分数由每轮评审的听众独立打,取均值。", "");
186
+ return lines.join("\n");
187
+ }
188
+
189
+ /** 给人看的一段摘要(工具输出 / review 文档都能用)。 */
190
+ export function renderScoreSummary({ mean, compare }) {
191
+ const parts = SCORE_DIMENSIONS.map((dimension) => {
192
+ const item = mean?.dimensions?.[dimension];
193
+ return item?.mean == null ? `${dimension} —` : `${dimension} ${item.mean}`;
194
+ });
195
+ const lines = [`四维评分:${parts.join(" · ")}${mean?.average != null ? `(均分 ${mean.average})` : ""}`];
196
+ if (compare?.line) lines.push(compare.line);
197
+ return lines.join("\n");
198
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 标题串读测试(智力升级 A3 / F-179):只看一串页标题,能不能推出这份 deck 的故事线。
3
+ *
4
+ * 为什么是确定性脚本:这是「结构问题」,不该花模型调用、也不该由模型嘴上说「故事线清晰」。
5
+ * 判据只查机器能查的东西:
6
+ * - 有没有占位标题(待定 / 待补 / 暂无 / TBD)——占位意味着这一页还没有观点;
7
+ * - 有没有重复标题——重复意味着两页在讲同一件事,或者一页是凑数;
8
+ * - 相邻标题有没有共同词(2 字词)——全断链说明每页各说各的,读者串不起来;
9
+ * - 有没有收束页(结论 / 小结 / 下一步 / 记住 / 带走 / 回扣)——没有收束,标题串读不出「然后呢」。
10
+ * 「标题好不好」不在这里判(那是审稿人和人的事);这里只判「能不能串起来」。
11
+ */
12
+
13
+ const PLACEHOLDER = /待定|待补|暂无|待填|TBD|TBA|XXX|xxx/;
14
+ const CLOSE_MARKERS = /小结|总结|结论|收束|下一步|记住|带走|回扣|最后|所以|落地|行动|开始|三件事|清单/;
15
+ const STOP_BIGRAMS = new Set(["怎么", "什么", "为什么", "如何", "我们", "他们", "一个", "这个", "那个", "可以", "不是", "就是"]);
16
+
17
+ /** 标题的 2 字词集合(去掉常见虚词组合)。中文没有分词器,2-gram 足够做「有没有共同词」的判断。 */
18
+ export function bigramsOf(title = "") {
19
+ const text = String(title ?? "").replace(/[\s\u3000]+/g, "").replace(/[,。、;:!?,.!?::·—–\-()()「」『』“”"']/g, "");
20
+ const grams = new Set();
21
+ for (let index = 0; index + 1 < text.length; index += 1) {
22
+ const gram = text.slice(index, index + 2);
23
+ if (!STOP_BIGRAMS.has(gram) && !/^\d+$/.test(gram)) grams.add(gram);
24
+ }
25
+ return grams;
26
+ }
27
+
28
+ function sharesBigram(left, right) {
29
+ for (const gram of left) if (right.has(gram)) return true;
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * 读一遍标题串,输出逐页标记 + 能不能推出来的结论。
35
+ * @param {{page:number,title:string,claim?:string}[]} pages
36
+ */
37
+ export function analyzeTitleChain(pages = []) {
38
+ const rows = (Array.isArray(pages) ? pages : [])
39
+ .map((item, index) => ({
40
+ page: Number(item?.page ?? index + 1),
41
+ title: String(item?.title ?? "").trim(),
42
+ claim: String(item?.claim ?? "").trim(),
43
+ }))
44
+ .filter((item) => item.title || item.claim)
45
+ .sort((left, right) => left.page - right.page);
46
+
47
+ const seen = new Map();
48
+ const chain = rows.map((row, index) => {
49
+ const flags = [];
50
+ if (!row.title) flags.push("无标题");
51
+ else if (PLACEHOLDER.test(row.title)) flags.push("占位标题");
52
+ const key = row.title.replace(/\s+/g, "");
53
+ if (key && seen.has(key)) flags.push(`与 P${String(seen.get(key)).padStart(2, "0")} 重复`);
54
+ else if (key) seen.set(key, row.page);
55
+ const grams = bigramsOf(row.title);
56
+ const prev = index > 0 ? bigramsOf(rows[index - 1].title) : null;
57
+ const next = index + 1 < rows.length ? bigramsOf(rows[index + 1].title) : null;
58
+ const linkedPrev = !prev || sharesBigram(grams, prev);
59
+ const linkedNext = !next || sharesBigram(grams, next);
60
+ if (index > 0 && !linkedPrev) flags.push("与上一页无共同词");
61
+ // 单看相邻两页会误杀正常转折(「故事线怎么定」→「逐页脚本怎么改」)。真正判「串不起来」看
62
+ // 整体连通性:这一页和前后任意一页有共同词,就算它挂在链上。
63
+ const attached = linkedPrev || linkedNext;
64
+ return {
65
+ ...row,
66
+ flags,
67
+ role: index === 0 ? "开场" : CLOSE_MARKERS.test(row.title) ? "收束" : "推进",
68
+ linked: attached,
69
+ };
70
+ });
71
+
72
+ const placeholderCount = chain.filter((row) => row.flags.includes("占位标题") || row.flags.includes("无标题")).length;
73
+ const duplicateCount = chain.filter((row) => row.flags.some((flag) => flag.includes("重复"))).length;
74
+ const brokenLinks = chain.filter((row) => row.flags.includes("与上一页无共同词")).length;
75
+ const linkRate = chain.length > 1 ? Number((1 - brokenLinks / (chain.length - 1)).toFixed(2)) : 1;
76
+ // 连通性:至少挂在一个邻居上的页比例(比「每一对相邻页都要同词」宽,能容忍一次正常转折)。
77
+ const detached = chain.filter((row) => !row.linked).length;
78
+ const connectivity = chain.length ? Number((1 - detached / chain.length).toFixed(2)) : 1;
79
+ const hasClose = chain.length > 1 && (chain[chain.length - 1].role === "收束" || chain.some((row) => CLOSE_MARKERS.test(row.title)));
80
+
81
+ const reasons = [];
82
+ if (!chain.length) reasons.push("没有可读的页标题");
83
+ if (placeholderCount) reasons.push(`${placeholderCount} 页还是占位标题(待定 / 待补)——这些页没有观点,标题串读不出内容`);
84
+ if (duplicateCount) reasons.push(`${duplicateCount} 页标题与别的页重复——读者会以为是同一件事讲两遍`);
85
+ if (connectivity < 0.7) reasons.push(`有 ${detached} 页和前后页都没有共同词——标题串中间断了,读者接不上`);
86
+ if (chain.length > 3 && !hasClose) reasons.push("没有收束页(结论 / 小结 / 下一步)——读完标题不知道「所以呢」");
87
+ if (chain.length > 0 && chain.length < 4) reasons.push(`只有 ${chain.length} 页,标题串太短,推不出完整论证`);
88
+
89
+ const verdict = reasons.length ? "推不出" : "推得出";
90
+ return {
91
+ chain,
92
+ stats: { pages: chain.length, placeholderCount, duplicateCount, brokenLinks, linkRate, connectivity, hasClose },
93
+ verdict,
94
+ reasons,
95
+ // 给 Agent 的下一步:按最靠前的硬问题先修,不要一次全改。
96
+ next: placeholderCount
97
+ ? "先把占位页的标题写成「这页的主张」(不是栏目名),再回头看链"
98
+ : duplicateCount
99
+ ? "把重复的那页并掉,或改成它真正多出来的那一层"
100
+ : connectivity < 0.7
101
+ ? "让相邻标题共享一个关键词,或加一句承接(「上一页说了 X,这页说 Y」)"
102
+ : !hasClose
103
+ ? "补一页收束:结论 / 下一步 / 要记住的一句话"
104
+ : "",
105
+ };
106
+ }
107
+
108
+ /** 渲染成给人看的标题链报告。 */
109
+ export function renderTitleChain({ deckTitle = "", analysis }) {
110
+ const lines = [`# 标题串读 · ${deckTitle || "(未命名 deck)"}`, ""];
111
+ lines.push(`结论:**${analysis.verdict}**(${analysis.stats.pages} 页 · 占位 ${analysis.stats.placeholderCount} · 重复 ${analysis.stats.duplicateCount} · 断点 ${analysis.stats.brokenLinks} · 连通 ${Math.round((analysis.stats.connectivity ?? 1) * 100)}%)`);
112
+ if (analysis.reasons.length) {
113
+ lines.push("", "为什么:");
114
+ for (const reason of analysis.reasons) lines.push(`- ${reason}`);
115
+ }
116
+ lines.push("", "| 页 | 标题 | 角色 | 标记 |", "|---|---|---|---|");
117
+ for (const row of analysis.chain) {
118
+ lines.push(`| P${String(row.page).padStart(2, "0")} | ${row.title || "(空)"} | ${row.role} | ${row.flags.join(";") || "—"} |`);
119
+ }
120
+ if (analysis.next) lines.push("", `下一步:${analysis.next}`);
121
+ return `${lines.join("\n")}\n`;
122
+ }