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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G3 引用核查(智力升级 B6 / F-177):一次独立小调用,角色是**校对员不是评审员**。
|
|
3
|
+
*
|
|
4
|
+
* 评审给的意见带 evidence,评审员可能「引对文、推错论」(F4)或「改写当引用」(F2)。
|
|
5
|
+
* G1(程序逐字匹配,见 buildFindings)只能杀 F1/F3;这里是第二道:
|
|
6
|
+
* 让另一个没有上下文的小模型只回答两件事——这段是否逐字存在?它是否支撑这条意见?
|
|
7
|
+
*
|
|
8
|
+
* 本模块无 IO:只拼 prompt、解析结果。调用方在 tools.js 里用 review agent 跑一次。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const CITATION_VERDICTS = new Set(["verbatim", "paraphrase", "unsupported"]);
|
|
12
|
+
|
|
13
|
+
/** 引用核查的 prompt:只做校对,不给意图、不让它评稿。 */
|
|
14
|
+
export function citationCheckPrompt({ items = [], script = "", deckTitle = "" }) {
|
|
15
|
+
const list = items
|
|
16
|
+
.map((item) => `- ${item.id}|${item.page}|证据:${JSON.stringify(item.evidence)}|意见:${item.problem || item.fix || "(未写)"}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
return `你是校对员,不是评审员。任务是核对下面的「意见」引用的稿件原文是否真实、是否支撑这条意见。
|
|
19
|
+
|
|
20
|
+
规则:
|
|
21
|
+
1. 只看给你的稿件原文和意见,不要评价稿件好坏,不要提新意见,不要改写。
|
|
22
|
+
2. 每条意见给两个判断:
|
|
23
|
+
- quote:证据是否逐字存在于稿件原文(忽略空白 / 标点差异)→ "verbatim" | "paraphrase" | "unsupported"
|
|
24
|
+
· verbatim:稿件里有这段原文
|
|
25
|
+
· paraphrase:原文在,但这条是改写/概括,不是逐字引用
|
|
26
|
+
· unsupported:稿件里找不到这段,也找不到对应原句
|
|
27
|
+
- supports:这条证据是否足以支撑这条意见的结论 → true | false
|
|
28
|
+
3. 原文里没有的东西一律判 unsupported,不要凭常识补。
|
|
29
|
+
|
|
30
|
+
只输出 JSON(不要 markdown 代码块):
|
|
31
|
+
{"checks":[{"id":"R01","quote":"verbatim|paraphrase|unsupported","supports":true|false,"note":"≤40 字"}]}
|
|
32
|
+
|
|
33
|
+
deck:${deckTitle || "(未命名)"}
|
|
34
|
+
稿 件 原 文:
|
|
35
|
+
"""
|
|
36
|
+
${script}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
待核对意见:
|
|
40
|
+
${list}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 解析引用核查输出;坏行丢掉,不抛。 */
|
|
44
|
+
export function parseCitationCheck(text) {
|
|
45
|
+
const raw = String(text ?? "");
|
|
46
|
+
const start = raw.indexOf("{");
|
|
47
|
+
const end = raw.lastIndexOf("}");
|
|
48
|
+
if (start < 0 || end <= start) return { checks: [], unparsed: true };
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw.slice(start, end + 1));
|
|
52
|
+
} catch {
|
|
53
|
+
return { checks: [], unparsed: true };
|
|
54
|
+
}
|
|
55
|
+
const rows = Array.isArray(parsed.checks) ? parsed.checks : [];
|
|
56
|
+
const checks = [];
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
if (!row || typeof row !== "object") continue;
|
|
59
|
+
const id = String(row.id ?? "").trim();
|
|
60
|
+
if (!/^R\d+$/.test(id)) continue;
|
|
61
|
+
const quote = String(row.quote ?? "").trim();
|
|
62
|
+
checks.push({
|
|
63
|
+
id,
|
|
64
|
+
quote: CITATION_VERDICTS.has(quote) ? quote : "unsupported",
|
|
65
|
+
supports: row.supports === true,
|
|
66
|
+
note: String(row.note ?? "").trim().slice(0, 80),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return { checks, unparsed: checks.length === 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把 G3 结论并回可采纳清单(B6 置信度 A–D 的完整口径):
|
|
74
|
+
* - verbatim + supports → A(可勾选,证据可定位)
|
|
75
|
+
* - paraphrase 或 supports=false(但原文在)→ B(可勾选,标「转述 / 支撑偏弱」)
|
|
76
|
+
* - unsupported → D(原文在但推不出结论 / 支撑不足:仍列出,只作参考,默认不勾)
|
|
77
|
+
* 没拿到结论的条目保持原置信度(G1 已过的仍是 A),不因核查失败降级——但要在返回里标 `citationChecked:false`。
|
|
78
|
+
*/
|
|
79
|
+
export function applyCitationChecks(findings = [], checks = []) {
|
|
80
|
+
const byId = new Map(checks.map((check) => [check.id, check]));
|
|
81
|
+
let checked = 0;
|
|
82
|
+
const next = findings.map((finding) => {
|
|
83
|
+
const check = byId.get(finding.id);
|
|
84
|
+
if (!check) return { ...finding };
|
|
85
|
+
checked += 1;
|
|
86
|
+
if (check.quote === "unsupported" || check.supports === false) {
|
|
87
|
+
return { ...finding, confidence: check.quote === "unsupported" ? "D" : "B", citation: check };
|
|
88
|
+
}
|
|
89
|
+
if (check.quote === "paraphrase") return { ...finding, confidence: "B", citation: check };
|
|
90
|
+
return { ...finding, confidence: "A", citation: check };
|
|
91
|
+
});
|
|
92
|
+
return { findings: next, checked, citationChecked: checked > 0 };
|
|
93
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -55,6 +55,19 @@ export const DEFAULT_RENDER = {
|
|
|
55
55
|
default: "",
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 图片评审(F-178 / 智力升级 C 层)用的视觉模型通道:OpenAI 兼容 chat/completions。
|
|
60
|
+
* 默认全空——**仓库不携带节点地址 / 模型名 / key**;用 profile 的 deckStudio.imageReview 或 env 配。
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_IMAGE_REVIEW = {
|
|
63
|
+
baseURL: "",
|
|
64
|
+
model: "",
|
|
65
|
+
apiKeyEnv: "WOCHENG_API_KEY",
|
|
66
|
+
apiKeyFile: "",
|
|
67
|
+
timeoutMs: 180_000,
|
|
68
|
+
maxTokens: 3000,
|
|
69
|
+
};
|
|
70
|
+
|
|
58
71
|
/** 默认视觉 preset(写进每页 prompt 头部;可在 profile 里用 styleBrief 覆盖)。 */
|
|
59
72
|
export const DEFAULT_STYLE_BRIEF =
|
|
60
73
|
"品牌信息图风格:深青 #06363B 主色、米白纸张底、暖金点缀,纸张纹理背景;" +
|
|
@@ -124,6 +137,18 @@ export function mergeConfig(raw, options = {}) {
|
|
|
124
137
|
timeoutMs: positive(renderInput.timeoutMs, DEFAULT_RENDER.timeoutMs),
|
|
125
138
|
};
|
|
126
139
|
|
|
140
|
+
const reviewInput = input.imageReview && typeof input.imageReview === "object" ? input.imageReview : {};
|
|
141
|
+
const imageReview = {
|
|
142
|
+
...DEFAULT_IMAGE_REVIEW,
|
|
143
|
+
...reviewInput,
|
|
144
|
+
baseURL: reviewInput.baseURL || env.DSH_DECK_VISION_BASE_URL || DEFAULT_IMAGE_REVIEW.baseURL,
|
|
145
|
+
model: reviewInput.model || env.DSH_DECK_VISION_MODEL || DEFAULT_IMAGE_REVIEW.model,
|
|
146
|
+
apiKeyEnv: reviewInput.apiKeyEnv || env.DSH_DECK_VISION_API_KEY_ENV || DEFAULT_IMAGE_REVIEW.apiKeyEnv,
|
|
147
|
+
apiKeyFile: expandHome(reviewInput.apiKeyFile || env.DSH_DECK_VISION_API_KEY_FILE || DEFAULT_IMAGE_REVIEW.apiKeyFile, env),
|
|
148
|
+
timeoutMs: positive(reviewInput.timeoutMs, DEFAULT_IMAGE_REVIEW.timeoutMs),
|
|
149
|
+
maxTokens: positive(reviewInput.maxTokens, DEFAULT_IMAGE_REVIEW.maxTokens),
|
|
150
|
+
};
|
|
151
|
+
|
|
127
152
|
const decksRaw = Array.isArray(input.decks) ? input.decks : [];
|
|
128
153
|
const decks = decksRaw.map((deck) => normalizeDeck(deck, env)).filter(Boolean);
|
|
129
154
|
|
|
@@ -140,6 +165,8 @@ export function mergeConfig(raw, options = {}) {
|
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
return {
|
|
168
|
+
/** 图片评审的视觉模型通道(F-178);默认空,靠 profile / env 配。 */
|
|
169
|
+
imageReview,
|
|
143
170
|
/** 只在 preset 子树挂载点为 true;根挂载只开路由与面板。 */
|
|
144
171
|
tools: input.tools === true,
|
|
145
172
|
/** preset 子树挂载点设 false(根挂载已注册同路径路由,避免双重注册)。 */
|
|
@@ -152,8 +179,8 @@ export function mergeConfig(raw, options = {}) {
|
|
|
152
179
|
env,
|
|
153
180
|
) || defaultDataRoot(env),
|
|
154
181
|
/**
|
|
155
|
-
* 新建 deck
|
|
156
|
-
*
|
|
182
|
+
* 新建 deck 项目的落盘根。留空 = <dataRoot>/projects/<id>/source。
|
|
183
|
+
* 与会话 workspacePath 独立;默认数据根不在插件安装目录内。
|
|
157
184
|
* 配置了这个根时,新项目直接建在 `<projectsRoot>/<目录名>/`
|
|
158
185
|
* (script.md / intent.md / style-reference.md / prompts/ 同层,和已有 deck 源结构一致)。
|
|
159
186
|
*/
|
|
@@ -205,14 +232,10 @@ export function mergeConfig(raw, options = {}) {
|
|
|
205
232
|
bootstrap: input.bootstrap === true,
|
|
206
233
|
/**
|
|
207
234
|
* 听众评审(F-159):审稿人由 intent 的「听众」动态生成,不是写死的固定 role。
|
|
208
|
-
*
|
|
235
|
+
* 审稿人使用插件内置 Sub Agent,不接受公开 preset 选择;旧 presetId 不再生效。
|
|
209
236
|
* maxReviewers 是成本上限(每个审稿人 = 一次子 agent 调用),默认 3。
|
|
210
237
|
*/
|
|
211
238
|
review: {
|
|
212
|
-
presetId:
|
|
213
|
-
typeof input.review?.presetId === "string" && input.review.presetId.trim()
|
|
214
|
-
? input.review.presetId.trim()
|
|
215
|
-
: "deck-critic",
|
|
216
239
|
maxReviewers: Math.min(
|
|
217
240
|
HARD_MAX_REVIEWERS,
|
|
218
241
|
positive(input.review?.maxReviewers, DEFAULT_MAX_REVIEWERS),
|
package/lib/deck-brief.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// 占位文本可能带嵌套括号(「(待补:…(只写角色,不写标题))」),所以整行匹配而不是遇到第一个右括号就停。
|
|
14
|
+
const clean = (value) => String(value ?? "").replace(/^(待补[\s\S]*)$/, "").trim();
|
|
14
15
|
|
|
15
16
|
/** 读 `---` front matter(只取顶层 `key: value`)。 */
|
|
16
17
|
export function readFrontMatter(text) {
|
|
@@ -56,6 +57,12 @@ export function readDeckBrief(sourceRoot) {
|
|
|
56
57
|
} catch {
|
|
57
58
|
intent = {};
|
|
58
59
|
}
|
|
60
|
+
const skeleton = {
|
|
61
|
+
argument: clean(intent["核心论证"]),
|
|
62
|
+
type: clean(intent["骨架类型"]),
|
|
63
|
+
order: clean(intent["页序角色"]),
|
|
64
|
+
keepThree: clean(intent["只留 3 页留哪 3 页"]),
|
|
65
|
+
};
|
|
59
66
|
return {
|
|
60
67
|
title: clean(scriptHead.title || intent["主题"]),
|
|
61
68
|
owner: clean(scriptHead.owner),
|
|
@@ -65,10 +72,37 @@ export function readDeckBrief(sourceRoot) {
|
|
|
65
72
|
takeaway: clean(intent["带走"]),
|
|
66
73
|
success: clean(intent["成功标准"]),
|
|
67
74
|
scopeOut: clean(intent["不讲的"]),
|
|
75
|
+
skeleton,
|
|
68
76
|
rules: "",
|
|
69
77
|
};
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
/** A0 骨架类型(plan §A0):写页之前先选一个,或自由写一句。 */
|
|
81
|
+
export const SKELETON_TYPES = ["结论先行", "故事线", "教学分解", "视觉主导", "中性简报"];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 论证骨架体检(A0 的判据):
|
|
85
|
+
* - 核心论证 / 骨架类型 / 页序角色 三行必填;
|
|
86
|
+
* - 「只留 3 页留哪 3 页」答不出来 → 说明论证还没想清(warn,不阻断);
|
|
87
|
+
* - 骨架类型不在预设里也行(自由写一句),但要在文本里能看出来。
|
|
88
|
+
* @returns {{ok:boolean, missing:string[], keepThree:boolean, typeKnown:boolean, hint:string}}
|
|
89
|
+
*/
|
|
90
|
+
export function validateSkeleton(brief = {}) {
|
|
91
|
+
const skeleton = brief.skeleton ?? {};
|
|
92
|
+
const missing = [];
|
|
93
|
+
if (!skeleton.argument) missing.push("核心论证");
|
|
94
|
+
if (!skeleton.type) missing.push("骨架类型");
|
|
95
|
+
if (!skeleton.order) missing.push("页序角色");
|
|
96
|
+
const keepThree = Boolean(skeleton.keepThree);
|
|
97
|
+
const typeKnown = !skeleton.type || SKELETON_TYPES.some((type) => String(skeleton.type).includes(type));
|
|
98
|
+
const hint = missing.length
|
|
99
|
+
? `论证骨架还没写全(缺:${missing.join(" / ")})。写页之前先在 intent.md 的「论证骨架」段写三行:核心论证(因为 X,所以 Y;听众要做的是 Z)/ 骨架类型(${SKELETON_TYPES.join(" / ")},或自由写一句)/ 页序角色(只写角色,不写标题)。连贯不是句子的产物,是骨架的产物。`
|
|
100
|
+
: !keepThree
|
|
101
|
+
? "论证骨架有了,但「只留 3 页留哪 3 页」没答——答不出来说明论证还没想清,先想清楚再往下写。"
|
|
102
|
+
: "";
|
|
103
|
+
return { ok: missing.length === 0, missing, keepThree, typeKnown, hint };
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
/** 出图 prompt 用的上下文行:受众 / 目的(一句话)。没有就返回空数组。 */
|
|
73
107
|
export function briefPromptLines(brief = {}) {
|
|
74
108
|
const lines = [];
|
package/lib/deck-docs.js
CHANGED
|
@@ -100,8 +100,10 @@ export const DOC_SPECS = [
|
|
|
100
100
|
{
|
|
101
101
|
id: "review",
|
|
102
102
|
label: "审稿",
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// F-187:评审产出按「读者 → 负责人 → 工程」排序,听众读后感放第一个(原来只有工程口径的 review.md)。
|
|
104
|
+
purpose: "听众读后感(人话)· 意图与听众差距 · 工程口径的问题清单 / 证据 / 修复建议",
|
|
105
|
+
candidates: ["review.readers.md", "review.gap.md", "review.md", "review.experts.md", "review.images.md", "review.scores.md"],
|
|
106
|
+
aliases: ["review.readers.md", "review.gap.md", "review.experts.md", "review.images.md", "review.scores.md"],
|
|
105
107
|
template: "review",
|
|
106
108
|
},
|
|
107
109
|
{
|
|
@@ -412,13 +414,17 @@ export function buildChangeFeed({ entries = [], revisions = [], events = [] }) {
|
|
|
412
414
|
}
|
|
413
415
|
for (const revision of revisions) {
|
|
414
416
|
const pageIds = revision.pageIds ?? revision.slideIds ?? (revision.page ? [revision.page] : []);
|
|
417
|
+
// F-173:首稿自动落版的 revision 要有醒目标记(徽标 + 自改摘要),否则用户看不出「这版没经过确认」。
|
|
418
|
+
const badge = String(revision.autolandBadge ?? "");
|
|
419
|
+
const autoland = revision.autoland && typeof revision.autoland === "object" ? revision.autoland : null;
|
|
415
420
|
rows.push({
|
|
416
421
|
date: revision.savedAt || "",
|
|
417
|
-
kind: "改脚本",
|
|
422
|
+
kind: badge || "改脚本",
|
|
418
423
|
page: pageIds.join(","),
|
|
419
|
-
note: revision.note || "script 编辑",
|
|
420
|
-
actor: revision.actor || "",
|
|
424
|
+
note: revision.note || (autoland?.selfReview?.summary ? `自改摘要:${autoland.selfReview.summary}` : "script 编辑"),
|
|
425
|
+
actor: autoland ? "agent(自动落版)" : revision.actor || "",
|
|
421
426
|
revision: revision.revision || "",
|
|
427
|
+
...(autoland ? { autoland: { ...autoland, badge: badge || "首稿(自动落版,未经确认)" } } : {}),
|
|
422
428
|
});
|
|
423
429
|
}
|
|
424
430
|
for (const event of events) {
|
|
@@ -810,7 +816,7 @@ export function resolveDeckDocs({ adapter, deckDir, render = {}, revisions = [],
|
|
|
810
816
|
* 读取单份文档:正文 + 结构化视图,供中间区对应标签渲染。
|
|
811
817
|
* @param {object} input
|
|
812
818
|
*/
|
|
813
|
-
export function readDeckDoc({ adapter, deckDir, docId, render = {}, revisions = [], events = [] }) {
|
|
819
|
+
export function readDeckDoc({ adapter, deckDir, docId, file = "", render = {}, revisions = [], events = [] }) {
|
|
814
820
|
const spec = DOC_SPECS.find((item) => item.id === docId);
|
|
815
821
|
if (!spec) return null;
|
|
816
822
|
const { docs } = resolveDeckDocs({ adapter, deckDir, render, revisions, events });
|
|
@@ -818,7 +824,39 @@ export function readDeckDoc({ adapter, deckDir, docId, render = {}, revisions =
|
|
|
818
824
|
const sourceRoot = adapter?.sourceRoot ?? "";
|
|
819
825
|
const draftFile = draftPath(deckDir, docId);
|
|
820
826
|
const sourceFile = spec.candidates.map((name) => join(sourceRoot, name)).find((file) => existsSync(file));
|
|
821
|
-
|
|
827
|
+
let primaryFile = sourceFile ?? (existsSync(draftFile) ? draftFile : null);
|
|
828
|
+
// F-187:评审产物写在 deck 工作副本的 docs/ 下(review.readers.md / review.gap.md 等),
|
|
829
|
+
// 不是源 deck 的候选文件;这里按「读者 → 负责人 → 工程」顺序找第一个存在的当作正文。
|
|
830
|
+
if (docId === "review") {
|
|
831
|
+
const preferred = spec.candidates.map((name) => join(deckDir, "docs", name)).find((file) => existsSync(file));
|
|
832
|
+
if (preferred) {
|
|
833
|
+
primaryFile = preferred;
|
|
834
|
+
doc.primary = `docs/${preferred.split(/[\\/]/).pop()}`;
|
|
835
|
+
// 人话版是散文体(没有「严重度」表格),不该被表格解析判成 partial。
|
|
836
|
+
const human = /review\.(readers|gap)\.md$/.test(preferred);
|
|
837
|
+
if (human || doc.status === "missing") {
|
|
838
|
+
doc.status = human ? "ok" : "draft";
|
|
839
|
+
doc.issues = [];
|
|
840
|
+
}
|
|
841
|
+
// 指定了相关文件(工作台点 chip)就读它;只允许本 deck docs/ 下的 review.* 文件。
|
|
842
|
+
const wanted = String(file ?? "").trim();
|
|
843
|
+
if (wanted && /^docs\/review\.[a-z]+\.(md|json)$/.test(wanted)) {
|
|
844
|
+
const target = join(deckDir, wanted);
|
|
845
|
+
if (existsSync(target) && statSync(target).isFile()) {
|
|
846
|
+
primaryFile = target;
|
|
847
|
+
doc.primary = wanted;
|
|
848
|
+
doc.status = "ok";
|
|
849
|
+
doc.issues = [];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// 「相关文件」按顺序列出存在的几本,工作台文档页可以逐个打开。
|
|
853
|
+
for (const name of spec.aliases ?? []) {
|
|
854
|
+
const file = join(deckDir, "docs", name);
|
|
855
|
+
if (!existsSync(file) || !statSync(file).isFile()) continue;
|
|
856
|
+
doc.files.push({ name: `docs/${name}`, exists: true, role: "相关", sizeBytes: statSync(file).size });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
822
860
|
const content = primaryFile ? readText(primaryFile) ?? "" : "";
|
|
823
861
|
const script = adapter?.readScriptRaw?.() ?? readText(join(sourceRoot, "script.md")) ?? "";
|
|
824
862
|
const pages = adapter?.storyPages?.() ?? [];
|
package/lib/deck-templates.js
CHANGED
|
@@ -20,7 +20,7 @@ const orPlaceholder = (value, hint) => (pad(value) ? pad(value) : `(待补:$
|
|
|
20
20
|
* intent.md 初始内容。
|
|
21
21
|
* @param {{title:string, audience?:string, topic?:string, intent?:string, references?:string, created?:string, owner?:string}} input
|
|
22
22
|
*/
|
|
23
|
-
export function buildIntentMarkdown({ title, audience = "", topic = "", intent = "", references = "", occasion = "", success = "", scopeOut = "", created = "", owner = "" } = {}) {
|
|
23
|
+
export function buildIntentMarkdown({ title, audience = "", topic = "", intent = "", references = "", occasion = "", success = "", scopeOut = "", argument = "", skeletonType = "", pageOrder = "", keepThree = "", created = "", owner = "" } = {}) {
|
|
24
24
|
const heading = pad(title) || "(deck 标题)";
|
|
25
25
|
return [
|
|
26
26
|
"---",
|
|
@@ -39,6 +39,13 @@ export function buildIntentMarkdown({ title, audience = "", topic = "", intent =
|
|
|
39
39
|
`- **成功标准**:${orPlaceholder(success, "怎么判断这场讲成了")}`,
|
|
40
40
|
`- **不讲的**:${orPlaceholder(scopeOut, "明确排除什么,避免范围膨胀")}`,
|
|
41
41
|
"",
|
|
42
|
+
"## 论证骨架(写页之前先定)",
|
|
43
|
+
"",
|
|
44
|
+
`- **核心论证**:${orPlaceholder(argument, "因为 [X],所以 [Y];听众要做的是 [Z]")}`,
|
|
45
|
+
`- **骨架类型**:${orPlaceholder(skeletonType, "结论先行 / 故事线 / 教学分解 / 视觉主导 / 中性简报,或自由写一句")}`,
|
|
46
|
+
`- **页序角色**:${orPlaceholder(pageOrder, "封面 → 结论 → 证据1 → 证据2 → 风险 → 请求;只写角色,不写标题")}`,
|
|
47
|
+
`- **只留 3 页留哪 3 页**:${orPlaceholder(keepThree, "答不出来说明论证还没想清——先想清楚再列页")}`,
|
|
48
|
+
"",
|
|
42
49
|
"## 参考资料",
|
|
43
50
|
"",
|
|
44
51
|
orPlaceholder(references, "链接 / 文件路径 / 已有材料说明"),
|
package/lib/deck-workspace.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module dsh-plugin-deck-studio/deck-workspace
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { createLocalSourceAdapter } from "./source-adapter.js";
|
|
@@ -56,6 +56,12 @@ export const SECTION_FIELD_KEYS = {
|
|
|
56
56
|
// F-109:正文独立成字段。以前 claim 把「正文」当别名,提案会整段覆盖正文,
|
|
57
57
|
// 现在 body 走自己的字段,claim 不再误伤正文。
|
|
58
58
|
body: ["正文"],
|
|
59
|
+
// F-181(I-06):逐页契约 5 项里的可写字段;缺字段时插在标题行之后,老 deck 不受影响。
|
|
60
|
+
// 键名用中文字段名(工具 enum 直接暴露给人看),英文别名保留给内部调用。
|
|
61
|
+
角色: ["角色"],
|
|
62
|
+
受众变化: ["受众变化", "听众变化"],
|
|
63
|
+
关系: ["关系"],
|
|
64
|
+
证据: ["证据"],
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
/** 标题行(`### P04 · …`)与该页结束行(下一个 heading 之前)。 */
|
|
@@ -393,6 +399,25 @@ export function createDeckWorkspace(config) {
|
|
|
393
399
|
return { revision, path: file };
|
|
394
400
|
}
|
|
395
401
|
|
|
402
|
+
/** 已落版 script revision 列表(新的在前);首稿判定与回滚共用。 */
|
|
403
|
+
function listScriptRevisions(deckId) {
|
|
404
|
+
const dir = join(deckDir(deckId), "script", "revisions");
|
|
405
|
+
if (!existsSync(dir)) return [];
|
|
406
|
+
return readdirSync(dir)
|
|
407
|
+
.filter((name) => /^v\d+\.md$/.test(name))
|
|
408
|
+
.sort()
|
|
409
|
+
.reverse()
|
|
410
|
+
.map((name) => {
|
|
411
|
+
const revision = name.replace(/\.md$/, "");
|
|
412
|
+
const noteFile = join(dir, `${revision}.note.txt`);
|
|
413
|
+
return {
|
|
414
|
+
revision,
|
|
415
|
+
savedAt: statSync(join(dir, name)).mtime.toISOString(),
|
|
416
|
+
note: existsSync(noteFile) ? readFileSync(noteFile, "utf8").trim() : "",
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
396
421
|
/** 提案。 */
|
|
397
422
|
function saveProposal(proposal) {
|
|
398
423
|
const dir = join(deckDir(proposal.deckId), "proposals");
|
|
@@ -460,6 +485,7 @@ export function createDeckWorkspace(config) {
|
|
|
460
485
|
appendScriptPages,
|
|
461
486
|
scriptPageNumbers,
|
|
462
487
|
saveScriptRevision,
|
|
488
|
+
listScriptRevisions,
|
|
463
489
|
saveProposal,
|
|
464
490
|
proposal,
|
|
465
491
|
updateProposal,
|
package/lib/designs.js
CHANGED
|
@@ -41,6 +41,21 @@ export function normalizeAbbr(value) {
|
|
|
41
41
|
return abbr.toUpperCase();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* 从风格名称推一个可用缩写(F-186:普通对话里登记风格时不用用户想缩写)。
|
|
46
|
+
* 规则与 normalizeAbbr 一致:2–6 位、字母开头;中文名没有拉丁字母时退化成 `ST01`。
|
|
47
|
+
*/
|
|
48
|
+
export function suggestAbbr(name, taken = []) {
|
|
49
|
+
const used = new Set((taken ?? []).map((value) => String(value ?? "").toUpperCase()).filter(Boolean));
|
|
50
|
+
const latin = String(name ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "").replace(/[0-9]/g, "");
|
|
51
|
+
const base = (latin.length >= 2 ? latin : "ST").slice(0, 4);
|
|
52
|
+
for (let index = 0; index < 500; index += 1) {
|
|
53
|
+
const candidate = index === 0 ? base : `${base.slice(0, 3)}${String(index).padStart(2, "0")}`;
|
|
54
|
+
if (candidate.length <= 6 && !used.has(candidate)) return candidate;
|
|
55
|
+
}
|
|
56
|
+
throw new Error("自动生成风格缩写失败(同名风格太多),请手动指定 abbr");
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
/**
|
|
45
60
|
* @param {{dataRoot: string}} input
|
|
46
61
|
*/
|
|
@@ -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
|
+
}
|