dsh-thesis 1.0.0

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 (188) hide show
  1. package/DESIGN.md +390 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +10 -0
  4. package/README.md +253 -0
  5. package/cordis.patch.yml +17 -0
  6. package/docs/host-api.md +640 -0
  7. package/lib/codewalk/commands.d.ts +23 -0
  8. package/lib/codewalk/commands.js +168 -0
  9. package/lib/codewalk/engine.d.ts +108 -0
  10. package/lib/codewalk/engine.js +449 -0
  11. package/lib/codewalk/errors.d.ts +31 -0
  12. package/lib/codewalk/errors.js +20 -0
  13. package/lib/codewalk/fs-types.d.ts +34 -0
  14. package/lib/codewalk/fs-types.js +10 -0
  15. package/lib/codewalk/gate.d.ts +30 -0
  16. package/lib/codewalk/gate.js +60 -0
  17. package/lib/codewalk/host-types.d.ts +198 -0
  18. package/lib/codewalk/host-types.js +11 -0
  19. package/lib/codewalk/prestep.d.ts +17 -0
  20. package/lib/codewalk/prestep.js +62 -0
  21. package/lib/codewalk/register.d.ts +23 -0
  22. package/lib/codewalk/register.js +31 -0
  23. package/lib/codewalk/skill.d.ts +15 -0
  24. package/lib/codewalk/skill.js +57 -0
  25. package/lib/codewalk/tools.d.ts +18 -0
  26. package/lib/codewalk/tools.js +314 -0
  27. package/lib/codewalk/types.d.ts +151 -0
  28. package/lib/codewalk/types.js +9 -0
  29. package/lib/commands.d.ts +33 -0
  30. package/lib/commands.js +60 -0
  31. package/lib/config.d.ts +125 -0
  32. package/lib/config.js +139 -0
  33. package/lib/dedup/commands.d.ts +21 -0
  34. package/lib/dedup/commands.js +93 -0
  35. package/lib/dedup/index.d.ts +75 -0
  36. package/lib/dedup/index.js +331 -0
  37. package/lib/dedup/normalize.d.ts +125 -0
  38. package/lib/dedup/normalize.js +329 -0
  39. package/lib/dedup/report.d.ts +114 -0
  40. package/lib/dedup/report.js +309 -0
  41. package/lib/dedup/rewrite.d.ts +71 -0
  42. package/lib/dedup/rewrite.js +228 -0
  43. package/lib/dedup/similarity.d.ts +169 -0
  44. package/lib/dedup/similarity.js +323 -0
  45. package/lib/gates/dangerous.d.ts +22 -0
  46. package/lib/gates/dangerous.js +47 -0
  47. package/lib/gates/detect.d.ts +54 -0
  48. package/lib/gates/detect.js +106 -0
  49. package/lib/gates/messages.d.ts +39 -0
  50. package/lib/gates/messages.js +74 -0
  51. package/lib/gates/prompt.d.ts +21 -0
  52. package/lib/gates/prompt.js +56 -0
  53. package/lib/gates/writing-intent.d.ts +49 -0
  54. package/lib/gates/writing-intent.js +68 -0
  55. package/lib/index.d.ts +36 -0
  56. package/lib/index.js +76 -0
  57. package/lib/ingest/code.d.ts +71 -0
  58. package/lib/ingest/code.js +356 -0
  59. package/lib/ingest/commands.d.ts +23 -0
  60. package/lib/ingest/commands.js +74 -0
  61. package/lib/ingest/docx.d.ts +29 -0
  62. package/lib/ingest/docx.js +92 -0
  63. package/lib/ingest/index.d.ts +179 -0
  64. package/lib/ingest/index.js +640 -0
  65. package/lib/ingest/ooxml.d.ts +58 -0
  66. package/lib/ingest/ooxml.js +393 -0
  67. package/lib/ingest/pdf.d.ts +86 -0
  68. package/lib/ingest/pdf.js +1016 -0
  69. package/lib/ingest/pptx.d.ts +32 -0
  70. package/lib/ingest/pptx.js +116 -0
  71. package/lib/ingest/text.d.ts +25 -0
  72. package/lib/ingest/text.js +180 -0
  73. package/lib/ingest/types.d.ts +48 -0
  74. package/lib/ingest/types.js +47 -0
  75. package/lib/ingest/xlsx.d.ts +72 -0
  76. package/lib/ingest/xlsx.js +411 -0
  77. package/lib/ingest/zip.d.ts +76 -0
  78. package/lib/ingest/zip.js +393 -0
  79. package/lib/intake/definitions.d.ts +65 -0
  80. package/lib/intake/definitions.js +42 -0
  81. package/lib/intake/gate-rules.d.ts +57 -0
  82. package/lib/intake/gate-rules.js +118 -0
  83. package/lib/intake/gate.d.ts +24 -0
  84. package/lib/intake/gate.js +86 -0
  85. package/lib/intake/index.d.ts +76 -0
  86. package/lib/intake/index.js +468 -0
  87. package/lib/intake/questions.d.ts +169 -0
  88. package/lib/intake/questions.js +720 -0
  89. package/lib/intake/spec.d.ts +66 -0
  90. package/lib/intake/spec.js +221 -0
  91. package/lib/intake/state.d.ts +192 -0
  92. package/lib/intake/state.js +420 -0
  93. package/lib/memory/recall.d.ts +69 -0
  94. package/lib/memory/recall.js +177 -0
  95. package/lib/memory/register.d.ts +37 -0
  96. package/lib/memory/register.js +52 -0
  97. package/lib/memory/service.d.ts +39 -0
  98. package/lib/memory/service.js +39 -0
  99. package/lib/memory/store.d.ts +45 -0
  100. package/lib/memory/store.js +123 -0
  101. package/lib/memory/tools.d.ts +19 -0
  102. package/lib/memory/tools.js +195 -0
  103. package/lib/memory/types.d.ts +55 -0
  104. package/lib/memory/types.js +6 -0
  105. package/lib/paper/commands.d.ts +13 -0
  106. package/lib/paper/commands.js +84 -0
  107. package/lib/paper/lib/aicheck.d.ts +38 -0
  108. package/lib/paper/lib/aicheck.js +241 -0
  109. package/lib/paper/lib/bibtex.d.ts +21 -0
  110. package/lib/paper/lib/bibtex.js +114 -0
  111. package/lib/paper/lib/check.d.ts +32 -0
  112. package/lib/paper/lib/check.js +215 -0
  113. package/lib/paper/lib/defense.d.ts +48 -0
  114. package/lib/paper/lib/defense.js +220 -0
  115. package/lib/paper/lib/docx.d.ts +35 -0
  116. package/lib/paper/lib/docx.js +213 -0
  117. package/lib/paper/lib/layout.d.ts +41 -0
  118. package/lib/paper/lib/layout.js +572 -0
  119. package/lib/paper/lib/ledger.d.ts +112 -0
  120. package/lib/paper/lib/ledger.js +349 -0
  121. package/lib/paper/lib/lit-api.d.ts +59 -0
  122. package/lib/paper/lib/lit-api.js +296 -0
  123. package/lib/paper/lib/lit-cache.d.ts +32 -0
  124. package/lib/paper/lib/lit-cache.js +91 -0
  125. package/lib/paper/lib/project.d.ts +29 -0
  126. package/lib/paper/lib/project.js +93 -0
  127. package/lib/paper/lib/review.d.ts +37 -0
  128. package/lib/paper/lib/review.js +194 -0
  129. package/lib/paper/lib/zip.d.ts +14 -0
  130. package/lib/paper/lib/zip.js +124 -0
  131. package/lib/paper/register.d.ts +20 -0
  132. package/lib/paper/register.js +183 -0
  133. package/lib/paper/tools/aicheck.d.ts +6 -0
  134. package/lib/paper/tools/aicheck.js +38 -0
  135. package/lib/paper/tools/build.d.ts +27 -0
  136. package/lib/paper/tools/build.js +166 -0
  137. package/lib/paper/tools/check.d.ts +7 -0
  138. package/lib/paper/tools/check.js +79 -0
  139. package/lib/paper/tools/decision.d.ts +13 -0
  140. package/lib/paper/tools/decision.js +53 -0
  141. package/lib/paper/tools/defense.d.ts +11 -0
  142. package/lib/paper/tools/defense.js +74 -0
  143. package/lib/paper/tools/init.d.ts +23 -0
  144. package/lib/paper/tools/init.js +99 -0
  145. package/lib/paper/tools/lit.d.ts +27 -0
  146. package/lib/paper/tools/lit.js +236 -0
  147. package/lib/paper/tools/progress.d.ts +18 -0
  148. package/lib/paper/tools/progress.js +77 -0
  149. package/lib/paper/tools/review.d.ts +11 -0
  150. package/lib/paper/tools/review.js +41 -0
  151. package/lib/ppt/actions.d.ts +97 -0
  152. package/lib/ppt/actions.js +263 -0
  153. package/lib/ppt/check.d.ts +64 -0
  154. package/lib/ppt/check.js +185 -0
  155. package/lib/ppt/commands.d.ts +28 -0
  156. package/lib/ppt/commands.js +71 -0
  157. package/lib/ppt/convert.d.ts +146 -0
  158. package/lib/ppt/convert.js +423 -0
  159. package/lib/ppt/index.d.ts +45 -0
  160. package/lib/ppt/index.js +57 -0
  161. package/lib/ppt/marp.d.ts +74 -0
  162. package/lib/ppt/marp.js +165 -0
  163. package/lib/ppt/outline.d.ts +206 -0
  164. package/lib/ppt/outline.js +695 -0
  165. package/lib/shared/define-tool.d.ts +77 -0
  166. package/lib/shared/define-tool.js +235 -0
  167. package/lib/shared/fs-errors.d.ts +50 -0
  168. package/lib/shared/fs-errors.js +90 -0
  169. package/lib/shared/index.d.ts +31 -0
  170. package/lib/shared/index.js +32 -0
  171. package/lib/shared/spawn.d.ts +38 -0
  172. package/lib/shared/spawn.js +50 -0
  173. package/lib/shared/text.d.ts +14 -0
  174. package/lib/shared/text.js +26 -0
  175. package/package.json +82 -0
  176. package/skills/code-walkthrough/SKILL.md +94 -0
  177. package/skills/code-walkthrough/references/examples.md +129 -0
  178. package/skills/paper-dedup/SKILL.md +58 -0
  179. package/skills/paper-intake/SKILL.md +64 -0
  180. package/skills/thesis-citation/SKILL.md +67 -0
  181. package/skills/thesis-defense/SKILL.md +87 -0
  182. package/skills/thesis-eng-design/SKILL.md +97 -0
  183. package/skills/thesis-inquiry/SKILL.md +55 -0
  184. package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
  185. package/skills/thesis-literature/SKILL.md +56 -0
  186. package/skills/thesis-opener/SKILL.md +77 -0
  187. package/skills/thesis-pipeline/SKILL.md +121 -0
  188. package/skills/thesis-writing/SKILL.md +86 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `thesis_slides` 的四个动作实现(outline / convert / check / guide)。
3
+ *
4
+ * 与 `src/paper/tools/*.ts` 同一分层思路:**业务逻辑在这里,靠 `defineTool` 的
5
+ * 胶水在 `index.ts`**。本文件只依赖 Node 内置与 `FileSystem` 的**类型**(宿主在
6
+ * 运行期提供真实实现),因此在没有安装宿主包的离线环境里也能被单元测试直接导入。
7
+ *
8
+ * 落盘约定(全部相对论文仓库根):
9
+ * - `07-答辩/PPT.md`(outline;默认不覆盖,force 时先备份为 `PPT-<时间戳>.md`)
10
+ * - `07-答辩/PPT.pptx`(convert 成功时,由外部转换器写出)
11
+ * - `07-答辩/PPT转换说明.md`(convert 无论成败都写:探测结果 + 命令 + 退出码 + 输出摘要)
12
+ *
13
+ * @module dsh-thesis/ppt
14
+ */
15
+ import * as nodePath from 'node:path';
16
+ import { existsSync } from 'node:fs';
17
+ import { findThesisRoot } from "../paper/lib/project.js";
18
+ import { buildSlidePlan, loadOutlineInputs, audienceProfile } from "./outline.js";
19
+ import { renderMarp } from "./marp.js";
20
+ import { checkDeck, renderCheckReport, verifyAnchors } from "./check.js";
21
+ import { conversionGuide, convert, detectEngines, renderConversionNotes } from "./convert.js";
22
+ /** PPT 工具在论文工作区里的所有落盘位置。 */
23
+ export const PPT_RELS = {
24
+ /** 幻灯 Markdown(唯一真源)。 */
25
+ slides: '07-答辩/PPT.md',
26
+ /** 外部转换器产出的 pptx。 */
27
+ pptx: '07-答辩/PPT.pptx',
28
+ /** 转换说明(探测结果 + 确切命令,任何情况都写)。 */
29
+ notes: '07-答辩/PPT转换说明.md',
30
+ /** 素材(输入,由 thesis_defense 生成)。 */
31
+ materials: '07-答辩/答辩素材.md',
32
+ };
33
+ /** 目标页数的合法区间(放置非法入参)。 */
34
+ const PAGES_MIN = 8;
35
+ const PAGES_MAX = 20;
36
+ function clampPages(pages) {
37
+ const value = pages === undefined || !Number.isFinite(pages) ? 11 : Math.round(pages);
38
+ return Math.min(PAGES_MAX, Math.max(PAGES_MIN, value));
39
+ }
40
+ function normalizeAudience(audience) {
41
+ return audience === 'master' ? 'master' : 'undergrad';
42
+ }
43
+ /** 时间戳(本地时间,文件名安全:20260912-184800)。 */
44
+ export function timestamp(now) {
45
+ const pad = (n) => String(n).padStart(2, '0');
46
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
47
+ }
48
+ async function readOptional(fs, absPath, signal) {
49
+ try {
50
+ return await fs.readText(await fs.resolve(absPath, { signal }), signal);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ async function writeText(fs, absPath, content, signal) {
57
+ await fs.writeText(await fs.resolve(absPath, { signal }), content, undefined, signal);
58
+ }
59
+ /** 把绝对路径转成工作区内的 POSIX 相对路径(写进说明文档,跨平台可读)。 */
60
+ function relativeForDisplay(root, absPath) {
61
+ return nodePath.relative(root, absPath).split(nodePath.sep).join('/') || absPath;
62
+ }
63
+ /** 由已装载的素材构建幻灯计划(工具与测试共用)。 */
64
+ export function planFromInputs(inputs, audience, pages) {
65
+ const { ledgerTitle, ...rest } = inputs;
66
+ return buildSlidePlan({ title: ledgerTitle, audience, targetPages: pages, ...rest });
67
+ }
68
+ async function resolveDeck(fs, root, signal) {
69
+ const markdown = await readOptional(fs, nodePath.join(root, PPT_RELS.slides), signal);
70
+ if (markdown === undefined) {
71
+ throw new Error(`${PPT_RELS.slides} 尚未生成。请先运行 thesis_slides action=outline。`);
72
+ }
73
+ return markdown;
74
+ }
75
+ /** 定位论文仓库根;找不到即抛错(所有动作都要求在工作区内)。 */
76
+ export async function requireRoot(fs, cwd, signal) {
77
+ const root = await findThesisRoot(fs, cwd, signal);
78
+ if (root === null)
79
+ throw new Error('未找到论文工作区。请先在论文仓库目录内操作(需要 00-管理/进度台账.md)。');
80
+ return root;
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // outline
84
+ // ---------------------------------------------------------------------------
85
+ /** outline 的文本摘要(有界)。 */
86
+ export function outlineSummary(plan, slidesRel, notesRel, backupRel) {
87
+ const lines = [
88
+ `幻灯 Markdown 已生成:${slidesRel}`,
89
+ `- 实际页数:${plan.pages} 页(听众:${audienceProfile(plan.audience).label})`,
90
+ `- 预计时长:${plan.estimate.minMinutes}-${plan.estimate.maxMinutes} 分钟(每页 ${plan.estimate.secondsPerSlideMin}-${plan.estimate.secondsPerSlideMax} 秒)`,
91
+ `- 素材来源:${plan.source === 'defense-materials' ? PPT_RELS.materials : '06-论文/章节/*.md(回退)'}`,
92
+ ];
93
+ if (plan.note !== undefined)
94
+ lines.push(`- 提示:${plan.note}`);
95
+ if (plan.pageAdjustment !== undefined)
96
+ lines.push(`- 页数调整:${plan.pageAdjustment}`);
97
+ for (const warning of plan.warnings)
98
+ lines.push(`- 注意:${warning}`);
99
+ if (backupRel !== undefined)
100
+ lines.push(`- 旧文件已备份为:${backupRel}`);
101
+ lines.push(`- 转换说明:${notesRel}`);
102
+ lines.push('');
103
+ lines.push('下一步:');
104
+ lines.push('- 复核页数/要点/讲稿后运行 thesis_slides action=convert 生成 PPT.pptx');
105
+ lines.push('- 或运行 thesis_slides action=guide 获取可复制执行的转换命令(本机未装 marp/pandoc 时用)');
106
+ return lines.join('\n');
107
+ }
108
+ /** `action=outline`:生成/刷新 `07-答辩/PPT.md`。 */
109
+ export async function runOutline(fs, root, args, options, signal, deps = {}) {
110
+ const now = deps.now ?? (() => new Date());
111
+ const audience = normalizeAudience(args.audience);
112
+ const targetPages = clampPages(args.pages);
113
+ const slidesAbs = nodePath.join(root, PPT_RELS.slides);
114
+ const inputs = await loadOutlineInputs(fs, root, signal);
115
+ const plan = planFromInputs(inputs, audience, targetPages);
116
+ const markdown = renderMarp(plan, { theme: options.ppt.theme, footer: 'dsh-thesis · 答辩幻灯', header: plan.title });
117
+ const existing = await readOptional(fs, slidesAbs, signal);
118
+ if (existing !== undefined && existing.trim() !== '' && args.force !== true) {
119
+ return {
120
+ plan,
121
+ text: [
122
+ `${PPT_RELS.slides} 已存在,未覆盖(保护你手工改过的内容)。`,
123
+ `- 现有文件:${PPT_RELS.slides}`,
124
+ `- 本次将生成 ${plan.pages} 页(若确认覆盖,请带 force=true;旧文件会先备份为 07-答辩/PPT-<时间戳>.md)`,
125
+ '',
126
+ '建议:先 thesis_slides action=check 看现有文件的问题清单,或复制一份再重新生成。',
127
+ ].join('\n'),
128
+ };
129
+ }
130
+ let backupRel;
131
+ if (existing !== undefined && existing.trim() !== '') {
132
+ backupRel = `07-答辩/PPT-${timestamp(now())}.md`;
133
+ await writeText(fs, nodePath.join(root, backupRel), existing, signal);
134
+ }
135
+ await writeText(fs, slidesAbs, markdown, signal);
136
+ return { plan, text: outlineSummary(plan, PPT_RELS.slides, PPT_RELS.notes, backupRel) };
137
+ }
138
+ /** `action=check`:对已有 `07-答辩/PPT.md` 做确定性质量检查。 */
139
+ export async function runCheckAction(fs, root, args, signal) {
140
+ const markdown = await resolveDeck(fs, root, signal);
141
+ const result = checkDeck(markdown, {
142
+ audience: normalizeAudience(args.audience),
143
+ fs,
144
+ root,
145
+ targetPages: args.pages !== undefined ? clampPages(args.pages) : undefined,
146
+ });
147
+ const anchorIssues = await verifyAnchors(markdown, fs, root);
148
+ const merged = {
149
+ ...result,
150
+ issues: [...result.issues, ...anchorIssues],
151
+ warnings: result.warnings + anchorIssues.length,
152
+ };
153
+ return { result: merged, anchorIssues, text: renderCheckReport(merged, PPT_RELS.slides) };
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // convert
157
+ // ---------------------------------------------------------------------------
158
+ /** convert 的文本摘要(有界)。 */
159
+ export function convertSummary(slidesRel, pptxRel, notesRel, detection, engine, result, error) {
160
+ const lines = [];
161
+ if (result !== undefined && result.ok) {
162
+ lines.push(`转换成功:${pptxRel}(引擎:${result.engine})`);
163
+ }
164
+ else {
165
+ lines.push(`未产出 pptx:${error ?? '未知原因'}`);
166
+ }
167
+ lines.push(`- 幻灯 Markdown:${slidesRel}`);
168
+ lines.push(`- 目标文件:${pptxRel}`);
169
+ lines.push(`- 配置引擎:${engine}`);
170
+ lines.push(`- 探测:marp=${detection.marp ? '可用' : '无'};npx marp-cli=${detection.marpNpx ? '可用' : '无'};pandoc=${detection.pandoc ? '可用' : '无'}`);
171
+ if (result !== undefined) {
172
+ for (const record of result.commands) {
173
+ lines.push(`- 命令:${[record.command, ...record.args].join(' ')} → 退出码 ${record.status ?? 'null'},耗时 ${record.elapsedMs} ms`);
174
+ }
175
+ }
176
+ lines.push(`- 转换说明(含命令与输出摘要):${notesRel}`);
177
+ if (result === undefined || !result.ok) {
178
+ lines.push('');
179
+ lines.push('下一步(可直接复制执行):');
180
+ lines.push(`- npx --yes @marp-team/marp-cli "${slidesRel}" --pptx --output "${pptxRel}" --allow-local-files`);
181
+ lines.push('- 或运行 thesis_slides action=guide 查看完整指引(PowerShell/Unix/手工兜底/离线方案)');
182
+ }
183
+ return lines.join('\n');
184
+ }
185
+ /** `action=convert`:执行外部转换;失败时**先写说明再抛错**(绝不假装成功)。 */
186
+ export async function runConvertAction(fs, root, options, signal, deps = {}) {
187
+ const now = deps.now ?? (() => new Date());
188
+ const detect = deps.detect ?? (() => detectEngines());
189
+ const runConvert = deps.convert ?? ((slidesPath, outPath, opts) => convert(slidesPath, outPath, { engine: opts.engine, timeoutMs: opts.timeoutMs, exists: path => existsSync(path) }));
190
+ const writePptx = deps.writeBinary ?? (async () => { });
191
+ const slidesAbs = nodePath.join(root, PPT_RELS.slides);
192
+ const pptxAbs = nodePath.join(root, PPT_RELS.pptx);
193
+ const notesAbs = nodePath.join(root, PPT_RELS.notes);
194
+ await resolveDeck(fs, root, signal);
195
+ const detection = detect();
196
+ let result;
197
+ let failure;
198
+ try {
199
+ result = runConvert(slidesAbs, pptxAbs, { engine: options.ppt.engine, timeoutMs: options.ppt.timeoutMs });
200
+ }
201
+ catch (error) {
202
+ failure = error instanceof Error ? error.message : String(error);
203
+ }
204
+ if (result !== undefined && result.ok && failure === undefined) {
205
+ // 真实转换器已经把 pptx 写在目标路径上;这里只调用落盘钩子(测试替身用),
206
+ // 生产环境用它保证"产出即落盘"这一契约可替换、可测。
207
+ await writePptx(pptxAbs, new Uint8Array(0));
208
+ }
209
+ const notes = renderConversionNotes({
210
+ slidesPath: relativeForDisplay(root, slidesAbs),
211
+ outPath: relativeForDisplay(root, pptxAbs),
212
+ detection,
213
+ engine: options.ppt.engine,
214
+ ...(result !== undefined ? { result } : {}),
215
+ ...(failure !== undefined ? { error: failure } : {}),
216
+ theme: options.ppt.theme,
217
+ now: now(),
218
+ });
219
+ await writeText(fs, notesAbs, notes, signal);
220
+ const text = convertSummary(PPT_RELS.slides, PPT_RELS.pptx, PPT_RELS.notes, detection, options.ppt.engine, result, failure);
221
+ if (failure !== undefined) {
222
+ return {
223
+ text: `${text}\n\n(转换说明已写入 ${PPT_RELS.notes})`,
224
+ detection,
225
+ error: failure,
226
+ };
227
+ }
228
+ return { text, detection, ...(result !== undefined ? { conversion: result } : {}) };
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // guide
232
+ // ---------------------------------------------------------------------------
233
+ /** `action=guide`:只输出可复制执行的转换指引,不写盘、不执行。 */
234
+ export function runGuideAction(deps = {}) {
235
+ const detect = deps.detect ?? (() => detectEngines());
236
+ const detection = detect();
237
+ return { text: conversionGuide(PPT_RELS.slides, PPT_RELS.pptx, detection), detection };
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // 动作分发
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * 按 action 分发。`convert` 失败时抛出的错误里**带上已经写好的说明**,
244
+ * 让模型看到的是一次真实失败 + 下一步命令,而不是沉默。
245
+ */
246
+ export async function runPptAction(fs, cwd, args, options, signal, deps = {}) {
247
+ if (args.action === 'guide')
248
+ return runGuideAction(deps);
249
+ const root = await requireRoot(fs, cwd, signal);
250
+ if (args.action === 'outline')
251
+ return runOutline(fs, root, args, options, signal, deps);
252
+ if (args.action === 'check') {
253
+ const checked = await runCheckAction(fs, root, args, signal);
254
+ return { text: checked.text };
255
+ }
256
+ if (args.action === 'convert') {
257
+ const outcome = await runConvertAction(fs, root, options, signal, deps);
258
+ if (outcome.error !== undefined)
259
+ throw new Error(outcome.text);
260
+ return outcome;
261
+ }
262
+ throw new Error(`未知 action:${String(args.action)}。可用:outline | convert | check | guide。`);
263
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 幻灯质量检查(`thesis_slides action=check` 的核心)。
3
+ *
4
+ * 全部检查项都是**确定性**的(不调用模型):页数区间、每页要点条数、单条要点
5
+ * 字数、是否有讲稿、是否每页都有证据锚点、是否出现「待补充/TODO」、预计时长
6
+ * 是否落在 8–12 分钟。每条问题都带页码与原文位置,便于逐条修。
7
+ *
8
+ * @module dsh-thesis/ppt
9
+ */
10
+ import type { FileSystem } from '@deepseek-ai/dsh-fs';
11
+ import { type Audience } from './outline.ts';
12
+ /** 问题级别:error = 必须修;warn = 建议修。 */
13
+ export type IssueLevel = 'error' | 'warn';
14
+ /** 一条检查问题。 */
15
+ export interface SlideIssue {
16
+ /** 稳定的问题代号(便于测试与逐条定位)。 */
17
+ readonly code: string;
18
+ readonly level: IssueLevel;
19
+ /** 定位(如「第 3 页」);全局问题为「全文」。 */
20
+ readonly where: string;
21
+ readonly message: string;
22
+ }
23
+ /** 质量检查结果。 */
24
+ export interface CheckResult {
25
+ readonly pages: number;
26
+ readonly estimate: {
27
+ readonly minMinutes: number;
28
+ readonly maxMinutes: number;
29
+ readonly secondsPerSlideMin: number;
30
+ readonly secondsPerSlideMax: number;
31
+ };
32
+ readonly issues: readonly SlideIssue[];
33
+ readonly errors: number;
34
+ readonly warnings: number;
35
+ /** 通过(无 error)时为 true。 */
36
+ readonly ok: boolean;
37
+ }
38
+ /** 每页预计时长的允许区间(分钟)。 */
39
+ export declare const DURATION_MIN_MINUTES = 8;
40
+ export declare const DURATION_MAX_MINUTES = 12;
41
+ /** 占位符/未完成标记(命中即提示)。 */
42
+ export declare const PLACEHOLDER_PATTERNS: readonly {
43
+ readonly pattern: RegExp;
44
+ readonly label: string;
45
+ }[];
46
+ /**
47
+ * 检查幻灯 Markdown 原文(确定性检查项全集)。
48
+ *
49
+ * `pages`/`audience` 用于判定页数区间与时长;`fs`/`root` 可选——提供了就顺便
50
+ * 验证证据锚点指向的文件是否存在(缺失记 warn,不阻塞)。
51
+ */
52
+ export declare function checkDeck(markdown: string, options: {
53
+ readonly audience: Audience;
54
+ readonly fs?: FileSystem;
55
+ readonly root?: string;
56
+ readonly targetPages?: number;
57
+ }): CheckResult;
58
+ /**
59
+ * 证据锚点落盘校验(可选):锚点形如 `相对路径#小节`,只校验路径部分是否存在。
60
+ * 缺失记为 warn——素材尚未生成时会大量缺失,属于正常提示而非错误。
61
+ */
62
+ export declare function verifyAnchors(markdown: string, fs: FileSystem, root: string): Promise<SlideIssue[]>;
63
+ /** 检查结果的文本渲染(有界;工具返回值用)。 */
64
+ export declare function renderCheckReport(result: CheckResult, deckRelPath: string): string;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 幻灯质量检查(`thesis_slides action=check` 的核心)。
3
+ *
4
+ * 全部检查项都是**确定性**的(不调用模型):页数区间、每页要点条数、单条要点
5
+ * 字数、是否有讲稿、是否每页都有证据锚点、是否出现「待补充/TODO」、预计时长
6
+ * 是否落在 8–12 分钟。每条问题都带页码与原文位置,便于逐条修。
7
+ *
8
+ * @module dsh-thesis/ppt
9
+ */
10
+ import * as nodePath from 'node:path';
11
+ import { MAX_BULLETS_PER_SLIDE, MAX_BULLET_CHARS, MAX_TITLE_CHARS, MIN_BULLETS_PER_SLIDE, audienceProfile } from "./outline.js";
12
+ import { parseMarp } from "./marp.js";
13
+ /** 每页预计时长的允许区间(分钟)。 */
14
+ export const DURATION_MIN_MINUTES = 8;
15
+ export const DURATION_MAX_MINUTES = 12;
16
+ /** 占位符/未完成标记(命中即提示)。 */
17
+ export const PLACEHOLDER_PATTERNS = [
18
+ { pattern: /待补充/, label: '待补充' },
19
+ { pattern: /待补\b|待填|待定/, label: '待填占位' },
20
+ { pattern: /TODO|FIXME/, label: 'TODO/FIXME' },
21
+ { pattern: /<[^>\s]{1,20}>/, label: '<> 模板占位' },
22
+ ];
23
+ function textLength(text) {
24
+ return text.replace(/\s+/g, '').length;
25
+ }
26
+ /**
27
+ * 检查幻灯 Markdown 原文(确定性检查项全集)。
28
+ *
29
+ * `pages`/`audience` 用于判定页数区间与时长;`fs`/`root` 可选——提供了就顺便
30
+ * 验证证据锚点指向的文件是否存在(缺失记 warn,不阻塞)。
31
+ */
32
+ export function checkDeck(markdown, options) {
33
+ const deck = parseMarp(markdown);
34
+ const profile = audienceProfile(options.audience);
35
+ const issues = [];
36
+ const pages = deck.slides.length;
37
+ const push = (code, level, where, message) => {
38
+ issues.push({ code, level, where, message });
39
+ };
40
+ // 1. 页数区间
41
+ const [minPages, maxPages] = profile.pages;
42
+ if (pages === 0) {
43
+ push('deck-empty', 'error', '全文', '没有解析到任何幻灯片(每页需要一行 `## 标题` 与要点列表)。');
44
+ }
45
+ else if (pages < minPages) {
46
+ push('deck-too-few-pages', 'error', '全文', `只有 ${pages} 页,少于${profile.label}答辩骨架要求的 ${minPages}-${maxPages} 页。`);
47
+ }
48
+ else if (pages > maxPages) {
49
+ push('deck-too-many-pages', 'error', '全文', `共 ${pages} 页,超出${profile.label}答辩骨架的 ${minPages}-${maxPages} 页;删页而不是压缩讲述。`);
50
+ }
51
+ if (options.targetPages !== undefined && pages !== options.targetPages) {
52
+ push('deck-page-mismatch', 'warn', '全文', `页数 ${pages} 与目标 ${options.targetPages} 页不一致。`);
53
+ }
54
+ // 2. frontmatter
55
+ if (deck.theme === '')
56
+ push('deck-theme-missing', 'warn', '全文', 'frontmatter 缺少 `theme:`,Marp 会退回默认主题。');
57
+ // 3-6. 逐页
58
+ for (const slide of deck.slides) {
59
+ checkSlide(slide, push);
60
+ }
61
+ // 7. 时长
62
+ const minMinutes = Math.max(1, Math.round((pages * profile.secondsMin) / 60));
63
+ const maxMinutes = Math.max(1, Math.round((pages * profile.secondsMax) / 60));
64
+ if (pages > 0 && maxMinutes > DURATION_MAX_MINUTES) {
65
+ push('time-over', 'error', '全文', `预计时长 ${minMinutes}-${maxMinutes} 分钟,超出 ${DURATION_MIN_MINUTES}-${DURATION_MAX_MINUTES} 分钟的答辩窗口;删页或压缩每页讲述内容(每页 ${profile.secondsMin}-${profile.secondsMax} 秒)。`);
66
+ }
67
+ else if (pages > 0 && minMinutes < DURATION_MIN_MINUTES) {
68
+ push('time-under', 'warn', '全文', `预计时长 ${minMinutes}-${maxMinutes} 分钟,短于 ${DURATION_MIN_MINUTES} 分钟;老师一般要求讲满,建议把要点讲透或补一页实现细节。`);
69
+ }
70
+ const errors = issues.filter(i => i.level === 'error').length;
71
+ const warnings = issues.filter(i => i.level === 'warn').length;
72
+ return {
73
+ pages,
74
+ estimate: {
75
+ minMinutes,
76
+ maxMinutes,
77
+ secondsPerSlideMin: profile.secondsMin,
78
+ secondsPerSlideMax: profile.secondsMax,
79
+ },
80
+ issues,
81
+ errors,
82
+ warnings,
83
+ ok: errors === 0,
84
+ };
85
+ }
86
+ function checkSlide(slide, push) {
87
+ const where = `第 ${slide.index} 页「${slide.title}」`;
88
+ const titleChars = textLength(slide.title);
89
+ // 封面/致谢页上的姓名、日期等占位符属正常(提交前替换),其余页面的占位符是必须修的问题。
90
+ const placeholderTolerated = slide.index === 1 || /^(封面|致谢|致谢页|问答页|目录)$/.test(slide.title.trim());
91
+ if (titleChars > MAX_TITLE_CHARS) {
92
+ push('title-too-long', 'warn', where, `标题 ${titleChars} 字,超过 ${MAX_TITLE_CHARS} 字上限,会换行挤压版面。`);
93
+ }
94
+ if (slide.bullets.length < MIN_BULLETS_PER_SLIDE) {
95
+ push('too-few-bullets', 'warn', where, `只有 ${slide.bullets.length} 条要点,建议 ${MIN_BULLETS_PER_SLIDE}-${MAX_BULLETS_PER_SLIDE} 条(一页一个观点)。`);
96
+ }
97
+ if (slide.bullets.length > MAX_BULLETS_PER_SLIDE) {
98
+ push('too-many-bullets', 'error', where, `${slide.bullets.length} 条要点,超出每页 ${MAX_BULLETS_PER_SLIDE} 条上限。`);
99
+ }
100
+ for (const [i, bullet] of slide.bullets.entries()) {
101
+ if (textLength(bullet.text) > MAX_BULLET_CHARS) {
102
+ push('bullet-too-long', 'error', where, `第 ${i + 1} 条要点 ${textLength(bullet.text)} 字,超出 ${MAX_BULLET_CHARS} 字上限:${bullet.text.slice(0, 40)}…`);
103
+ }
104
+ if (bullet.truncated) {
105
+ push('bullet-truncated', 'warn', where, `第 ${i + 1} 条要点在生成时被自动截断(标记「已截断」),请人工确认语义是否完整。`);
106
+ }
107
+ for (const { pattern, label } of PLACEHOLDER_PATTERNS) {
108
+ if (pattern.test(bullet.text)) {
109
+ const informational = placeholderTolerated && (label === '待填占位' || label === '<> 模板占位');
110
+ push('placeholder', informational ? 'warn' : 'error', where, `第 ${i + 1} 条要点含未完成标记「${label}」:${bullet.text}${informational ? '(封面/致谢页的占位符属正常,提交前替换)' : ''}`);
111
+ }
112
+ }
113
+ if (bullet.anchor === undefined || bullet.anchor.trim() === '') {
114
+ push('missing-anchor', 'error', where, `第 ${i + 1} 条要点没有证据锚点(应指向真实文件/章节/数据,如 06-论文/章节/04-系统设计.md#架构)。`);
115
+ }
116
+ else if (/^材料|^见论文|^结论/.test(bullet.anchor.trim())) {
117
+ push('vague-anchor', 'warn', where, `第 ${i + 1} 条要点的锚点过于笼统:${bullet.anchor}`);
118
+ }
119
+ }
120
+ if (slide.note === undefined || slide.note.trim() === '') {
121
+ push('missing-note', 'warn', where, '缺少讲稿要点(生成时应写入 `<!-- 讲稿:… -->` 注释,Marp 演示者备注用)。');
122
+ }
123
+ }
124
+ /**
125
+ * 证据锚点落盘校验(可选):锚点形如 `相对路径#小节`,只校验路径部分是否存在。
126
+ * 缺失记为 warn——素材尚未生成时会大量缺失,属于正常提示而非错误。
127
+ */
128
+ export async function verifyAnchors(markdown, fs, root) {
129
+ const deck = parseMarp(markdown);
130
+ const issues = [];
131
+ const checked = new Map();
132
+ for (const slide of deck.slides) {
133
+ for (const [i, bullet] of slide.bullets.entries()) {
134
+ if (bullet.anchor === undefined || bullet.anchor.trim() === '')
135
+ continue;
136
+ const rel = (bullet.anchor.split('#')[0] ?? '').trim();
137
+ if (rel === '' || /^(git log|见论文|材料)/.test(rel))
138
+ continue;
139
+ let exists = checked.get(rel);
140
+ if (exists === undefined) {
141
+ exists = await fileExists(fs, joinPath(root, rel));
142
+ checked.set(rel, exists);
143
+ }
144
+ if (!exists) {
145
+ issues.push({
146
+ code: 'anchor-file-missing',
147
+ level: 'warn',
148
+ where: `第 ${slide.index} 页「${slide.title}」`,
149
+ message: `第 ${i + 1} 条要点的证据锚点指向的文件不存在:${rel}(素材未生成或路径已变;答辩前请补齐真实证据)`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ return issues;
155
+ }
156
+ function joinPath(root, rel) {
157
+ return nodePath.join(root, rel.replace(/^[\\/]+/, ''));
158
+ }
159
+ async function fileExists(fs, absPath) {
160
+ try {
161
+ await fs.readText(await fs.resolve(absPath), undefined);
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ /** 检查结果的文本渲染(有界;工具返回值用)。 */
169
+ export function renderCheckReport(result, deckRelPath) {
170
+ const lines = [];
171
+ lines.push(`幻灯质量检查:${deckRelPath}`);
172
+ lines.push(`- 页数:${result.pages} 页`);
173
+ lines.push(`- 预计时长:${result.estimate.minMinutes}-${result.estimate.maxMinutes} 分钟(每页 ${result.estimate.secondsPerSlideMin}-${result.estimate.secondsPerSlideMax} 秒)`);
174
+ lines.push(`- 结论:${result.ok ? '通过(无 error)' : `${result.errors} 条必须修、${result.warnings} 条建议修`}`);
175
+ if (result.issues.length === 0) {
176
+ lines.push('- 未发现问题。');
177
+ return lines.join('\n');
178
+ }
179
+ lines.push('');
180
+ lines.push('问题清单(逐条定位):');
181
+ for (const issue of result.issues) {
182
+ lines.push(`- [${issue.level === 'error' ? '必须修' : '建议修'}] ${issue.where} · ${issue.code}:${issue.message}`);
183
+ }
184
+ return lines.join('\n');
185
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 斜杠命令 `/thesis-defense` 的**幻灯侧**输入解析。
3
+ *
4
+ * 命令名遵循 DSH 的 `[a-z][a-z0-9_-]*` 约束(kebab-case)。命令注册本身在
5
+ * 装配层(`src/index.ts`)完成:`/thesis-defense` 同时覆盖答辩幻灯与代码演练,
6
+ * 由装配层聚合两侧的处理器——这样 `ppt/` 与 `codewalk/` 之间没有反向依赖。
7
+ *
8
+ * @module dsh-thesis/ppt
9
+ */
10
+ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands';
11
+ import type { FileSystem } from '@deepseek-ai/dsh-fs';
12
+ import type { PptOptions } from '../config.ts';
13
+ import { type PptArgs, type PptDeps } from './actions.ts';
14
+ /** 幻灯侧的子命令(与代码演练侧区分)。 */
15
+ export declare const PPT_SUBCOMMANDS: readonly string[];
16
+ /** 把 `/thesis-defense <输入>` 的幻灯侧部分解析成工具参数。 */
17
+ export declare function parsePptCommandInput(rawInput: string): PptArgs;
18
+ /**
19
+ * 幻灯侧的命令处理器(由装配层在 `/thesis-defense` 下调度)。
20
+ *
21
+ * `rawInput` 仍是命令名之后的全部输入;本函数只关心幻灯侧子命令。
22
+ */
23
+ export declare function handlePptCommand(fs: FileSystem, options: {
24
+ ppt: PptOptions;
25
+ }, invocation: CommandInvocation, deps?: PptDeps): Promise<CommandResult>;
26
+ /** 命令的展示元信息(装配层注册时使用)。 */
27
+ export declare const PPT_COMMAND_DESCRIPTION = "\u7B54\u8FA9\u5E7B\u706F\uFF1A\u751F\u6210 Marp Markdown\uFF08prepare/outline\uFF09/ \u6267\u884C\u5916\u90E8\u8F6C\u6362\uFF08convert\uFF09/ \u8D28\u91CF\u68C0\u67E5\uFF08check\uFF09/ \u8F6C\u6362\u6307\u5F15\uFF08guide\uFF09";
28
+ export declare const PPT_COMMAND_HINT = "prepare | convert | check | guide [\u9875\u6570] [undergrad|master] [force]";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 斜杠命令 `/thesis-defense` 的**幻灯侧**输入解析。
3
+ *
4
+ * 命令名遵循 DSH 的 `[a-z][a-z0-9_-]*` 约束(kebab-case)。命令注册本身在
5
+ * 装配层(`src/index.ts`)完成:`/thesis-defense` 同时覆盖答辩幻灯与代码演练,
6
+ * 由装配层聚合两侧的处理器——这样 `ppt/` 与 `codewalk/` 之间没有反向依赖。
7
+ *
8
+ * @module dsh-thesis/ppt
9
+ */
10
+ import { runPptAction } from "./actions.js";
11
+ async function safe(work) {
12
+ try {
13
+ return { kind: 'success', text: await work() };
14
+ }
15
+ catch (error) {
16
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
17
+ }
18
+ }
19
+ /** 幻灯侧的子命令(与代码演练侧区分)。 */
20
+ export const PPT_SUBCOMMANDS = ['prepare', 'convert', 'check', 'guide'];
21
+ /** 把 `/thesis-defense <输入>` 的幻灯侧部分解析成工具参数。 */
22
+ export function parsePptCommandInput(rawInput) {
23
+ const tokens = rawInput.trim().split(/\s+/).filter(t => t !== '');
24
+ let action = 'outline';
25
+ let pages;
26
+ let audience;
27
+ let force = false;
28
+ for (const token of tokens) {
29
+ const lowered = token.toLowerCase();
30
+ if (lowered === 'prepare' || lowered === 'outline' || lowered === 'convert' || lowered === 'check' || lowered === 'guide') {
31
+ action = lowered === 'prepare' ? 'outline' : lowered;
32
+ continue;
33
+ }
34
+ if (lowered === 'undergrad' || lowered === 'master') {
35
+ audience = lowered;
36
+ continue;
37
+ }
38
+ if (lowered === 'force' || lowered === '--force' || lowered === '-f') {
39
+ force = true;
40
+ continue;
41
+ }
42
+ const number = Number.parseInt(token, 10);
43
+ if (Number.isFinite(number)) {
44
+ pages = number;
45
+ continue;
46
+ }
47
+ throw new Error(`无法识别的参数「${token}」。用法:/thesis-defense prepare [页数] [undergrad|master] [force]`
48
+ + ' | convert | check | guide | new <原始项目路径> | status | check [里程碑]');
49
+ }
50
+ return {
51
+ action,
52
+ ...(pages !== undefined ? { pages } : {}),
53
+ ...(audience !== undefined ? { audience } : {}),
54
+ ...(force ? { force: true } : {}),
55
+ };
56
+ }
57
+ /**
58
+ * 幻灯侧的命令处理器(由装配层在 `/thesis-defense` 下调度)。
59
+ *
60
+ * `rawInput` 仍是命令名之后的全部输入;本函数只关心幻灯侧子命令。
61
+ */
62
+ export async function handlePptCommand(fs, options, invocation, deps = {}) {
63
+ return await safe(async () => {
64
+ const args = parsePptCommandInput(invocation.rawInput);
65
+ const outcome = await runPptAction(fs, invocation.agent.session.header.cwd, args, options, invocation.signal, deps);
66
+ return outcome.text;
67
+ });
68
+ }
69
+ /** 命令的展示元信息(装配层注册时使用)。 */
70
+ export const PPT_COMMAND_DESCRIPTION = '答辩幻灯:生成 Marp Markdown(prepare/outline)/ 执行外部转换(convert)/ 质量检查(check)/ 转换指引(guide)';
71
+ export const PPT_COMMAND_HINT = 'prepare | convert | check | guide [页数] [undergrad|master] [force]';