dsh-plugin-deck-studio 0.2.6 → 0.2.9

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 (53) hide show
  1. package/cordis.example.yml +85 -7
  2. package/cordis.patch.yml +78 -7
  3. package/index.js +367 -27
  4. package/lib/capabilities.js +234 -0
  5. package/lib/citation-check.js +93 -0
  6. package/lib/config.js +27 -0
  7. package/lib/deck-brief.js +35 -1
  8. package/lib/deck-docs.js +45 -7
  9. package/lib/deck-templates.js +8 -1
  10. package/lib/deck-workspace.js +27 -1
  11. package/lib/designs.js +15 -0
  12. package/lib/fact-labels.js +81 -0
  13. package/lib/first-draft.js +119 -0
  14. package/lib/image-review.js +523 -0
  15. package/lib/page-contract.js +112 -0
  16. package/lib/proposal-decision.js +249 -75
  17. package/lib/render.js +101 -10
  18. package/lib/review-agent.js +1 -1
  19. package/lib/review-personas.js +49 -2
  20. package/lib/review-protocol.js +411 -0
  21. package/lib/review-readers.js +300 -0
  22. package/lib/review-score.js +198 -0
  23. package/lib/title-chain.js +122 -0
  24. package/lib/tools.js +973 -78
  25. package/lib/update-check.js +166 -0
  26. package/package.json +12 -11
  27. package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
  28. package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
  29. package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
  30. package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
  31. package/seeds/docs/assets/screenshots/f188-01-header-buttons.png +0 -0
  32. package/seeds/docs/assets/screenshots/f188-02-update-toast.png +0 -0
  33. package/seeds/docs/assets/screenshots/f188-03-review-doc-tabs.png +0 -0
  34. package/seeds/docs/assets/screenshots/f188-04-script-doc-nochips.png +0 -0
  35. package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
  36. package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
  37. package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
  38. package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
  39. package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
  40. package/seeds/docs/install.en.md +13 -3
  41. package/seeds/docs/install.md +13 -4
  42. package/seeds/docs/manual.en.md +17 -0
  43. package/seeds/docs/manual.md +17 -0
  44. package/seeds/docs/sample-deck.en.md +1 -1
  45. package/seeds/docs/sample-deck.md +1 -1
  46. package/seeds/docs/styles.en.md +2 -1
  47. package/seeds/docs/styles.md +14 -4
  48. package/seeds/samples/DSA-01.jpg +0 -0
  49. package/seeds/samples/DSA-02.jpg +0 -0
  50. package/seeds/samples/DSA-03.jpg +0 -0
  51. package/seeds/styles/DSA.json +22 -0
  52. package/ui/workbench.css +25 -3
  53. package/ui/workbench.html +286 -10
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 事实标注(智力升级 A2 / F-182):正文里的数字,要么挂「事实 ID」(调研台账里的条目),
3
+ * 要么显式标「示意数据」——不能既没出处、又没说「这是我编的数」。
4
+ *
5
+ * 判据是确定性的(不花模型、不阻断出图):
6
+ * 1. 从这页的 body + claim 里抽数字(排除年份、页号这类不承载事实的编号);
7
+ * 2. 看这页的「证据」字段(I-06 契约里的第 5 项)写了什么:
8
+ * - 事实 ID(`F-01` / `#3` / 「调研台账 C02」/「访谈 12 人」…)→ 有来源;
9
+ * - 「示意数据 / 示意」→ 明确标了是示意;
10
+ * - 「无需」或空 → 有数字却没来源 → 提示(不阻断)。
11
+ * 3. 提示只指出「哪页哪个数没来源」,不替用户改稿。
12
+ */
13
+
14
+ // 数字 + 可选单位:42 / 3–5 分钟 / 30% / 1.5 倍 / 1200 万。
15
+ const NUMBER_RE = /(\d+(?:\.\d+)?(?:\s*[–\-~—至]\s*\d+(?:\.\d+)?)?)\s*(%|%|万|亿|倍|分钟|小时|天|个月|月|年|人|页|个|条|次|元|块|秒)?/g;
16
+ // 不承载事实的编号:年份、日期、页号、版本号、编号 ID 里的数字由字段自己管。
17
+ const IGNORE_IF_YEAR = /^(19|20)\d{2}$/;
18
+ const CITATION_RE = /(事实|台账|调研|访谈|问卷|报告|数据|统计|来源|出处|F-?\d+|#\s?\d+|C\d{2}|P\d{2})/i;
19
+ const SCHEMATIC_RE = /示意/;
20
+
21
+ /** 抽正文里的数字(去重、保序),返回 [{raw, unit, context}]。 */
22
+ export function extractFacts(text = "") {
23
+ const source = String(text ?? "");
24
+ const found = [];
25
+ const seen = new Set();
26
+ for (const match of source.matchAll(NUMBER_RE)) {
27
+ const raw = match[1].replace(/\s+/g, "");
28
+ const unit = match[2] ?? "";
29
+ if (IGNORE_IF_YEAR.test(raw)) continue;
30
+ // 编号不是事实:v001 / F-03 / C02 / P05 / 2026-10-25 里的数字不算「正文数字」。
31
+ const before = source.slice(Math.max(0, (match.index ?? 0) - 1), match.index ?? 0);
32
+ if (/[A-Za-z0-9_#\-.]/.test(before)) continue;
33
+ const key = `${raw}${unit}`;
34
+ if (seen.has(key)) continue;
35
+ seen.add(key);
36
+ const index = match.index ?? 0;
37
+ found.push({ raw, unit, context: source.slice(Math.max(0, index - 12), index + raw.length + unit.length + 12).replace(/\s+/g, " ").trim() });
38
+ }
39
+ return found;
40
+ }
41
+
42
+ /**
43
+ * 单页事实标注体检。
44
+ * @param {{page:number, body?:string, claim?:string, evidence?:string}} input
45
+ * @returns {{page:number, numbers:string[], status:"no-numbers"|"cited"|"schematic"|"unannotated", hint:string}}
46
+ */
47
+ export function checkFactLabels({ page = 0, body = "", claim = "", evidence = "" } = {}) {
48
+ const facts = extractFacts([claim, body].filter(Boolean).join("\n"));
49
+ const numbers = facts.map((fact) => `${fact.raw}${fact.unit}`);
50
+ if (!numbers.length) return { page, numbers, status: "no-numbers", hint: "" };
51
+ const evidenceText = String(evidence ?? "").trim();
52
+ if (SCHEMATIC_RE.test(evidenceText)) return { page, numbers, status: "schematic", hint: "" };
53
+ if (evidenceText && CITATION_RE.test(evidenceText)) return { page, numbers, status: "cited", hint: "" };
54
+ const shown = numbers.slice(0, 4).join(" / ") + (numbers.length > 4 ? " …" : "");
55
+ const hint = evidenceText
56
+ ? `P${String(page).padStart(2, "0")} 正文有数字(${shown}),但「证据」写的是「${evidenceText.slice(0, 20)}」——数字要么挂事实 ID(调研台账里的条目),要么写「示意数据」。`
57
+ : `P${String(page).padStart(2, "0")} 正文有数字(${shown})却没写来源——在「证据」字段写事实 ID(调研台账里的条目),或写「示意数据」标明这是示意。`;
58
+ return { page, numbers, status: "unannotated", hint };
59
+ }
60
+
61
+ /**
62
+ * 整本事实标注汇总。
63
+ * @param {{page:number, body?:string, claim?:string, evidence?:string}[]} pages
64
+ */
65
+ export function analyzeFactLabels(pages = []) {
66
+ const perPage = (Array.isArray(pages) ? pages : []).map((page) => checkFactLabels(page)).sort((left, right) => left.page - right.page);
67
+ const unannotated = perPage.filter((item) => item.status === "unannotated");
68
+ return {
69
+ perPage,
70
+ unannotated,
71
+ withNumbers: perPage.filter((item) => item.numbers.length > 0).length,
72
+ };
73
+ }
74
+
75
+ /** 一行摘要(没有问题时返回空串)。 */
76
+ export function factSummaryLine(analysis) {
77
+ if (!analysis?.unannotated?.length) return "";
78
+ const pages = analysis.unannotated.slice(0, 5).map((item) => `P${String(item.page).padStart(2, "0")}(${item.numbers.slice(0, 2).join(" / ")})`).join(";");
79
+ const more = analysis.unannotated.length > 5 ? ` …等 ${analysis.unannotated.length} 页` : "";
80
+ return `事实标注:${analysis.unannotated.length} 页正文有数字但没挂来源:${pages}${more}——在「证据」字段写事实 ID(见调研台账)或「示意数据」;不阻断出图,但别让用户以为数字有出处。`;
81
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 首稿自动落版(F-173 / 智力升级 I-17)。
3
+ *
4
+ * 为什么单独一层:首稿是唯一「没有人工把关就进库」的动作,所以判定、元数据、回滚
5
+ * 必须由程序保证,而不是靠 Agent 记得「这次是首稿」。三道硬约束:
6
+ *
7
+ * 1. 判定:`script/revisions/` 里没有任何 vNNN.md 才允许自动落版;有历史版本一律拒绝。
8
+ * 2. 留痕:每条自动落版 revision 旁写 `<vNNN>.autoland.json`(自改轮次 / 依据的评审 / 摘要),
9
+ * 并把落版前的稿子存成 `<vNNN>.before.md`——回滚要能精确还原到落版前,不是「重来一次」。
10
+ * 3. 可回滚:只有「最新 revision 且是自动落版」才允许回滚;回滚本身也追加新 revision,
11
+ * 旧版本照常保留(copy-on-write 不放宽)。
12
+ *
13
+ * 本模块无 IO 之外的依赖,工具 / 路由 / 文档读取共用同一套判定,避免三处各写一遍。
14
+ *
15
+ * @module dsh-plugin-deck-studio/first-draft
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ export const AUTOLAND_SUFFIX = ".autoland.json";
22
+ export const BEFORE_SUFFIX = ".before.md";
23
+
24
+ /** script revision 目录。 */
25
+ export function revisionsDir(workspace, deckId) {
26
+ return join(workspace.deckDir(deckId), "script", "revisions");
27
+ }
28
+
29
+ /** 已落版 revision 数(vNNN.md;note/sidecar 不算)。 */
30
+ export function revisionCount(workspace, deckId) {
31
+ const dir = revisionsDir(workspace, deckId);
32
+ if (!existsSync(dir)) return 0;
33
+ return workspace.listScriptRevisions(deckId).length;
34
+ }
35
+
36
+ /** 首稿判定:一份稿子都还没落过。 */
37
+ export function isFirstDraft(workspace, deckId) {
38
+ return revisionCount(workspace, deckId) === 0;
39
+ }
40
+
41
+ /** 读某条 revision 的自动落版记录;没有返回 null。 */
42
+ export function autolandOf(workspace, deckId, revision) {
43
+ const file = join(revisionsDir(workspace, deckId), `${revision}${AUTOLAND_SUFFIX}`);
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ return JSON.parse(readFileSync(file, "utf8"));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 最新一条 revision 的自动落版记录(用于「能不能回滚」判定)。
54
+ * @returns {{revision:string, record:object}|null}
55
+ */
56
+ export function latestAutoland(workspace, deckId) {
57
+ const revisions = workspace.listScriptRevisions(deckId); // 新的在前
58
+ if (!revisions.length) return null;
59
+ const revision = revisions[0].revision;
60
+ const record = autolandOf(workspace, deckId, revision);
61
+ if (!record || record.rolledBack) return null;
62
+ return { revision, record };
63
+ }
64
+
65
+ /** 写自动落版记录 + 落版前稿子(回滚用)。 */
66
+ export function writeAutoland(workspace, deckId, { revision, proposal, selfReview, draftRound, beforeText }) {
67
+ const dir = revisionsDir(workspace, deckId);
68
+ const beforeFile = `${revision}${BEFORE_SUFFIX}`;
69
+ writeFileSync(join(dir, beforeFile), String(beforeText ?? ""), "utf8");
70
+ const record = {
71
+ autoland: true,
72
+ at: new Date().toISOString(),
73
+ deckId,
74
+ revision,
75
+ proposalId: proposal?.proposalId ?? null,
76
+ draftRound: Number.isFinite(Number(draftRound)) ? Number(draftRound) : 1,
77
+ selfReview: {
78
+ rounds: Number.isFinite(Number(selfReview?.rounds)) ? Number(selfReview.rounds) : 0,
79
+ reviewId: String(selfReview?.reviewId ?? ""),
80
+ summary: String(selfReview?.summary ?? ""),
81
+ },
82
+ before: { revision: null, file: beforeFile, chars: String(beforeText ?? "").length },
83
+ rolledBack: null,
84
+ };
85
+ writeFileSync(join(dir, `${revision}${AUTOLAND_SUFFIX}`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
86
+ return record;
87
+ }
88
+
89
+ /**
90
+ * 回滚首稿:把 `<vNNN>.before.md` 的内容作为**新 revision** 写回,并标记原记录已回滚。
91
+ * 只在「最新 revision 是自动落版且未回滚」时可用。
92
+ * @returns {{ok:true, revision:string, restoredFrom:string, beforeRevision:string}}
93
+ */
94
+ export function rollbackFirstDraft({ workspace, deckId, note = "" }) {
95
+ const latest = latestAutoland(workspace, deckId);
96
+ if (!latest) {
97
+ throw new Error("没有可回滚的首稿:只支持回滚「最新一条 revision 是自动落版」的情况;正式修订请用提案流程。");
98
+ }
99
+ const dir = revisionsDir(workspace, deckId);
100
+ const beforeFile = join(dir, latest.record.before?.file ?? `${latest.revision}${BEFORE_SUFFIX}`);
101
+ if (!existsSync(beforeFile)) throw new Error(`首稿回滚缺少落版前快照:${beforeFile}`);
102
+ const text = readFileSync(beforeFile, "utf8");
103
+ const saved = workspace.saveScriptRevision(
104
+ deckId,
105
+ text,
106
+ `rollback autoland ${latest.revision} → 落版前内容(${note || "用户要求回滚首稿"})`,
107
+ );
108
+ latest.record.rolledBack = { at: new Date().toISOString(), revision: saved.revision, note: String(note ?? "") };
109
+ writeFileSync(join(dir, `${latest.revision}${AUTOLAND_SUFFIX}`), `${JSON.stringify(latest.record, null, 2)}\n`, "utf8");
110
+ return { ok: true, revision: saved.revision, restoredFrom: latest.record.before?.file ?? beforeFile, beforeRevision: latest.revision };
111
+ }
112
+
113
+ /** 给 workbench / 修改日志用的一句话徽标文案。 */
114
+ export function autolandBadge(record) {
115
+ if (!record || !record.autoland) return "";
116
+ if (record.rolledBack) return "首稿已回滚";
117
+ const rounds = Number(record.selfReview?.rounds ?? 0);
118
+ return `首稿(自动落版${rounds > 0 ? `,自改 ${rounds} 轮` : ""},未经确认)`;
119
+ }