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,695 @@
1
+ /**
2
+ * 答辩幻灯计划(`thesis_slides action=outline` 的核心)。
3
+ *
4
+ * 产品决策:**不自研 pptx 生成器**。本模块把真实素材变成结构化的《幻灯片计划》
5
+ * (SlidePlan),由 `marp.ts` 渲染为标准 Marp Markdown,再交给外部转换器
6
+ * (marp-cli / pandoc)成稿。计划里的每一条要点都带**证据锚点**,指向工作区里
7
+ * 真实存在的文件/章节/数据——这是"零编造"红线在答辩环节的落地。
8
+ *
9
+ * 两条输入路径:
10
+ * 1. `07-答辩/答辩素材.md`(由 `thesis_defense` 生成)——优先;
11
+ * 2. 素材不存在时回退到 `06-论文/章节/*.md` 的标题与要点,并在结果里标注
12
+ * "素材未生成,建议先跑 thesis_defense",**不编造系统细节**。
13
+ *
14
+ * @module dsh-thesis/ppt
15
+ */
16
+ import * as nodePath from 'node:path';
17
+ import { CHAPTER_META } from "../paper/lib/layout.js";
18
+ /** 单条要点的硬上限(字符):超出自动截断并标记。 */
19
+ export const MAX_BULLET_CHARS = 30;
20
+ /** 单页标题的硬上限(字符)。 */
21
+ export const MAX_TITLE_CHARS = 20;
22
+ /** 单页要点条数区间。 */
23
+ export const MIN_BULLETS_PER_SLIDE = 3;
24
+ export const MAX_BULLETS_PER_SLIDE = 6;
25
+ /** 每页预计讲述秒数区间(本科)。 */
26
+ export const SECONDS_PER_SLIDE_MIN = 40;
27
+ export const SECONDS_PER_SLIDE_MAX = 60;
28
+ /** 系统实现页的证据锚点根目录(真实代码位置)。 */
29
+ export const CODE_ROOT = '04-实现';
30
+ /** 实验结果的证据锚点根目录。 */
31
+ export const RESULT_ROOT = '05-实验测试/结果';
32
+ // ---------------------------------------------------------------------------
33
+ // 纯解析器
34
+ // ---------------------------------------------------------------------------
35
+ /** 去掉 Markdown 修饰(粗体、行内代码、列表符号),得到纯文本。 */
36
+ export function stripMarkdownInline(text) {
37
+ return text
38
+ .replace(/^\s*(?:[-*+]|\d+[.、)])\s+/, '')
39
+ .replace(/\*\*(.+?)\*\*/g, '$1')
40
+ .replace(/`(.+?)`/g, '$1')
41
+ .trim();
42
+ }
43
+ /** 中文字符数(用于判断素材章节是否"已撰写")。 */
44
+ export function cjkCount(text) {
45
+ return (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
46
+ }
47
+ /**
48
+ * 截断单条要点:`limit` 字以内原样返回;超出则截到 `limit - 1` 并补省略号,
49
+ * 保证总长不超过 limit,并把 `truncated` 标记为 true(原文保留在 `original`)。
50
+ */
51
+ export function truncateBullet(text, limit = MAX_BULLET_CHARS) {
52
+ const clean = text.replace(/\s+/g, ' ').trim();
53
+ if (clean.length <= limit)
54
+ return { text: clean, truncated: false };
55
+ const head = clean.slice(0, Math.max(1, limit - 1)).trimEnd();
56
+ return { text: `${head}…`, truncated: true, original: clean };
57
+ }
58
+ /** 标题超长同样截断(20 字硬上限)。 */
59
+ export function truncateTitle(title, limit = MAX_TITLE_CHARS) {
60
+ const clean = title.replace(/\s+/g, ' ').trim();
61
+ if (clean.length <= limit)
62
+ return clean;
63
+ return `${clean.slice(0, Math.max(1, limit - 1)).trimEnd()}…`;
64
+ }
65
+ /**
66
+ * 解析 `07-答辩/答辩素材.md`(thesis_defense 的产物)。
67
+ *
68
+ * 容错原则:任何一段缺失都只是"该段没有事实",绝不用默认值伪造内容。
69
+ */
70
+ export function parseDefenseMaterials(markdown) {
71
+ const lines = markdown.split(/\r?\n/);
72
+ let title = '';
73
+ const chapters = [];
74
+ const techChoices = [];
75
+ const decisions = [];
76
+ const resultFiles = [];
77
+ let testCases = 0;
78
+ let gitCommits;
79
+ let bibCount;
80
+ let section = '';
81
+ let current;
82
+ for (const raw of lines) {
83
+ const line = raw.trim();
84
+ if (line.startsWith('## ')) {
85
+ section = line.slice(3).trim();
86
+ current = undefined;
87
+ continue;
88
+ }
89
+ if (line.startsWith('### ')) {
90
+ // 章节概览的小节标题形如:`### 第 N 章 标题(M 字,引用 K 处)`
91
+ if (section.startsWith('章节概览')) {
92
+ const heading = line.slice(4).trim();
93
+ const matched = /^第\s*(\S+)\s*章\s*(.*?)(?:[((].*)?$/.exec(heading);
94
+ const no = matched?.[1] ?? '';
95
+ const name = (matched?.[2] ?? heading).trim();
96
+ current = { file: no === '' ? name : `第 ${no} 章`, title: name, headings: [] };
97
+ chapters.push(current);
98
+ }
99
+ else {
100
+ current = undefined;
101
+ }
102
+ continue;
103
+ }
104
+ if (line === '' || line.startsWith('>'))
105
+ continue;
106
+ if (section === '课题') {
107
+ const plain = stripMarkdownInline(line);
108
+ if (plain !== '' && title === '')
109
+ title = plain;
110
+ continue;
111
+ }
112
+ if (section.startsWith('章节概览')) {
113
+ if (current !== undefined && line.startsWith('- ')) {
114
+ const bullet = stripMarkdownInline(line);
115
+ if (bullet !== '')
116
+ current.headings.push(bullet);
117
+ }
118
+ continue;
119
+ }
120
+ if (section.startsWith('技术选型')) {
121
+ if (!line.startsWith('- '))
122
+ continue;
123
+ const body = stripMarkdownInline(line);
124
+ const parts = body.split(/[::]/);
125
+ const item = parts[0]?.trim() ?? '';
126
+ const choice = (parts[1] ?? '').split(/[((]/)[0]?.trim() ?? '';
127
+ const reasonMatch = /理由[::]\s*([^;;]*)/.exec(body);
128
+ const altMatch = /备选[::]\s*([^;;]*)/.exec(body);
129
+ if (item !== '') {
130
+ techChoices.push({
131
+ item,
132
+ choice,
133
+ reason: (reasonMatch?.[1] ?? '').trim(),
134
+ alternative: (altMatch?.[1] ?? '').trim(),
135
+ });
136
+ }
137
+ continue;
138
+ }
139
+ if (section.startsWith('关键决定')) {
140
+ if (line.startsWith('- '))
141
+ decisions.push(stripMarkdownInline(line));
142
+ continue;
143
+ }
144
+ if (section.startsWith('测试与数据')) {
145
+ const cases = /测试用例[::]\s*(\d+)/.exec(line);
146
+ if (cases?.[1] !== undefined)
147
+ testCases = Number.parseInt(cases[1], 10);
148
+ const results = /结果文件[::]\s*(.*)$/.exec(line);
149
+ if (results?.[1] !== undefined) {
150
+ const value = results[1].trim();
151
+ if (value !== '' && !value.startsWith('(')) {
152
+ for (const name of value.split(/[、,,]/)) {
153
+ const clean = name.trim();
154
+ if (clean !== '')
155
+ resultFiles.push(clean);
156
+ }
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+ if (section.startsWith('工作量')) {
162
+ const commits = /提交[::]\s*(\d+)/.exec(line);
163
+ if (commits?.[1] !== undefined)
164
+ gitCommits = Number.parseInt(commits[1], 10);
165
+ continue;
166
+ }
167
+ if (section.startsWith('文献')) {
168
+ const bib = /收录\s*(\d+)\s*条/.exec(line);
169
+ if (bib?.[1] !== undefined)
170
+ bibCount = Number.parseInt(bib[1], 10);
171
+ continue;
172
+ }
173
+ }
174
+ return {
175
+ title,
176
+ chapters,
177
+ techChoices,
178
+ decisions,
179
+ testCases,
180
+ resultFiles,
181
+ ...(gitCommits !== undefined ? { gitCommits } : {}),
182
+ ...(bibCount !== undefined ? { bibCount } : {}),
183
+ };
184
+ }
185
+ /**
186
+ * 回退解析:从 `06-论文/章节/*.md` 提取章节标题与该章二三级小节标题。
187
+ *
188
+ * 这是"素材未生成"时的降级路径——只搬运论文章节里**已经写好**的标题,
189
+ * 不推断、不补全任何系统细节。
190
+ */
191
+ export function parseChapterFallback(files) {
192
+ const digests = [];
193
+ for (const file of files) {
194
+ const stem = file.name.replace(/\.md$/i, '');
195
+ let title = stem;
196
+ const headings = [];
197
+ for (const raw of file.text.split(/\r?\n/)) {
198
+ const line = raw.trim();
199
+ const h1 = /^#\s+(.+)$/.exec(line);
200
+ if (h1?.[1] !== undefined && title === stem) {
201
+ title = stripMarkdownInline(h1[1]).replace(/^第\s*\S+\s*章\s*/, '');
202
+ continue;
203
+ }
204
+ const h2 = /^#{2,3}\s+(.+)$/.exec(line);
205
+ if (h2?.[1] !== undefined)
206
+ headings.push(stripMarkdownInline(h2[1]));
207
+ }
208
+ digests.push({ file: stem, title, headings });
209
+ }
210
+ return digests;
211
+ }
212
+ const UNDERGRAD_PAGES = [10, 12];
213
+ const MASTER_PAGES = [12, 16];
214
+ function clamp(value, min, max) {
215
+ if (value < min)
216
+ return min;
217
+ if (value > max)
218
+ return max;
219
+ return value;
220
+ }
221
+ function bulletsOrFallback(candidates, anchor) {
222
+ if (candidates.length > 0)
223
+ return [...candidates];
224
+ return [
225
+ { text: '本页素材尚未生成', anchor },
226
+ { text: '先跑 thesis_defense 再刷新', anchor },
227
+ { text: '禁止编造系统细节与数据', anchor },
228
+ ];
229
+ }
230
+ /**
231
+ * 《答辩素材》的章节概览只记"第 N 章",这里把它映射回真实章节文件名
232
+ * (`06-论文/章节/04-系统设计.md`),使证据锚点落在**真实文件**上而不是虚构路径。
233
+ */
234
+ function chapterFileOf(chapter) {
235
+ const direct = `${chapter.file}.md`;
236
+ if (CHAPTER_META.some(meta => direct === `${meta.file}.md`))
237
+ return direct;
238
+ const no = /第?\s*(\d+)/.exec(chapter.file)?.[1];
239
+ const index = no !== undefined ? Number.parseInt(no, 10) - 1 : -1;
240
+ const meta = CHAPTER_META[index];
241
+ return meta !== undefined ? `${meta.file}.md` : `${chapter.file}.md`;
242
+ }
243
+ /** 按章标题关键词取该章的小节标题作为要点(素材里没有就返回空)。 */
244
+ function chapterBullets(materials, keyword, limit = 5) {
245
+ const out = [];
246
+ for (const chapter of materials.chapters) {
247
+ if (!keyword.test(chapter.title))
248
+ continue;
249
+ for (const heading of chapter.headings) {
250
+ if (out.length >= limit)
251
+ break;
252
+ out.push({ text: heading, anchor: `06-论文/章节/${chapterFileOf(chapter)}` });
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+ const SEGMENTS = [
258
+ {
259
+ id: 'background',
260
+ expandable: true,
261
+ page: ({ planTitle, materials }) => {
262
+ const bullets = [
263
+ { text: `课题来源:${planTitle}`, anchor: '00-管理/选题/选题确认书.md' },
264
+ ];
265
+ bullets.push(...chapterBullets(materials, /绪论/, 5));
266
+ if (bullets.length < 3) {
267
+ bullets.push({ text: '行业/场景的真正痛点是什么', anchor: '06-论文/章节/01-绪论.md#1.1' }, { text: '现有做法为什么不够用', anchor: '06-论文/章节/01-绪论.md#1.1' });
268
+ }
269
+ return {
270
+ kind: 'background',
271
+ title: '选题背景与意义',
272
+ bullets: bullets.slice(0, MAX_BULLETS_PER_SLIDE),
273
+ note: '先讲痛点再讲意义:一句话说明谁在什么场景下遇到什么问题;本页数据/结论必须能追到论文绪论。',
274
+ };
275
+ },
276
+ },
277
+ {
278
+ id: 'status',
279
+ expandable: true,
280
+ page: ({ materials }) => ({
281
+ kind: 'status',
282
+ title: '国内外研究现状',
283
+ bullets: bulletsOrFallback(chapterBullets(materials, /绪论|相关技术/, 5), '06-论文/章节/01-绪论.md#1.2'),
284
+ note: '一图带过:按主题讲 2-3 条主流路线,落点是"现有方案的不足 → 本课题要补的空白";引用编号与论文一致。',
285
+ }),
286
+ },
287
+ {
288
+ id: 'route',
289
+ expandable: false,
290
+ page: () => ({
291
+ kind: 'route',
292
+ title: '研究内容与技术路线',
293
+ bullets: [
294
+ { text: '功能需求 FR 清单(见论文)', anchor: '03-设计/需求分析.md' },
295
+ { text: '技术路线:分步实现(见开题报告)', anchor: '01-开题/开题报告.md#4' },
296
+ { text: '关键难点与对应解法', anchor: '00-管理/决定日志.md' },
297
+ ],
298
+ note: '讲清"要做什么、怎么做":2-3 条研究内容各一句目标,再给技术路线步骤图(与开题报告第 4 节一致)。',
299
+ }),
300
+ },
301
+ {
302
+ id: 'design',
303
+ expandable: true,
304
+ page: ({ materials, part, total }) => {
305
+ const tables = [
306
+ {
307
+ title: '系统设计:总体架构',
308
+ bullets: [
309
+ { text: '分层架构与部署形态', anchor: '03-设计/系统设计.md#1' },
310
+ { text: '架构图与论文图一致', anchor: '03-设计/系统设计.md#1' },
311
+ { text: '关键模块划分与职责', anchor: '03-设计/系统设计.md#2' },
312
+ { text: '模块间接口与数据流', anchor: '03-设计/系统设计.md#4' },
313
+ ],
314
+ note: '对着架构图讲一遍数据流:请求从哪进、经过哪些模块、落到哪些表;老师最容易在这里追问模块边界。',
315
+ },
316
+ {
317
+ title: '系统设计:模块与数据库',
318
+ bullets: [
319
+ { text: '核心表结构与关系(ER)', anchor: '03-设计/系统设计.md#3' },
320
+ { text: '接口设计:路径/参数/返回', anchor: '03-设计/系统设计.md#4' },
321
+ { text: '非功能需求如何落地', anchor: '03-设计/需求分析.md#3' },
322
+ { text: '选型理由一句话结论', anchor: '03-设计/技术选型论证.md' },
323
+ ],
324
+ note: '数据库页只讲 2-3 张关键表和它们的关系;非功能需求(性能/安全)各给一条已落地的措施。',
325
+ },
326
+ ];
327
+ const page = tables[Math.min(part, tables.length - 1)];
328
+ const bullets = [...page.bullets];
329
+ if (materials.techChoices.length > 0 && part === total - 1) {
330
+ const first = materials.techChoices[0];
331
+ bullets.unshift({
332
+ text: `${first.item}选型:${first.choice}`,
333
+ anchor: '03-设计/技术选型论证.md',
334
+ });
335
+ }
336
+ return { kind: 'design', title: page.title, bullets: bullets.slice(0, MAX_BULLETS_PER_SLIDE), note: page.note };
337
+ },
338
+ },
339
+ {
340
+ id: 'impl',
341
+ expandable: true,
342
+ page: ({ materials, part, total, source, planTitle }) => {
343
+ const implBullets = chapterBullets(materials, /实现/, 12);
344
+ const moduleBullet = implBullets[part];
345
+ const title = total === 1 ? '系统实现' : (part === 0 ? '系统实现:总体' : `系统实现:模块 ${part}`);
346
+ if (source === 'chapters') {
347
+ const bullets = [
348
+ { text: `开发环境与关键技术:${planTitle}`, anchor: '06-论文/章节/05-系统实现.md#5.1' },
349
+ ...(moduleBullet !== undefined ? [moduleBullet] : []),
350
+ { text: '真实代码文件与关键函数', anchor: '06-论文/章节/05-系统实现.md' },
351
+ { text: '关键设计取舍与理由', anchor: '00-管理/决定日志.md' },
352
+ { text: '真实界面/接口截图', anchor: RESULT_ROOT },
353
+ ];
354
+ return {
355
+ kind: 'impl',
356
+ title,
357
+ bullets: bulletsOrFallback(bullets.slice(0, MAX_BULLETS_PER_SLIDE), '06-论文/章节/05-系统实现.md'),
358
+ note: '素材未生成时以论文"系统实现"章为依据;每页一个模块,讲"为什么这么写",并准备好真实代码文件路径。',
359
+ };
360
+ }
361
+ const bullets = [
362
+ {
363
+ text: moduleBullet !== undefined ? `实现要点:${moduleBullet.text}` : '核心流程:从入口到落库',
364
+ anchor: moduleBullet?.anchor ?? CODE_ROOT,
365
+ },
366
+ { text: '真实代码文件与关键函数', anchor: CODE_ROOT },
367
+ { text: '关键设计取舍与理由', anchor: '00-管理/决定日志.md' },
368
+ { text: '真实界面/接口截图', anchor: RESULT_ROOT },
369
+ ];
370
+ return {
371
+ kind: 'impl',
372
+ title,
373
+ bullets: bullets.slice(0, MAX_BULLETS_PER_SLIDE),
374
+ note: '这是老师看得最细的部分:每页一个模块,按"入口 → 关键函数 → 数据表"讲;代码片段必须来自 04-实现 的真实文件,现场能打开。',
375
+ };
376
+ },
377
+ },
378
+ {
379
+ id: 'test',
380
+ expandable: true,
381
+ page: ({ materials }) => {
382
+ const result = materials.resultFiles[0];
383
+ const bullets = [
384
+ { text: `测试用例 ${materials.testCases > 0 ? `${materials.testCases} 个` : '见测试计划'}`, anchor: '05-实验测试/测试计划.md' },
385
+ { text: '测试环境与复现步骤', anchor: '05-实验测试/测试计划.md' },
386
+ ];
387
+ if (result !== undefined) {
388
+ bullets.push({ text: `真实结果:${result}`, anchor: `${RESULT_ROOT}/${result}` });
389
+ }
390
+ else {
391
+ bullets.push({ text: '真实结果文件(结果/ 目录)', anchor: RESULT_ROOT });
392
+ }
393
+ bullets.push({ text: '失败用例与修复记录', anchor: '05-实验测试/测试计划.md' });
394
+ return {
395
+ kind: 'test',
396
+ title: '实验与测试',
397
+ bullets: bullets.slice(0, MAX_BULLETS_PER_SLIDE),
398
+ note: '只讲真实跑出来的数据:测试环境、用例数、关键指标图表;准备好"这些数据怎么复现"的回答。',
399
+ };
400
+ },
401
+ },
402
+ {
403
+ id: 'conclusion',
404
+ expandable: false,
405
+ page: ({ materials }) => ({
406
+ kind: 'conclusion',
407
+ title: '结论与展望',
408
+ bullets: [
409
+ { text: '系统已完成的核心功能', anchor: '06-论文/章节/07-总结与展望.md#7.1' },
410
+ { text: '达到的设计目标与指标', anchor: '06-论文/章节/06-系统测试.md' },
411
+ { text: '后续可扩展方向', anchor: '06-论文/章节/07-总结与展望.md#7.2' },
412
+ ...(materials.gitCommits !== undefined ? [{ text: `开发过程 ${materials.gitCommits} 次提交`, anchor: 'git log' }] : []),
413
+ ],
414
+ note: '结论要短:已完成什么、达到什么指标、下一步做什么;与绪论"本文主要工作"逐条呼应。',
415
+ }),
416
+ },
417
+ ];
418
+ const FIXED_HEAD = [
419
+ ({ planTitle }) => ({
420
+ kind: 'cover',
421
+ title: '封面',
422
+ bullets: [
423
+ { text: planTitle, anchor: '00-管理/进度台账.md' },
424
+ { text: '姓名:___(待填)', anchor: '00-管理/选题/选题确认书.md' },
425
+ { text: '学号:___(待填)', anchor: '00-管理/选题/选题确认书.md' },
426
+ { text: '指导教师:___(待填)', anchor: '01-开题/任务书.md' },
427
+ { text: '答辩日期:____(待填)', anchor: '00-管理/时间线.md' },
428
+ ],
429
+ note: '开场 20 秒:自报课题与姓名,一句话说清课题做什么;占位符(姓名/学号/导师/日期)必须由本人替换。',
430
+ }),
431
+ () => ({
432
+ kind: 'toc',
433
+ title: '目录',
434
+ bullets: [
435
+ { text: '背景与现状', anchor: '01-开题/开题报告.md' },
436
+ { text: '设计、实现与测试', anchor: '06-论文/大纲.md' },
437
+ { text: '结论与创新点', anchor: '06-论文/章节/07-总结与展望.md' },
438
+ ],
439
+ note: '10 秒带过,只报章节顺序,不展开;顺序必须与后面的讲述顺序一致。',
440
+ }),
441
+ ];
442
+ const FIXED_TAIL = [
443
+ ({ materials }) => ({
444
+ kind: 'innovation',
445
+ title: '创新点与不足',
446
+ bullets: [
447
+ { text: '自评创新点:结合绪论本文工作', anchor: '06-论文/章节/01-绪论.md#1.3' },
448
+ { text: '不足 1:诚实说明边界', anchor: '06-论文/章节/07-总结与展望.md#7.2' },
449
+ { text: '不足 2 与改进方向', anchor: '06-论文/章节/07-总结与展望.md#7.2' },
450
+ ...(materials.techChoices.length > 0
451
+ ? [{ text: `选型证据:${materials.techChoices[0].item}`, anchor: '03-设计/技术选型论证.md' }]
452
+ : []),
453
+ ],
454
+ note: '创新点讲"相对现有方案的差异",不足讲 2 条真实的并各给改进方向;不要用"时间有限"敷衍。',
455
+ }),
456
+ () => ({
457
+ kind: 'thanks',
458
+ title: '致谢',
459
+ bullets: [
460
+ { text: '感谢指导教师与评阅老师', anchor: '06-论文/章节/07-总结与展望.md#致谢' },
461
+ { text: '感谢实验室/同学的支持', anchor: '06-论文/章节/07-总结与展望.md#致谢' },
462
+ { text: '请各位老师批评指正', anchor: '06-论文/章节/07-总结与展望.md#致谢' },
463
+ ],
464
+ note: '固定收尾话术:"我的汇报到此结束,谢谢各位老师,请批评指正。"留出提问时间。',
465
+ }),
466
+ ];
467
+ const SEGMENT_BY_ID = {
468
+ background: SEGMENTS[0],
469
+ status: SEGMENTS[1],
470
+ route: SEGMENTS[2],
471
+ design: SEGMENTS[3],
472
+ impl: SEGMENTS[4],
473
+ test: SEGMENTS[5],
474
+ conclusion: SEGMENTS[6],
475
+ };
476
+ /**
477
+ * 按目标页数分配各段落页数:固定头(封面、目录)与固定尾(创新点、致谢)各 1 页,
478
+ * 中间的 7 个段落至少各 1 页,多出来的页数按可展开顺序补 2 页以上的段落。
479
+ */
480
+ export function composeSlides(targetPages) {
481
+ const fixed = 4;
482
+ const middleIds = SEGMENTS.map(s => s.id);
483
+ const pages = clamp(Math.round(targetPages), fixed + middleIds.length, 20);
484
+ const expandable = SEGMENTS.filter(s => s.expandable).map(s => s.id);
485
+ const counts = new Map();
486
+ for (const id of middleIds)
487
+ counts.set(id, 1);
488
+ let remaining = pages - fixed - middleIds.length;
489
+ let cursor = 0;
490
+ while (remaining > 0) {
491
+ const id = expandable[cursor % expandable.length];
492
+ counts.set(id, (counts.get(id) ?? 1) + 1);
493
+ remaining -= 1;
494
+ cursor += 1;
495
+ }
496
+ const ids = [];
497
+ const parts = { background: 0, status: 0, route: 0, design: 0, impl: 0, test: 0, conclusion: 0 };
498
+ const seen = new Map();
499
+ for (const id of middleIds) {
500
+ const count = counts.get(id) ?? 1;
501
+ for (let i = 0; i < count; i += 1) {
502
+ const n = seen.get(id) ?? 0;
503
+ parts[id] = n;
504
+ seen.set(id, n + 1);
505
+ ids.push(id);
506
+ }
507
+ }
508
+ return { ids, parts };
509
+ }
510
+ function toSlide(index, spec) {
511
+ const bullets = spec.bullets.map(b => {
512
+ const cut = truncateBullet(b.text);
513
+ const base = {
514
+ text: cut.text,
515
+ truncated: cut.truncated,
516
+ ...(b.anchor !== undefined ? { anchor: b.anchor } : {}),
517
+ };
518
+ return cut.truncated && cut.original !== undefined ? { ...base, original: cut.original } : base;
519
+ });
520
+ return {
521
+ index,
522
+ kind: spec.kind,
523
+ title: truncateTitle(spec.title),
524
+ bullets,
525
+ note: spec.note,
526
+ };
527
+ }
528
+ /** 听众档位对应的时长与页数参数。 */
529
+ export function audienceProfile(audience) {
530
+ if (audience === 'master') {
531
+ return {
532
+ label: '硕士',
533
+ secondsMin: 45,
534
+ secondsMax: 70,
535
+ pages: MASTER_PAGES,
536
+ defaultPages: 13,
537
+ advice: '硕士答辩讲述更细:实现与测试各给 2 页以上,预计时长 12-16 分钟,按学校通知的汇报时长裁剪。',
538
+ };
539
+ }
540
+ return {
541
+ label: '本科',
542
+ secondsMin: SECONDS_PER_SLIDE_MIN,
543
+ secondsMax: SECONDS_PER_SLIDE_MAX,
544
+ pages: UNDERGRAD_PAGES,
545
+ defaultPages: 11,
546
+ advice: '本科答辩按 8-12 分钟准备:每页 40-60 秒,超过 12 分钟要删页,不要靠语速硬撑。',
547
+ };
548
+ }
549
+ /**
550
+ * 生成幻灯片计划(纯函数;不读磁盘、不编造素材)。
551
+ *
552
+ * 步骤:解析素材 → 取骨架 → 按目标页数补齐段落 → 填素材 → 截断要点 → 估时。
553
+ */
554
+ export function buildSlidePlan(input) {
555
+ const audience = input.audience;
556
+ const profile = audienceProfile(audience);
557
+ const parsed = input.defenseMaterials !== undefined && input.defenseMaterials.trim() !== ''
558
+ ? parseDefenseMaterials(input.defenseMaterials)
559
+ : undefined;
560
+ const source = parsed !== undefined
561
+ && (parsed.chapters.length > 0 || parsed.techChoices.length > 0 || parsed.decisions.length > 0)
562
+ ? 'defense-materials'
563
+ : 'chapters';
564
+ const chapterDigests = source === 'chapters' ? parseChapterFallback(input.chapterFiles ?? []) : (parsed?.chapters ?? []);
565
+ const materials = parsed !== undefined
566
+ ? { ...parsed, chapters: chapterDigests }
567
+ : {
568
+ title: input.title,
569
+ chapters: chapterDigests,
570
+ techChoices: [],
571
+ decisions: [],
572
+ testCases: 0,
573
+ resultFiles: [],
574
+ };
575
+ const rawTitle = parsed !== undefined && parsed.title !== '' ? parsed.title : input.title;
576
+ const warnings = [];
577
+ if (rawTitle === '' || rawTitle === '本科毕业论文(设计)') {
578
+ warnings.push('课题标题为默认值——请在进度台账或幻灯片计划里替换为你的真实课题名。');
579
+ }
580
+ const requested = Number.isFinite(input.targetPages) ? Math.round(input.targetPages) : profile.defaultPages;
581
+ const [minPages, maxPages] = profile.pages;
582
+ const composition = composeSlides(clamp(requested, minPages, maxPages));
583
+ const pageAdjustment = requested !== composition.ids.length + 4
584
+ ? `目标 ${requested} 页超出${profile.label}答辩骨架允许的 ${minPages}-${maxPages} 页,已按 ${composition.ids.length + 4} 页生成。`
585
+ : undefined;
586
+ const slides = [];
587
+ for (const head of FIXED_HEAD)
588
+ slides.push(toSlide(slides.length + 1, head({ planTitle: rawTitle })));
589
+ const totals = new Map();
590
+ for (const id of composition.ids)
591
+ totals.set(id, (totals.get(id) ?? 0) + 1);
592
+ for (const id of composition.ids) {
593
+ const segment = SEGMENT_BY_ID[id];
594
+ const spec = segment.page({
595
+ planTitle: rawTitle,
596
+ materials,
597
+ source,
598
+ part: composition.parts[id],
599
+ total: totals.get(id) ?? 1,
600
+ });
601
+ slides.push(toSlide(slides.length + 1, spec));
602
+ }
603
+ for (const tail of FIXED_TAIL) {
604
+ slides.push(toSlide(slides.length + 1, tail({ planTitle: rawTitle, materials, source, part: 0, total: 1 })));
605
+ }
606
+ const note = source === 'defense-materials'
607
+ ? undefined
608
+ : (parsed === undefined
609
+ ? '素材未生成,建议先跑 thesis_defense:当前计划由论文章节标题回退生成,未编造任何系统细节。'
610
+ : '答辩素材里没有可用的章节/选型/决定记录,建议先跑 thesis_defense 重新生成素材。');
611
+ const pages = slides.length;
612
+ const minMinutes = Math.max(1, Math.round((pages * profile.secondsMin) / 60));
613
+ const maxMinutes = Math.max(1, Math.round((pages * profile.secondsMax) / 60));
614
+ const plan = {
615
+ title: rawTitle,
616
+ audience,
617
+ pages,
618
+ slides,
619
+ estimate: {
620
+ minMinutes,
621
+ maxMinutes,
622
+ secondsPerSlideMin: profile.secondsMin,
623
+ secondsPerSlideMax: profile.secondsMax,
624
+ advice: profile.advice,
625
+ },
626
+ ...(pageAdjustment !== undefined ? { pageAdjustment } : {}),
627
+ source,
628
+ warnings,
629
+ };
630
+ return note !== undefined ? { ...plan, note } : plan;
631
+ }
632
+ async function readOptional(fs, absPath, signal) {
633
+ try {
634
+ return await fs.readText(await fs.resolve(absPath, { signal }), signal);
635
+ }
636
+ catch {
637
+ return undefined;
638
+ }
639
+ }
640
+ /** 从进度台账提取课题标题(与 thesis_build/thesis_defense 的口径一致)。 */
641
+ export function titleFromLedger(ledger) {
642
+ if (ledger === undefined)
643
+ return '本科毕业论文(设计)';
644
+ for (const raw of ledger.split(/\r?\n/)) {
645
+ const line = raw.trim();
646
+ const matched = /^(?:-\s*)?(?:title|课题)\s*[::]\s*(.+)$/i.exec(line);
647
+ if (matched?.[1] !== undefined && matched[1].trim() !== '')
648
+ return matched[1].trim();
649
+ }
650
+ return '本科毕业论文(设计)';
651
+ }
652
+ /** 装载生成幻灯计划所需的全部输入(素材 → 回退章节 → 台账标题)。 */
653
+ export async function loadOutlineInputs(fs, root, signal) {
654
+ const ledgerTitle = titleFromLedger(await readOptional(fs, nodePath.join(root, '00-管理/进度台账.md'), signal));
655
+ const defenseMaterials = await readOptional(fs, nodePath.join(root, '07-答辩/答辩素材.md'), signal);
656
+ const chapterFiles = [];
657
+ if (defenseMaterials === undefined || defenseMaterials.trim() === '') {
658
+ const dir = nodePath.join(root, '06-论文/章节');
659
+ try {
660
+ const entries = await fs.listDir(await fs.resolve(dir, { signal }), signal);
661
+ for (const entry of entries) {
662
+ if (entry.isDirectory || !entry.name.endsWith('.md'))
663
+ continue;
664
+ const text = await readOptional(fs, nodePath.join(dir, entry.name), signal);
665
+ if (text !== undefined)
666
+ chapterFiles.push({ name: entry.name, text });
667
+ }
668
+ }
669
+ catch {
670
+ // 章节目录不存在:回退路径无内容,计划里如实标注。
671
+ }
672
+ }
673
+ return {
674
+ ledgerTitle,
675
+ ...(defenseMaterials !== undefined ? { defenseMaterials } : {}),
676
+ chapterFiles,
677
+ };
678
+ }
679
+ /** 计划的人类可读摘要(工具返回值用,有界)。 */
680
+ export function summarizePlan(plan) {
681
+ const lines = [];
682
+ lines.push(`- 实际页数:${plan.pages} 页(听众:${audienceProfile(plan.audience).label})`);
683
+ lines.push(`- 预计时长:${plan.estimate.minMinutes}-${plan.estimate.maxMinutes} 分钟(每页 ${plan.estimate.secondsPerSlideMin}-${plan.estimate.secondsPerSlideMax} 秒)`);
684
+ lines.push(`- 素材来源:${plan.source === 'defense-materials' ? '07-答辩/答辩素材.md' : '06-论文/章节/*.md(回退)'}`);
685
+ if (plan.note !== undefined)
686
+ lines.push(`- 提示:${plan.note}`);
687
+ if (plan.pageAdjustment !== undefined)
688
+ lines.push(`- 页数调整:${plan.pageAdjustment}`);
689
+ for (const warning of plan.warnings)
690
+ lines.push(`- 注意:${warning}`);
691
+ const truncated = [...new Set(plan.slides.filter(s => s.bullets.some(b => b.truncated)).map(s => s.index))];
692
+ if (truncated.length > 0)
693
+ lines.push(`- 超长要点已截断并标记:第 ${truncated.join('、')} 页`);
694
+ return lines;
695
+ }