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.
- package/DESIGN.md +390 -0
- package/LICENSE +21 -0
- package/NOTICE.md +10 -0
- package/README.md +253 -0
- package/cordis.patch.yml +17 -0
- package/docs/host-api.md +640 -0
- package/lib/codewalk/commands.d.ts +23 -0
- package/lib/codewalk/commands.js +168 -0
- package/lib/codewalk/engine.d.ts +108 -0
- package/lib/codewalk/engine.js +449 -0
- package/lib/codewalk/errors.d.ts +31 -0
- package/lib/codewalk/errors.js +20 -0
- package/lib/codewalk/fs-types.d.ts +34 -0
- package/lib/codewalk/fs-types.js +10 -0
- package/lib/codewalk/gate.d.ts +30 -0
- package/lib/codewalk/gate.js +60 -0
- package/lib/codewalk/host-types.d.ts +198 -0
- package/lib/codewalk/host-types.js +11 -0
- package/lib/codewalk/prestep.d.ts +17 -0
- package/lib/codewalk/prestep.js +62 -0
- package/lib/codewalk/register.d.ts +23 -0
- package/lib/codewalk/register.js +31 -0
- package/lib/codewalk/skill.d.ts +15 -0
- package/lib/codewalk/skill.js +57 -0
- package/lib/codewalk/tools.d.ts +18 -0
- package/lib/codewalk/tools.js +314 -0
- package/lib/codewalk/types.d.ts +151 -0
- package/lib/codewalk/types.js +9 -0
- package/lib/commands.d.ts +33 -0
- package/lib/commands.js +60 -0
- package/lib/config.d.ts +125 -0
- package/lib/config.js +139 -0
- package/lib/dedup/commands.d.ts +21 -0
- package/lib/dedup/commands.js +93 -0
- package/lib/dedup/index.d.ts +75 -0
- package/lib/dedup/index.js +331 -0
- package/lib/dedup/normalize.d.ts +125 -0
- package/lib/dedup/normalize.js +329 -0
- package/lib/dedup/report.d.ts +114 -0
- package/lib/dedup/report.js +309 -0
- package/lib/dedup/rewrite.d.ts +71 -0
- package/lib/dedup/rewrite.js +228 -0
- package/lib/dedup/similarity.d.ts +169 -0
- package/lib/dedup/similarity.js +323 -0
- package/lib/gates/dangerous.d.ts +22 -0
- package/lib/gates/dangerous.js +47 -0
- package/lib/gates/detect.d.ts +54 -0
- package/lib/gates/detect.js +106 -0
- package/lib/gates/messages.d.ts +39 -0
- package/lib/gates/messages.js +74 -0
- package/lib/gates/prompt.d.ts +21 -0
- package/lib/gates/prompt.js +56 -0
- package/lib/gates/writing-intent.d.ts +49 -0
- package/lib/gates/writing-intent.js +68 -0
- package/lib/index.d.ts +36 -0
- package/lib/index.js +76 -0
- package/lib/ingest/code.d.ts +71 -0
- package/lib/ingest/code.js +356 -0
- package/lib/ingest/commands.d.ts +23 -0
- package/lib/ingest/commands.js +74 -0
- package/lib/ingest/docx.d.ts +29 -0
- package/lib/ingest/docx.js +92 -0
- package/lib/ingest/index.d.ts +179 -0
- package/lib/ingest/index.js +640 -0
- package/lib/ingest/ooxml.d.ts +58 -0
- package/lib/ingest/ooxml.js +393 -0
- package/lib/ingest/pdf.d.ts +86 -0
- package/lib/ingest/pdf.js +1016 -0
- package/lib/ingest/pptx.d.ts +32 -0
- package/lib/ingest/pptx.js +116 -0
- package/lib/ingest/text.d.ts +25 -0
- package/lib/ingest/text.js +180 -0
- package/lib/ingest/types.d.ts +48 -0
- package/lib/ingest/types.js +47 -0
- package/lib/ingest/xlsx.d.ts +72 -0
- package/lib/ingest/xlsx.js +411 -0
- package/lib/ingest/zip.d.ts +76 -0
- package/lib/ingest/zip.js +393 -0
- package/lib/intake/definitions.d.ts +65 -0
- package/lib/intake/definitions.js +42 -0
- package/lib/intake/gate-rules.d.ts +57 -0
- package/lib/intake/gate-rules.js +118 -0
- package/lib/intake/gate.d.ts +24 -0
- package/lib/intake/gate.js +86 -0
- package/lib/intake/index.d.ts +76 -0
- package/lib/intake/index.js +468 -0
- package/lib/intake/questions.d.ts +169 -0
- package/lib/intake/questions.js +720 -0
- package/lib/intake/spec.d.ts +66 -0
- package/lib/intake/spec.js +221 -0
- package/lib/intake/state.d.ts +192 -0
- package/lib/intake/state.js +420 -0
- package/lib/memory/recall.d.ts +69 -0
- package/lib/memory/recall.js +177 -0
- package/lib/memory/register.d.ts +37 -0
- package/lib/memory/register.js +52 -0
- package/lib/memory/service.d.ts +39 -0
- package/lib/memory/service.js +39 -0
- package/lib/memory/store.d.ts +45 -0
- package/lib/memory/store.js +123 -0
- package/lib/memory/tools.d.ts +19 -0
- package/lib/memory/tools.js +195 -0
- package/lib/memory/types.d.ts +55 -0
- package/lib/memory/types.js +6 -0
- package/lib/paper/commands.d.ts +13 -0
- package/lib/paper/commands.js +84 -0
- package/lib/paper/lib/aicheck.d.ts +38 -0
- package/lib/paper/lib/aicheck.js +241 -0
- package/lib/paper/lib/bibtex.d.ts +21 -0
- package/lib/paper/lib/bibtex.js +114 -0
- package/lib/paper/lib/check.d.ts +32 -0
- package/lib/paper/lib/check.js +215 -0
- package/lib/paper/lib/defense.d.ts +48 -0
- package/lib/paper/lib/defense.js +220 -0
- package/lib/paper/lib/docx.d.ts +35 -0
- package/lib/paper/lib/docx.js +213 -0
- package/lib/paper/lib/layout.d.ts +41 -0
- package/lib/paper/lib/layout.js +572 -0
- package/lib/paper/lib/ledger.d.ts +112 -0
- package/lib/paper/lib/ledger.js +349 -0
- package/lib/paper/lib/lit-api.d.ts +59 -0
- package/lib/paper/lib/lit-api.js +296 -0
- package/lib/paper/lib/lit-cache.d.ts +32 -0
- package/lib/paper/lib/lit-cache.js +91 -0
- package/lib/paper/lib/project.d.ts +29 -0
- package/lib/paper/lib/project.js +93 -0
- package/lib/paper/lib/review.d.ts +37 -0
- package/lib/paper/lib/review.js +194 -0
- package/lib/paper/lib/zip.d.ts +14 -0
- package/lib/paper/lib/zip.js +124 -0
- package/lib/paper/register.d.ts +20 -0
- package/lib/paper/register.js +183 -0
- package/lib/paper/tools/aicheck.d.ts +6 -0
- package/lib/paper/tools/aicheck.js +38 -0
- package/lib/paper/tools/build.d.ts +27 -0
- package/lib/paper/tools/build.js +166 -0
- package/lib/paper/tools/check.d.ts +7 -0
- package/lib/paper/tools/check.js +79 -0
- package/lib/paper/tools/decision.d.ts +13 -0
- package/lib/paper/tools/decision.js +53 -0
- package/lib/paper/tools/defense.d.ts +11 -0
- package/lib/paper/tools/defense.js +74 -0
- package/lib/paper/tools/init.d.ts +23 -0
- package/lib/paper/tools/init.js +99 -0
- package/lib/paper/tools/lit.d.ts +27 -0
- package/lib/paper/tools/lit.js +236 -0
- package/lib/paper/tools/progress.d.ts +18 -0
- package/lib/paper/tools/progress.js +77 -0
- package/lib/paper/tools/review.d.ts +11 -0
- package/lib/paper/tools/review.js +41 -0
- package/lib/ppt/actions.d.ts +97 -0
- package/lib/ppt/actions.js +263 -0
- package/lib/ppt/check.d.ts +64 -0
- package/lib/ppt/check.js +185 -0
- package/lib/ppt/commands.d.ts +28 -0
- package/lib/ppt/commands.js +71 -0
- package/lib/ppt/convert.d.ts +146 -0
- package/lib/ppt/convert.js +423 -0
- package/lib/ppt/index.d.ts +45 -0
- package/lib/ppt/index.js +57 -0
- package/lib/ppt/marp.d.ts +74 -0
- package/lib/ppt/marp.js +165 -0
- package/lib/ppt/outline.d.ts +206 -0
- package/lib/ppt/outline.js +695 -0
- package/lib/shared/define-tool.d.ts +77 -0
- package/lib/shared/define-tool.js +235 -0
- package/lib/shared/fs-errors.d.ts +50 -0
- package/lib/shared/fs-errors.js +90 -0
- package/lib/shared/index.d.ts +31 -0
- package/lib/shared/index.js +32 -0
- package/lib/shared/spawn.d.ts +38 -0
- package/lib/shared/spawn.js +50 -0
- package/lib/shared/text.d.ts +14 -0
- package/lib/shared/text.js +26 -0
- package/package.json +82 -0
- package/skills/code-walkthrough/SKILL.md +94 -0
- package/skills/code-walkthrough/references/examples.md +129 -0
- package/skills/paper-dedup/SKILL.md +58 -0
- package/skills/paper-intake/SKILL.md +64 -0
- package/skills/thesis-citation/SKILL.md +67 -0
- package/skills/thesis-defense/SKILL.md +87 -0
- package/skills/thesis-eng-design/SKILL.md +97 -0
- package/skills/thesis-inquiry/SKILL.md +55 -0
- package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
- package/skills/thesis-literature/SKILL.md +56 -0
- package/skills/thesis-opener/SKILL.md +77 -0
- package/skills/thesis-pipeline/SKILL.md +121 -0
- package/skills/thesis-writing/SKILL.md +86 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 材料摄取模块门面:类型判定 → 各格式抽取 → 统一的 `thesis_ingest` 工具。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - `extractDocument` 是**纯函数**(字节 + 类型 + 选项 → 结果),不碰磁盘;
|
|
6
|
+
* - `extractText` 是统一入口(按文件名判定类型),供其它模块编程调用;
|
|
7
|
+
* - `thesis_ingest` 负责磁盘/工作区落盘:读文件(二进制走 `node:fs` 例外)、
|
|
8
|
+
* 递归枚举目录、把摘要写到 `00-管理/材料/`,**绝不覆盖已存在的摘要**;
|
|
9
|
+
* - I/O 通过 {@link IngestIO} 注入,便于测试注入自定义读取器与假文件系统。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-thesis/ingest
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
14
|
+
import * as nodePath from 'node:path';
|
|
15
|
+
import { findThesisRoot } from "../paper/lib/project.js";
|
|
16
|
+
import { bulletList, definePaperTool, sessionCwd, textOutput } from "../shared/index.js";
|
|
17
|
+
import { ingestCommand } from "./commands.js";
|
|
18
|
+
import { codeLanguageOf, detectTechStack, isManifestFile, renderProjectSummary, summarizeSource, } from "./code.js";
|
|
19
|
+
import { extractDocx } from "./docx.js";
|
|
20
|
+
import { extractPdf } from "./pdf.js";
|
|
21
|
+
import { extractPptx } from "./pptx.js";
|
|
22
|
+
import { decodeTextBytes, extractText as extractPlainText } from "./text.js";
|
|
23
|
+
import { extractXlsx } from "./xlsx.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// 类型判定
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** 统一的门面输出(`shared/textOutput`):模型看到的就是这段字符串。 */
|
|
28
|
+
const OUTPUT = textOutput();
|
|
29
|
+
/** 文本类扩展名(含 `.html`,抽取时去标签)。 */
|
|
30
|
+
const TEXT_EXTENSIONS = [
|
|
31
|
+
'.md', '.markdown', '.txt', '.csv', '.tsv', '.json', '.yaml', '.yml',
|
|
32
|
+
'.bib', '.ris', '.html', '.htm',
|
|
33
|
+
];
|
|
34
|
+
/** 旧版二进制 Office 格式:明确不支持并给出另存为提示。 */
|
|
35
|
+
const LEGACY_EXTENSIONS = ['.doc', '.xls', '.ppt'];
|
|
36
|
+
/** 递归枚举时跳过的目录名(版本库/依赖/产出,避免把仓库翻个底朝天)。 */
|
|
37
|
+
export const SKIP_DIRS = ['node_modules', '.git', '06-论文/产出', '产出', '.paper', 'dist', 'lib', '__pycache__', '.venv', 'venv'];
|
|
38
|
+
/**
|
|
39
|
+
* 按扩展名判定摄取类型。
|
|
40
|
+
*
|
|
41
|
+
* 顺序有讲究:源码扩展名先判为 `code`(`.md/.json/.yaml/.html` 仍走 `text`,
|
|
42
|
+
* 依赖清单也走 `text`——清单原文对写"技术选型"有用)。
|
|
43
|
+
*/
|
|
44
|
+
export function detectKind(path) {
|
|
45
|
+
const lower = path.toLowerCase().replace(/\\/g, '/');
|
|
46
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
47
|
+
const dot = base.lastIndexOf('.');
|
|
48
|
+
const extension = dot <= 0 ? '' : base.slice(dot);
|
|
49
|
+
if (extension === '.docx' || extension === '.docm')
|
|
50
|
+
return 'docx';
|
|
51
|
+
if (extension === '.xlsx' || extension === '.xlsm')
|
|
52
|
+
return 'xlsx';
|
|
53
|
+
if (extension === '.pptx' || extension === '.pptm')
|
|
54
|
+
return 'pptx';
|
|
55
|
+
if (extension === '.pdf')
|
|
56
|
+
return 'pdf';
|
|
57
|
+
if (isManifestFile(path))
|
|
58
|
+
return 'text';
|
|
59
|
+
// `.html/.htm/.md/.json/.yaml` 等仍按文本读(HTML 另有去标签处理):
|
|
60
|
+
// 它们虽然是"代码风格"的文件,但作为材料时原文更有用。
|
|
61
|
+
if (TEXT_EXTENSIONS.includes(extension))
|
|
62
|
+
return 'text';
|
|
63
|
+
if (codeLanguageOf(path) !== undefined)
|
|
64
|
+
return 'code';
|
|
65
|
+
// 旧版二进制格式:判为 unsupported,但调用方在目录枚举时仍会收进来,
|
|
66
|
+
// 以便给出"请另存为新格式"的明确提示(见 unsupportedReason)。
|
|
67
|
+
if (LEGACY_EXTENSIONS.includes(extension))
|
|
68
|
+
return 'unsupported';
|
|
69
|
+
return 'unsupported';
|
|
70
|
+
}
|
|
71
|
+
/** 不支持时的解释(`.doc`/`.xls`/`.ppt` 走专门提示)。 */
|
|
72
|
+
export function unsupportedReason(path) {
|
|
73
|
+
const lower = path.toLowerCase();
|
|
74
|
+
const extension = lower.slice(lower.lastIndexOf('.'));
|
|
75
|
+
if (LEGACY_EXTENSIONS.includes(extension)) {
|
|
76
|
+
return `${extension} 是 1997–2003 的旧二进制 Office 格式,本模块无法读取:请在 Word/Excel/PPT 或 WPS 里「另存为」.${extension.slice(1)}x 新格式后重试。`;
|
|
77
|
+
}
|
|
78
|
+
return '不支持的文件类型:可摄取的格式为 docx / xlsx / pptx / pdf / md / txt / csv / tsv / json / yaml / bib / ris / html。';
|
|
79
|
+
}
|
|
80
|
+
/** 把内部结果 + 类型包装成公开结果。 */
|
|
81
|
+
function wrap(kind, result) {
|
|
82
|
+
return {
|
|
83
|
+
ok: result.ok,
|
|
84
|
+
kind,
|
|
85
|
+
text: result.ok ? result.text : '',
|
|
86
|
+
chars: result.ok ? result.text.length : 0,
|
|
87
|
+
truncated: result.truncated,
|
|
88
|
+
...(result.title !== undefined ? { title: result.title } : {}),
|
|
89
|
+
...(result.note !== undefined ? { note: result.note } : {}),
|
|
90
|
+
...(result.lowConfidence === true ? { lowConfidence: true } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 按类型抽取字节 → 文本(纯函数)。
|
|
95
|
+
*
|
|
96
|
+
* - `unsupported` 返回 `ok: false` 与明确的另存为提示,绝不静默返回空文本;
|
|
97
|
+
* - `code` **不返回源码正文**,只给该文件的结构摘要(语言/行数/声明)。
|
|
98
|
+
*/
|
|
99
|
+
export function extractDocument(bytes, kind, options) {
|
|
100
|
+
switch (kind) {
|
|
101
|
+
case 'docx':
|
|
102
|
+
return wrap(kind, extractDocx(bytes, options));
|
|
103
|
+
case 'xlsx':
|
|
104
|
+
return wrap(kind, extractXlsx(bytes, options));
|
|
105
|
+
case 'pptx':
|
|
106
|
+
return wrap(kind, extractPptx(bytes, options));
|
|
107
|
+
case 'pdf':
|
|
108
|
+
return wrap(kind, extractPdf(bytes, options));
|
|
109
|
+
case 'text':
|
|
110
|
+
return wrap(kind, extractPlainText(bytes, 'material.txt', options));
|
|
111
|
+
case 'code':
|
|
112
|
+
return codeFileExtract('material.ts', decodeTextBytes(bytes), options);
|
|
113
|
+
default:
|
|
114
|
+
return { ok: false, kind: 'unsupported', text: '', chars: 0, truncated: false, note: unsupportedReason('') };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** 单文件代码结构摘要(不返回源码正文)。 */
|
|
118
|
+
export function codeFileExtract(rel, text, options) {
|
|
119
|
+
const summary = summarizeSource(rel, text);
|
|
120
|
+
const declarations = summary.declarations.length === 0
|
|
121
|
+
? '(未检出顶层声明)'
|
|
122
|
+
: summary.declarations.map(item => `${item.kind} ${item.name}(第 ${item.line} 行)`).join(';');
|
|
123
|
+
const body = [
|
|
124
|
+
`文件:${summary.rel}`,
|
|
125
|
+
`语言:${summary.language}`,
|
|
126
|
+
`行数:${summary.lines}(代码 ${summary.codeLines},注释 ${summary.commentLines},空行 ${summary.blankLines})`,
|
|
127
|
+
`主要声明:${declarations}`,
|
|
128
|
+
...(summary.truncated ? ['(文件过长,声明索引已截断)'] : []),
|
|
129
|
+
].join('\n');
|
|
130
|
+
const limited = body.length > options.maxChars
|
|
131
|
+
? `${body.slice(0, Math.max(0, options.maxChars - 16))}\n…(摘要已截断)`
|
|
132
|
+
: body;
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
kind: 'code',
|
|
136
|
+
text: limited,
|
|
137
|
+
chars: limited.length,
|
|
138
|
+
truncated: limited.length < body.length,
|
|
139
|
+
note: '这是**结构摘要**,不含源码正文:写"系统实现"章与答辩素材时请基于结构与取舍,不要粘贴代码。',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** 统一入口:字节 + 文件名 → 结果(内部判定类型)。 */
|
|
143
|
+
export function extractText(bytes, filename, options) {
|
|
144
|
+
const kind = detectKind(filename);
|
|
145
|
+
if (kind === 'unsupported') {
|
|
146
|
+
return { ok: false, kind, text: '', chars: 0, truncated: false, note: unsupportedReason(filename) };
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'text')
|
|
149
|
+
return wrap(kind, extractPlainText(bytes, filename, options));
|
|
150
|
+
if (kind === 'code')
|
|
151
|
+
return codeFileExtract(filename, decodeTextBytes(bytes), options);
|
|
152
|
+
return extractDocument(bytes, kind, options);
|
|
153
|
+
}
|
|
154
|
+
/** 清单文件超过该字节数就不纳入技术栈指纹(只如实列出跳过)。 */
|
|
155
|
+
export const MAX_MANIFEST_BYTES = 200 * 1024;
|
|
156
|
+
/** 真实磁盘 I/O(二进制读取是文档化的例外:ctx.fs 只有 readText)。 */
|
|
157
|
+
export const diskIO = {
|
|
158
|
+
async kind(target) {
|
|
159
|
+
try {
|
|
160
|
+
const info = await stat(target);
|
|
161
|
+
return info.isDirectory() ? 'dir' : 'file';
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
async list(target) {
|
|
168
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
out.push({ name: entry.name, isDirectory: entry.isDirectory() });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
},
|
|
175
|
+
async read(target) {
|
|
176
|
+
return new Uint8Array(await readFile(target));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// 目录遍历
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/** 一次摄取最多处理的文件数(防止把整个磁盘拖进来)。 */
|
|
183
|
+
export const MAX_FILES = 200;
|
|
184
|
+
/** 递归深度上限。 */
|
|
185
|
+
export const MAX_DEPTH = 6;
|
|
186
|
+
/** 后缀过滤:`docx,pdf,md` → 匹配集合(空 = 不过滤)。 */
|
|
187
|
+
export function parseGlobFilter(glob) {
|
|
188
|
+
const out = new Set();
|
|
189
|
+
if (glob === undefined)
|
|
190
|
+
return out;
|
|
191
|
+
for (const part of glob.split(/[,,\s]+/)) {
|
|
192
|
+
const cleaned = part.trim().toLowerCase().replace(/^\*?\.?/, '');
|
|
193
|
+
if (cleaned !== '')
|
|
194
|
+
out.add(`.${cleaned}`);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
function extensionOf(path) {
|
|
199
|
+
const base = path.slice(path.lastIndexOf(nodePath.sep) + 1);
|
|
200
|
+
const dot = base.lastIndexOf('.');
|
|
201
|
+
return dot <= 0 ? '' : base.slice(dot).toLowerCase();
|
|
202
|
+
}
|
|
203
|
+
/** 是否跳过该目录(名称命中 SKIP_DIRS 尾段或整体相对路径)。 */
|
|
204
|
+
export function shouldSkipDir(name, rel) {
|
|
205
|
+
const normalizedRel = rel.replace(/\\/g, '/');
|
|
206
|
+
for (const skip of SKIP_DIRS) {
|
|
207
|
+
if (skip.includes('/')) {
|
|
208
|
+
if (normalizedRel === skip || normalizedRel.endsWith(`/${skip}`))
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
else if (name === skip) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
/** 递归枚举待摄取文件(目录)或单个文件。 */
|
|
218
|
+
export async function collectFiles(io, target, filter) {
|
|
219
|
+
const kind = await io.kind(target);
|
|
220
|
+
if (kind === undefined)
|
|
221
|
+
throw new Error(`路径不存在:${target}`);
|
|
222
|
+
if (kind === 'file') {
|
|
223
|
+
return [{ absolute: target, size: await fileSize(io, target), rel: nodePath.basename(target), kind: detectKind(target) }];
|
|
224
|
+
}
|
|
225
|
+
const out = [];
|
|
226
|
+
const root = target;
|
|
227
|
+
const walk = async (dir, depth) => {
|
|
228
|
+
if (depth > MAX_DEPTH || out.length >= MAX_FILES)
|
|
229
|
+
return;
|
|
230
|
+
let entries;
|
|
231
|
+
try {
|
|
232
|
+
entries = await io.list(dir);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
if (out.length >= MAX_FILES)
|
|
239
|
+
return;
|
|
240
|
+
const absolute = nodePath.join(dir, entry.name);
|
|
241
|
+
const rel = nodePath.relative(root, absolute);
|
|
242
|
+
if (entry.isDirectory) {
|
|
243
|
+
if (shouldSkipDir(entry.name, rel))
|
|
244
|
+
continue;
|
|
245
|
+
await walk(absolute, depth + 1);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const detected = detectKind(entry.name);
|
|
249
|
+
const extension = extensionOf(entry.name);
|
|
250
|
+
if (filter.size > 0 && !filter.has(extension))
|
|
251
|
+
continue;
|
|
252
|
+
// 无过滤器时也收旧版 Office 文件:它们会以 unsupported 失败并给出"另存为"提示。
|
|
253
|
+
if (filter.size === 0 && detected === 'unsupported' && !LEGACY_EXTENSIONS.includes(extension))
|
|
254
|
+
continue;
|
|
255
|
+
out.push({ absolute, size: await fileSize(io, absolute), rel, kind: detected });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
await walk(root, 0);
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/** 目录名 → 代码摘要文件名(`<目录名>-代码结构.md`)。 */
|
|
262
|
+
export function codeSummaryFileName(target) {
|
|
263
|
+
const base = nodePath.basename(target.replace(/[\\/]+$/, ''));
|
|
264
|
+
const safe = base.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim();
|
|
265
|
+
return `${safe === '' ? '项目' : safe}-代码结构.md`;
|
|
266
|
+
}
|
|
267
|
+
/** 汇总一组单文件摘要。 */
|
|
268
|
+
export function buildCodeStructure(files, manifests, summaryPath, skipped) {
|
|
269
|
+
const byLanguage = new Map();
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
const bucket = byLanguage.get(file.language) ?? { files: 0, lines: 0 };
|
|
272
|
+
bucket.files += 1;
|
|
273
|
+
bucket.lines += file.lines;
|
|
274
|
+
byLanguage.set(file.language, bucket);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
files: files.length,
|
|
278
|
+
lines: files.reduce((sum, file) => sum + file.lines, 0),
|
|
279
|
+
codeLines: files.reduce((sum, file) => sum + file.codeLines, 0),
|
|
280
|
+
languages: [...byLanguage.entries()]
|
|
281
|
+
.map(([language, bucket]) => ({ language, files: bucket.files, lines: bucket.lines }))
|
|
282
|
+
.sort((a, b) => b.lines - a.lines),
|
|
283
|
+
stack: detectTechStack(manifests),
|
|
284
|
+
summaryPath,
|
|
285
|
+
skipped,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** 读取源码/清单文本(按统一解码,最多 1MB 防止超大文件吃内存)。 */
|
|
289
|
+
function readSmallText(bytes) {
|
|
290
|
+
const slice = bytes.length > 1024 * 1024 ? bytes.subarray(0, 1024 * 1024) : bytes;
|
|
291
|
+
return decodeTextBytes(slice);
|
|
292
|
+
}
|
|
293
|
+
/** 文件字节数:通过 {@link statFileSize} 注入;注入式 IO(测试)返回 -1 表示未知。 */
|
|
294
|
+
async function fileSize(io, target) {
|
|
295
|
+
const stat = statFileSize(io);
|
|
296
|
+
if (stat === undefined)
|
|
297
|
+
return -1;
|
|
298
|
+
try {
|
|
299
|
+
return await stat(target);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return -1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** 取字节数函数:只有真实磁盘 IO 才有(测试注入的 IO 不读磁盘)。 */
|
|
306
|
+
function statFileSize(io) {
|
|
307
|
+
return io === diskIO ? async (target) => (await stat(target)).size : undefined;
|
|
308
|
+
}
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// 工作区落盘
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
/** 摘要目录(相对论文工作区根)。 */
|
|
313
|
+
export const MATERIAL_DIR = '00-管理/材料';
|
|
314
|
+
/** 清单文件(相对论文工作区根)。 */
|
|
315
|
+
export const MANIFEST_REL = '00-管理/材料清单.md';
|
|
316
|
+
/** 文件名 → 安全的摘要文件名(去扩展名,替换非法字符)。 */
|
|
317
|
+
export function summaryFileName(rel) {
|
|
318
|
+
const base = nodePath.basename(rel);
|
|
319
|
+
const withoutExtension = base.replace(/\.[^.]*$/, '');
|
|
320
|
+
const safe = withoutExtension.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim();
|
|
321
|
+
return `${safe === '' ? '材料' : safe}.md`;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 生成单个文件的摘要 Markdown。
|
|
325
|
+
* 含:原文件名、类型、字节数、字符数、是否截断/低置信、正文。
|
|
326
|
+
*/
|
|
327
|
+
export function buildSummary(rel, size, result) {
|
|
328
|
+
return [
|
|
329
|
+
`# 摄取摘要:${rel}`,
|
|
330
|
+
'',
|
|
331
|
+
`- 原文件:${rel}`,
|
|
332
|
+
`- 类型:${result.kind}`,
|
|
333
|
+
`- 字节数:${size}`,
|
|
334
|
+
`- 字符数:${result.chars}`,
|
|
335
|
+
`- 状态:${result.ok ? '成功' : '失败'}`,
|
|
336
|
+
`- 截断:${result.truncated ? '是(超出 maxChars)' : '否'}`,
|
|
337
|
+
`- 低置信:${result.lowConfidence === true ? '是(文本量明显偏少,请核对)' : '否'}`,
|
|
338
|
+
...(result.title !== undefined ? [`- 文档标题:${result.title}`] : []),
|
|
339
|
+
...(result.note !== undefined ? [`- 说明:${result.note}`] : []),
|
|
340
|
+
'',
|
|
341
|
+
'## 正文',
|
|
342
|
+
'',
|
|
343
|
+
result.ok && result.text !== '' ? result.text : '(未能抽取文本)',
|
|
344
|
+
'',
|
|
345
|
+
].join('\n');
|
|
346
|
+
}
|
|
347
|
+
/** 生成工作区清单文件内容。 */
|
|
348
|
+
export function buildManifest(files, results, written, skipped, code) {
|
|
349
|
+
const lines = [
|
|
350
|
+
'# 材料清单',
|
|
351
|
+
'',
|
|
352
|
+
`- 生成时间:${new Date().toISOString()}`,
|
|
353
|
+
`- 文件数:${files.length}`,
|
|
354
|
+
`- 成功:${results.filter(r => r.ok).length},失败:${results.filter(r => !r.ok).length}`,
|
|
355
|
+
'',
|
|
356
|
+
'## 文件',
|
|
357
|
+
'',
|
|
358
|
+
];
|
|
359
|
+
for (const [index, file] of files.entries()) {
|
|
360
|
+
const result = results[index];
|
|
361
|
+
if (result === undefined)
|
|
362
|
+
continue;
|
|
363
|
+
const status = result.ok ? '成功' : '失败';
|
|
364
|
+
lines.push(`- ${file.rel}(${result.kind},${file.size} 字节,${result.chars} 字符,${status}${result.truncated ? ',已截断' : ''}${result.lowConfidence === true ? ',低置信' : ''})`);
|
|
365
|
+
if (result.note !== undefined)
|
|
366
|
+
lines.push(` - 说明:${result.note}`);
|
|
367
|
+
}
|
|
368
|
+
if (code !== undefined) {
|
|
369
|
+
lines.push('', '## 代码结构', '');
|
|
370
|
+
lines.push(`- 源码文件:${code.files} 个,共 ${code.lines} 行(代码行 ${code.codeLines})`);
|
|
371
|
+
if (code.languages.length > 0) {
|
|
372
|
+
lines.push(`- 语言分布:${code.languages.map(item => `${item.language} ${item.files} 个/${item.lines} 行`).join('、')}`);
|
|
373
|
+
}
|
|
374
|
+
if (code.stack.length > 0) {
|
|
375
|
+
lines.push(`- 技术栈(由依赖清单推断,须核对):${code.stack.map(item => item.name).join('、')}`);
|
|
376
|
+
}
|
|
377
|
+
lines.push(`- 摘要文件:${code.summaryPath}`);
|
|
378
|
+
if (code.skipped.length > 0) {
|
|
379
|
+
lines.push(`- 未纳入摘要:${code.skipped.join('、')}`);
|
|
380
|
+
}
|
|
381
|
+
lines.push('- 代码结构是「系统实现」章与答辩素材的骨架:**不要把源码正文贴进论文**。');
|
|
382
|
+
}
|
|
383
|
+
if (written.length > 0) {
|
|
384
|
+
lines.push('', '## 已写入摘要', '', bulletList(written, '- '));
|
|
385
|
+
}
|
|
386
|
+
if (skipped.length > 0) {
|
|
387
|
+
lines.push('', '## 已跳过(摘要文件已存在或无法写入,绝不覆盖)', '', bulletList(skipped, '- '));
|
|
388
|
+
}
|
|
389
|
+
lines.push('');
|
|
390
|
+
return lines.join('\n');
|
|
391
|
+
}
|
|
392
|
+
/** 执行摄取(I/O 注入;工具与测试共用)。 */
|
|
393
|
+
export async function runIngest(fs, io, args, cwd, options, signal) {
|
|
394
|
+
const target = args.path;
|
|
395
|
+
if (target === undefined || target.trim() === '') {
|
|
396
|
+
throw new Error('path 必填:文件或目录的绝对路径。');
|
|
397
|
+
}
|
|
398
|
+
if (!nodePath.isAbsolute(target)) {
|
|
399
|
+
throw new Error(`path 必须是绝对路径,收到的是相对路径:${target}`);
|
|
400
|
+
}
|
|
401
|
+
const filter = parseGlobFilter(args.glob);
|
|
402
|
+
const files = await collectFiles(io, target, filter);
|
|
403
|
+
if (files.length === 0) {
|
|
404
|
+
throw new Error(`在 ${target} 下没有找到可摄取的文档${args.glob !== undefined ? `(后缀过滤:${args.glob})` : ''}。`);
|
|
405
|
+
}
|
|
406
|
+
const root = await findThesisRoot(fs, cwd, signal);
|
|
407
|
+
const write = args.write !== false;
|
|
408
|
+
const results = [];
|
|
409
|
+
const written = [];
|
|
410
|
+
const skipped = [];
|
|
411
|
+
const failed = [];
|
|
412
|
+
const lowConfidence = [];
|
|
413
|
+
let truncatedCount = 0;
|
|
414
|
+
const canWrite = write && root !== null;
|
|
415
|
+
// 代码结构汇总(源码只做结构摘要,不落源码正文)
|
|
416
|
+
const codeSummaries = [];
|
|
417
|
+
const manifests = {};
|
|
418
|
+
const codeSkipped = [];
|
|
419
|
+
const summaryPath = `${MATERIAL_DIR}/${codeSummaryFileName(target)}`;
|
|
420
|
+
const bytesCache = new Map();
|
|
421
|
+
const readBytes = async (file) => {
|
|
422
|
+
const cached = bytesCache.get(file.absolute);
|
|
423
|
+
if (cached !== undefined)
|
|
424
|
+
return cached;
|
|
425
|
+
const bytes = await io.read(file.absolute);
|
|
426
|
+
bytesCache.set(file.absolute, bytes);
|
|
427
|
+
return bytes;
|
|
428
|
+
};
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
if (file.size >= 0 && file.size > options.maxBytes) {
|
|
431
|
+
const result = {
|
|
432
|
+
ok: false,
|
|
433
|
+
kind: file.kind,
|
|
434
|
+
text: '',
|
|
435
|
+
chars: 0,
|
|
436
|
+
truncated: false,
|
|
437
|
+
note: `文件 ${file.size} 字节超过单文件上限 ${options.maxBytes} 字节:请拆分或先压缩后提供。`,
|
|
438
|
+
};
|
|
439
|
+
results.push(result);
|
|
440
|
+
failed.push({ rel: file.rel, reason: result.note ?? '文件过大' });
|
|
441
|
+
if (file.kind === 'code')
|
|
442
|
+
codeSkipped.push(`${file.rel}(超过单文件上限)`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
let bytes;
|
|
446
|
+
let result;
|
|
447
|
+
try {
|
|
448
|
+
bytes = await readBytes(file);
|
|
449
|
+
result = extractText(bytes, file.rel, { maxChars: options.maxChars });
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
result = {
|
|
453
|
+
ok: false,
|
|
454
|
+
kind: file.kind,
|
|
455
|
+
text: '',
|
|
456
|
+
chars: 0,
|
|
457
|
+
truncated: false,
|
|
458
|
+
note: `读取失败:${error instanceof Error ? error.message : String(error)}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
results.push(result);
|
|
462
|
+
if (!result.ok)
|
|
463
|
+
failed.push({ rel: file.rel, reason: result.note ?? '抽取失败' });
|
|
464
|
+
if (result.truncated)
|
|
465
|
+
truncatedCount += 1;
|
|
466
|
+
if (result.lowConfidence === true)
|
|
467
|
+
lowConfidence.push(file.rel);
|
|
468
|
+
// 代码结构:源码进来做摘要;依赖清单进来做技术栈指纹(超大清单如实跳过)
|
|
469
|
+
if (file.kind === 'code' && bytes !== undefined) {
|
|
470
|
+
codeSummaries.push(summarizeSource(file.rel, readSmallText(bytes)));
|
|
471
|
+
}
|
|
472
|
+
else if (isManifestFile(file.rel) && bytes !== undefined) {
|
|
473
|
+
if (bytes.length > MAX_MANIFEST_BYTES) {
|
|
474
|
+
codeSkipped.push(`${file.rel}(清单 ${bytes.length} 字节,超过 ${MAX_MANIFEST_BYTES} 字节上限)`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
manifests[file.rel] = readSmallText(bytes);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!canWrite)
|
|
481
|
+
continue;
|
|
482
|
+
const fileSummaryRel = `${MATERIAL_DIR}/${summaryFileName(file.rel)}`;
|
|
483
|
+
const absolute = nodePath.join(root, fileSummaryRel);
|
|
484
|
+
try {
|
|
485
|
+
const existing = await fs.stat(await fs.resolve(absolute, { signal }), signal);
|
|
486
|
+
if (existing !== undefined) {
|
|
487
|
+
skipped.push(`${fileSummaryRel}(已存在,跳过)`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// stat 失败/未实现:按"不存在"处理,下面写入时仍可能失败。
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
await fs.writeText(await fs.resolve(absolute, { signal }), buildSummary(file.rel, file.size, result), undefined, signal);
|
|
496
|
+
written.push(fileSummaryRel);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
skipped.push(`${fileSummaryRel}(写入失败:${error instanceof Error ? error.message : String(error)})`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const code = codeSummaries.length > 0 || Object.keys(manifests).length > 0
|
|
503
|
+
? buildCodeStructure(codeSummaries, manifests, summaryPath, codeSkipped)
|
|
504
|
+
: undefined;
|
|
505
|
+
if (canWrite && code !== undefined) {
|
|
506
|
+
const absolute = nodePath.join(root, code.summaryPath);
|
|
507
|
+
let exists = false;
|
|
508
|
+
try {
|
|
509
|
+
exists = (await fs.stat(await fs.resolve(absolute, { signal }), signal)) !== undefined;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
exists = false;
|
|
513
|
+
}
|
|
514
|
+
if (exists) {
|
|
515
|
+
skipped.push(`${code.summaryPath}(已存在,跳过)`);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
try {
|
|
519
|
+
const markdown = renderProjectSummary({ root: target, files: codeSummaries, manifests, skipped: codeSkipped });
|
|
520
|
+
await fs.writeText(await fs.resolve(absolute, { signal }), markdown, undefined, signal);
|
|
521
|
+
written.push(code.summaryPath);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
skipped.push(`${code.summaryPath}(写入失败:${error instanceof Error ? error.message : String(error)})`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (canWrite) {
|
|
529
|
+
const manifest = buildManifest(files, results, written, skipped, code);
|
|
530
|
+
try {
|
|
531
|
+
await fs.writeText(await fs.resolve(nodePath.join(root, MANIFEST_REL), { signal }), manifest, undefined, signal);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
skipped.push(`${MANIFEST_REL}(写入失败:${error instanceof Error ? error.message : String(error)})`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const note = root === null
|
|
538
|
+
? '未找到论文工作区(向上查找 00-管理/进度台账.md 失败):本次只返回结果,未落盘。请先在论文仓库目录内操作,或先运行 thesis_init。'
|
|
539
|
+
: write
|
|
540
|
+
? undefined
|
|
541
|
+
: 'write=false:本次只返回结果,未落盘。';
|
|
542
|
+
return {
|
|
543
|
+
root: root ?? undefined,
|
|
544
|
+
files,
|
|
545
|
+
results,
|
|
546
|
+
written,
|
|
547
|
+
skipped,
|
|
548
|
+
failed,
|
|
549
|
+
truncatedCount,
|
|
550
|
+
lowConfidence,
|
|
551
|
+
code,
|
|
552
|
+
note,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** 按优先级给出"该带进追问的关键材料"建议。 */
|
|
556
|
+
export function suggestNextSteps(files, results) {
|
|
557
|
+
const kinds = new Set(results.map(result => result.kind));
|
|
558
|
+
const names = files.map(file => file.rel.toLowerCase());
|
|
559
|
+
const tips = [];
|
|
560
|
+
if (!names.some(name => /模板|template|格式要求|格式规范/.test(name)) && !kinds.has('docx')) {
|
|
561
|
+
tips.push('还没读到学校论文模板/格式要求(.docx):追问前务必补上,否则格式要求只能靠猜。');
|
|
562
|
+
}
|
|
563
|
+
if (!kinds.has('xlsx') && !names.some(name => /(csv|数据|实验|结果)/.test(name))) {
|
|
564
|
+
tips.push('还没读到实验数据(.xlsx/.csv):结论与图表都需要它,请一并提供。');
|
|
565
|
+
}
|
|
566
|
+
if (!names.some(name => /(bib|ris|refs|参考文献|文献)/.test(name))) {
|
|
567
|
+
tips.push('还没读到参考文献(.bib/.ris/.txt):引用真实性核查需要它。');
|
|
568
|
+
}
|
|
569
|
+
if (!results.some(result => result.lowConfidence === true)) {
|
|
570
|
+
tips.push('建议把材料清单(00-管理/材料清单.md)带去 intake:追问会按"已读到/还缺什么"逐项确认。');
|
|
571
|
+
}
|
|
572
|
+
tips.push('优先带进追问的顺序:学校模板与评分标准 → 实验数据 → 代码结构/README → 参考文献 → 往届论文。');
|
|
573
|
+
return tips;
|
|
574
|
+
}
|
|
575
|
+
/** 渲染工具返回值(有界字符串,遵守 maxChars)。 */
|
|
576
|
+
export function renderOutcome(outcome, maxChars) {
|
|
577
|
+
const lines = [];
|
|
578
|
+
lines.push(`材料摄取:读取 ${outcome.files.length} 个文件(成功 ${outcome.results.filter(r => r.ok).length},失败 ${outcome.failed.length})`);
|
|
579
|
+
lines.push(outcome.root !== undefined ? `论文工作区:${outcome.root}` : '论文工作区:未找到');
|
|
580
|
+
lines.push('');
|
|
581
|
+
lines.push('## 文件清单');
|
|
582
|
+
for (const [index, file] of outcome.files.entries()) {
|
|
583
|
+
const result = outcome.results[index];
|
|
584
|
+
if (result === undefined)
|
|
585
|
+
continue;
|
|
586
|
+
const status = result.ok ? '成功' : `失败(${result.note ?? '未知原因'})`;
|
|
587
|
+
lines.push(`- ${file.rel} | ${result.kind} | ${file.size} 字节 | ${result.chars} 字符${result.truncated ? '(已截断)' : ''}${result.lowConfidence === true ? '(低置信)' : ''} | ${status}`);
|
|
588
|
+
}
|
|
589
|
+
if (outcome.code !== undefined) {
|
|
590
|
+
lines.push('', '## 代码结构');
|
|
591
|
+
lines.push(`- 源码文件:${outcome.code.files} 个,共 ${outcome.code.lines} 行(代码行 ${outcome.code.codeLines})`);
|
|
592
|
+
if (outcome.code.languages.length > 0) {
|
|
593
|
+
lines.push(`- 语言分布:${outcome.code.languages.map(item => `${item.language} ${item.files} 个/${item.lines} 行`).join('、')}`);
|
|
594
|
+
}
|
|
595
|
+
if (outcome.code.stack.length > 0) {
|
|
596
|
+
lines.push(`- 技术栈(由依赖清单推断,须核对):${outcome.code.stack.map(item => item.name).join('、')}`);
|
|
597
|
+
}
|
|
598
|
+
lines.push(`- 摘要文件:${outcome.code.summaryPath}`);
|
|
599
|
+
if (outcome.code.skipped.length > 0)
|
|
600
|
+
lines.push(`- 未纳入摘要:${outcome.code.skipped.join('、')}`);
|
|
601
|
+
lines.push('- 代码结构是「系统实现」章与答辩素材的骨架:**不要把源码正文贴进论文**。');
|
|
602
|
+
}
|
|
603
|
+
if (outcome.written.length > 0) {
|
|
604
|
+
lines.push('', `## 已写入(${outcome.written.length} 个摘要 + 材料清单)`, bulletList(outcome.written));
|
|
605
|
+
}
|
|
606
|
+
if (outcome.skipped.length > 0) {
|
|
607
|
+
lines.push('', '## 已跳过', bulletList(outcome.skipped));
|
|
608
|
+
}
|
|
609
|
+
if (outcome.note !== undefined)
|
|
610
|
+
lines.push('', `注意:${outcome.note}`);
|
|
611
|
+
lines.push('', '## 下一步建议');
|
|
612
|
+
lines.push(bulletList(suggestNextSteps(outcome.files, outcome.results)));
|
|
613
|
+
const text = lines.join('\n');
|
|
614
|
+
if (text.length <= maxChars)
|
|
615
|
+
return text;
|
|
616
|
+
return `${text.slice(0, Math.max(0, maxChars - 20))}\n…(清单过长已截断)`;
|
|
617
|
+
}
|
|
618
|
+
/** 注册 `thesis_ingest`(同步注册,走仓库统一的 `definePaperTool`;斜杠命令见 `commands.ts`)。 */
|
|
619
|
+
export function registerIngest(ctx, options) {
|
|
620
|
+
ctx.tools.register(definePaperTool({
|
|
621
|
+
name: 'thesis_ingest',
|
|
622
|
+
description: '把学生手上的材料读成纯文本或结构摘要:.docx(论文模板/要求/往届论文)、.xlsx/.csv(实验数据)、.pptx(答辩/课程 PPT)、' +
|
|
623
|
+
'.pdf(要求文件/往届论文,尽力而为)、.md/.txt/.bib/.ris/.html,以及**源码结构摘要**(.ts/.py/.java/.go… 只给文件/行数/声明/技术栈,不给源码正文)。' +
|
|
624
|
+
'path 给文件或目录绝对路径(目录会递归,深度上限 6,自动跳过 node_modules/.git/产出等);glob 可按后缀过滤(如 docx,pdf);' +
|
|
625
|
+
'write=true 时把清单写进论文工作区的 00-管理/材料清单.md、每个文件的摘要写进 00-管理/材料/<文件名>.md、代码结构写进 00-管理/材料/<目录名>-代码结构.md' +
|
|
626
|
+
'(已存在的摘要绝不覆盖)。失败的 PDF(扫描版/加密/无 ToUnicode 的 CID 字体)会如实说明原因,不会编造内容。',
|
|
627
|
+
parameters: {
|
|
628
|
+
path: { type: 'string', required: true, description: '文件或目录的绝对路径(目录会递归枚举)' },
|
|
629
|
+
glob: { type: 'string', description: '可选后缀过滤,逗号分隔,如 docx,pdf,md,ts(缺省按支持的格式自动筛选)' },
|
|
630
|
+
write: { type: 'boolean', description: '是否把清单与摘要写入论文工作区,默认 true;findThesisRoot 失败时只返回结果' },
|
|
631
|
+
},
|
|
632
|
+
output: OUTPUT,
|
|
633
|
+
async execute(rawArgs, exec) {
|
|
634
|
+
const args = rawArgs;
|
|
635
|
+
const outcome = await runIngest(ctx.fs, diskIO, args, sessionCwd(exec), options.ingest, exec.signal);
|
|
636
|
+
return renderOutcome(outcome, options.ingest.maxChars);
|
|
637
|
+
},
|
|
638
|
+
}));
|
|
639
|
+
ctx.commands.register(ingestCommand(ctx.fs, options.ingest));
|
|
640
|
+
}
|