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,331 @@
1
+ /**
2
+ * dsh-thesis 降重模块的门面与工具装配。
3
+ *
4
+ * 三层结构:
5
+ * - `normalize.ts` 文本归一化 + 段落切分(带原文偏移映射)
6
+ * - `similarity.ts` shingle 指纹、倒排索引、包含率/杰卡德/最长公共子串
7
+ * - `rewrite.ts` / `report.ts` 改写处方与报告渲染
8
+ *
9
+ * 本文件只做两件事:把正文段落与参考语料装进扫描器;注册 `thesis_originality` 工具
10
+ * 并把报告落到 `08-合规/降重报告.md`(verify 另写 `08-合规/降重报告-复测.md`)。
11
+ *
12
+ * **边界**:不做知网/维普等收费系统的接入(无授权,也不应接入)。
13
+ * 本地相似度只是字符串重合度估算,权威口径是学校检测结果,可回填到报告里。
14
+ *
15
+ * @module dsh-thesis/dedup
16
+ */
17
+ import * as nodePath from 'node:path';
18
+ import { bulletList, definePaperTool, sessionCwd, textOutput } from "../shared/index.js";
19
+ import { CHAPTER_META, isWrittenChapter } from "../paper/lib/layout.js";
20
+ import { findThesisRoot } from "../paper/lib/project.js";
21
+ import { dedupCommand } from "./commands.js";
22
+ import { segmentsFromText } from "./normalize.js";
23
+ import { scan } from "./similarity.js";
24
+ import { buildReport, parseBaseline, renderReport, renderVerifyReport, verifyDelta, weightedRateOf, } from "./report.js";
25
+ export const REPORT_REL = '08-合规/降重报告.md';
26
+ export const VERIFY_REPORT_REL = '08-合规/降重报告-复测.md';
27
+ export const CHAPTERS_REL = '06-论文/章节';
28
+ /** 缺省参考语料目录(相对论文工作区根)。 */
29
+ export const DEFAULT_CORPUS_DIRS = [
30
+ '02-文献/笔记',
31
+ '05-实验测试/结果',
32
+ '00-管理/材料',
33
+ ];
34
+ /** 参与比对的文件扩展名。 */
35
+ const CORPUS_EXTENSIONS = ['.md', '.txt', '.tex', '.csv'];
36
+ function toPosix(p) {
37
+ return p.replace(/\\/g, '/');
38
+ }
39
+ function scanOptionsFor(similarity) {
40
+ return { shingle: similarity.shingle, threshold: similarity.threshold };
41
+ }
42
+ /**
43
+ * 读取目录下(不递归子目录)参与比对的文本文件;目录不存在返回空数组。
44
+ */
45
+ async function readDirFiles(fs, root, relDir, signal) {
46
+ const entries = await listDirSafe(fs, nodePath.join(root, relDir), signal);
47
+ const files = [];
48
+ const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
49
+ for (const entry of sorted) {
50
+ if (entry.name.startsWith('.'))
51
+ continue;
52
+ const rel = `${relDir}/${entry.name}`;
53
+ if (entry.isDirectory) {
54
+ const nested = await readDirFiles(fs, root, rel, signal);
55
+ for (const file of nested)
56
+ files.push(file);
57
+ continue;
58
+ }
59
+ if (!CORPUS_EXTENSIONS.some(ext => entry.name.toLowerCase().endsWith(ext)))
60
+ continue;
61
+ const text = await readTextSafe(fs, nodePath.join(root, rel), signal);
62
+ if (text === null)
63
+ continue;
64
+ files.push({ file: rel, label: rel, text });
65
+ }
66
+ return files;
67
+ }
68
+ async function listDirSafe(fs, abs, signal) {
69
+ try {
70
+ return await fs.listDir(await fs.resolve(abs, { signal }), signal);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
76
+ async function readTextSafe(fs, abs, signal) {
77
+ try {
78
+ return await fs.readText(await fs.resolve(abs, { signal }), signal);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** 收集参考语料:显式路径优先,否则用缺省目录 + 各章正文(互查)。 */
85
+ export async function collectCorpus(fs, root, corpusArg, excludeFiles, wholeChapters, signal) {
86
+ const exclude = new Set(excludeFiles.map(toPosix));
87
+ const found = [];
88
+ const push = (file) => {
89
+ if (exclude.has(toPosix(file.file)))
90
+ return;
91
+ if (found.some(existing => existing.file === file.file))
92
+ return;
93
+ found.push(file);
94
+ };
95
+ if (corpusArg !== undefined && corpusArg.trim() !== '') {
96
+ for (const raw of corpusArg.split(/[;,]/)) {
97
+ const entry = raw.trim();
98
+ if (entry === '')
99
+ continue;
100
+ const abs = nodePath.isAbsolute(entry) ? entry : nodePath.join(root, entry);
101
+ const text = await readTextSafe(fs, abs, signal);
102
+ if (text !== null) {
103
+ const rel = toPosix(nodePath.isAbsolute(entry) ? nodePath.basename(entry) : entry);
104
+ push({ file: rel, label: rel, text });
105
+ continue;
106
+ }
107
+ const relDir = toPosix(nodePath.isAbsolute(entry) ? nodePath.basename(entry) : entry);
108
+ const files = await readDirFiles(fs, root, relDir, signal);
109
+ for (const file of files)
110
+ push(file);
111
+ }
112
+ return found;
113
+ }
114
+ for (const dir of DEFAULT_CORPUS_DIRS) {
115
+ const files = await readDirFiles(fs, root, dir, signal);
116
+ for (const file of files)
117
+ push(file);
118
+ }
119
+ // 正文互查:其他章整章作为语料(本段所在章由 excludeFiles/自比屏蔽排除)。
120
+ if (wholeChapters) {
121
+ for (const meta of CHAPTER_META) {
122
+ const rel = `${CHAPTERS_REL}/${meta.file}.md`;
123
+ if (exclude.has(rel))
124
+ continue;
125
+ const text = await readTextSafe(fs, nodePath.join(root, rel), signal);
126
+ if (text === null)
127
+ continue;
128
+ push({ file: rel, label: `${meta.file}(正文互查)`, text });
129
+ }
130
+ }
131
+ return found;
132
+ }
133
+ /** 读取正文段落(按章切分;chapter 为空则全部已撰写章)。 */
134
+ export async function loadChapterSegments(fs, root, chapter, minChars, signal) {
135
+ const wanted = chapter === undefined ? undefined : (chapter.endsWith('.md') ? chapter.slice(0, -3) : chapter);
136
+ const segments = [];
137
+ const missing = [];
138
+ const corpusCandidates = [];
139
+ for (const [index, meta] of CHAPTER_META.entries()) {
140
+ if (wanted !== undefined && meta.file !== wanted)
141
+ continue;
142
+ const rel = `${CHAPTERS_REL}/${meta.file}.md`;
143
+ const text = await readTextSafe(fs, nodePath.join(root, rel), signal);
144
+ if (text === null || !isWrittenChapter(text, index + 1, meta)) {
145
+ missing.push(meta.file);
146
+ continue;
147
+ }
148
+ corpusCandidates.push({ file: rel, label: `${meta.file}(正文互查)`, text });
149
+ const split = segmentsFromText(text, { file: rel, chapter: meta.file, minChars });
150
+ for (const segment of split.segments)
151
+ segments.push(segment);
152
+ }
153
+ return { segments, missing, corpusCandidates };
154
+ }
155
+ async function runScan(fs, root, args, similarity, now, detected, signal) {
156
+ const loaded = await loadChapterSegments(fs, root, args.chapter, similarity.minChars, signal);
157
+ const explicitCorpus = args.corpus !== undefined && args.corpus.trim() !== '';
158
+ // 本次已作为正文扫描的章不再进语料:与"自己所在的那一章"比较是自比,不是重复。
159
+ const corpusFiles = await collectCorpus(fs, root, args.corpus, loaded.corpusCandidates.map(file => file.file), !explicitCorpus, signal);
160
+ const scanResult = scan({
161
+ segments: loaded.segments,
162
+ corpus: corpusFiles.map(file => ({ id: file.file, label: file.label, text: file.text })),
163
+ options: scanOptionsFor(similarity),
164
+ });
165
+ const options = {
166
+ threshold: similarity.threshold,
167
+ shingle: similarity.shingle,
168
+ minChars: similarity.minChars,
169
+ now,
170
+ corpusLabels: corpusFiles.length === 0 ? ['(无语料,仅统计段落)'] : corpusFiles.map(file => file.file),
171
+ ...(detected !== undefined ? { detected } : {}),
172
+ };
173
+ const report = buildReport(scanResult, options);
174
+ return {
175
+ scanResult,
176
+ report,
177
+ markdown: renderReport(report, options),
178
+ options,
179
+ missing: loaded.missing,
180
+ corpusLabels: options.corpusLabels ?? [],
181
+ };
182
+ }
183
+ function summaryLines(outcome, target, extra) {
184
+ const { scanResult, report } = outcome;
185
+ const top = report.risks.slice(0, 5);
186
+ const lines = [];
187
+ lines.push(`文件数:${new Set(outcome.scanResult.items.map(item => item.segment.file)).size} 个正文文件(正文段落 ${scanResult.paragraphCount} 段,语料 ${scanResult.corpusCount} 份)`);
188
+ lines.push(`分级:high ${scanResult.high} 段 | medium ${scanResult.medium} 段 | low ${scanResult.low} 段(有命中 ${scanResult.matchedCount} 段)`);
189
+ lines.push(`全文加权重复率估算:${(weightedRateOf(scanResult) * 100).toFixed(1)}%(口径见报告第 0 节;非学校检测结果)`);
190
+ lines.push('');
191
+ lines.push('Top 5 高风险段落:');
192
+ if (top.length === 0) {
193
+ lines.push('- 无中/高风险段落');
194
+ }
195
+ else {
196
+ lines.push(bulletList(top.map((risk, index) => `${index + 1}. ${risk.chapter} 第 ${risk.lineStart}-${risk.lineEnd} 行:${(risk.score * 100).toFixed(1)}% ← ${risk.source}`)));
197
+ }
198
+ lines.push('');
199
+ lines.push('下一步建议:');
200
+ lines.push(bulletList([
201
+ '先改高风险段落:按处方换结构(拆句/换语序/主被动),必须保留数字、单位、引用标记与代码标识符',
202
+ '命中来源是文献笔记时,改为直接引用 + [n] 标注,不要改写成自己的话',
203
+ '改写完成后执行 thesis_originality action=verify(baseline 传本次报告路径)复测降幅',
204
+ '送检后把真实重复率回填:thesis_originality action=report detected_rate=<百分比>',
205
+ ], '- '));
206
+ if (extra.length > 0) {
207
+ lines.push('');
208
+ for (const line of extra)
209
+ lines.push(line);
210
+ }
211
+ lines.push('');
212
+ lines.push(`报告已写入:${target}(每次覆盖,含时间戳与算法参数)`);
213
+ return lines;
214
+ }
215
+ async function writeReport(fs, root, rel, content, signal) {
216
+ await fs.writeText(await fs.resolve(nodePath.join(root, rel), { signal }), content, undefined, signal);
217
+ }
218
+ /** 执行 thesis_originality 的全部动作;返回有界文本摘要。 */
219
+ export async function runDedup(fs, cwd, args, similarity, signal) {
220
+ // action 必填:缺省/空串**不静默当成 scan**——那会在用户只想看报告时意外跑一次全量扫描并覆盖报告。
221
+ const action = (args.action ?? '').trim();
222
+ if (action === '') {
223
+ throw new Error('action 必填:scan(首次扫描并落盘)| verify(对 baseline 复测降幅)| report(生成/回填检测结果)。');
224
+ }
225
+ if (!['scan', 'verify', 'report'].includes(action)) {
226
+ throw new Error(`未知 action:${action}(只支持 scan | verify | report)。`);
227
+ }
228
+ const root = await findThesisRoot(fs, cwd, signal);
229
+ if (root === null)
230
+ throw new Error('未找到论文工作区。请先在论文仓库目录内操作。');
231
+ const now = new Date();
232
+ const detected = detectedFrom(args);
233
+ const missingNote = (missing) => missing.length === 0 ? [] : [`⚠ 未撰写/缺失章节未计入:${missing.join('、')}`];
234
+ if (action === 'verify') {
235
+ const current = await runScan(fs, root, args, similarity, now, detected, signal);
236
+ const baselineRel = args.baseline !== undefined && args.baseline.trim() !== '' ? args.baseline.trim() : REPORT_REL;
237
+ const baselineText = await readTextSafe(fs, nodePath.isAbsolute(baselineRel) ? baselineRel : nodePath.join(root, baselineRel), signal);
238
+ if (baselineText === null) {
239
+ throw new Error(`未找到 baseline 报告:${baselineRel}。请先执行 thesis_originality action=scan 生成 ${REPORT_REL}。`);
240
+ }
241
+ const baseline = parseBaseline(baselineText);
242
+ if (baseline === null) {
243
+ throw new Error(`baseline 报告无法解析(${baselineRel})。baseline 必须是 thesis_originality scan 生成的降重报告。`);
244
+ }
245
+ const currentSnapshot = {
246
+ paragraphCount: current.scanResult.paragraphCount,
247
+ matchedCount: current.scanResult.matchedCount,
248
+ high: current.scanResult.high,
249
+ medium: current.scanResult.medium,
250
+ weightedRate: weightedRateOf(current.scanResult),
251
+ };
252
+ const delta = verifyDelta(baseline, currentSnapshot);
253
+ await writeReport(fs, root, REPORT_REL, current.markdown, signal);
254
+ await writeReport(fs, root, VERIFY_REPORT_REL, renderVerifyReport(delta, current.options, current.report), signal);
255
+ const head = [
256
+ `复测完成:加权重复率估算 ${(delta.baseline.weightedRate * 100).toFixed(1)}% → ${(delta.current.weightedRate * 100).toFixed(1)}%`,
257
+ `降幅:${(delta.rateDrop * 100).toFixed(1)} 个百分点(相对 ${(delta.rateDropRatio * 100).toFixed(1)}%)`,
258
+ `高风险 ${delta.baseline.high} → ${delta.current.high} 段;中风险 ${delta.baseline.medium} → ${delta.current.medium} 段`,
259
+ `结论:${delta.verdict}`,
260
+ '',
261
+ ];
262
+ return [
263
+ ...head,
264
+ ...summaryLines(current, `${VERIFY_REPORT_REL}(并覆盖 ${REPORT_REL})`, missingNote(current.missing)),
265
+ ].join('\n');
266
+ }
267
+ if (action !== 'scan' && action !== 'report') {
268
+ throw new Error(`未知 action:${action}(只支持 scan | verify | report)`);
269
+ }
270
+ const outcome = await runScan(fs, root, args, similarity, now, detected, signal);
271
+ await writeReport(fs, root, REPORT_REL, outcome.markdown, signal);
272
+ const head = [];
273
+ if (outcome.scanResult.paragraphCount === 0) {
274
+ head.push('⚠ 没有可比对的正文段落:章节未撰写或全部段落短于 minChars。');
275
+ head.push('');
276
+ }
277
+ if (outcome.scanResult.corpusCount === 0) {
278
+ head.push('⚠ 参考语料为空:请把可能的来源文本放进 02-文献/笔记、05-实验测试/结果 或 00-管理/材料,或用 corpus 参数显式指定。');
279
+ head.push('');
280
+ }
281
+ return [
282
+ ...head,
283
+ ...summaryLines(outcome, REPORT_REL, missingNote(outcome.missing)),
284
+ ].join('\n');
285
+ }
286
+ function detectedFrom(args) {
287
+ const rate = args.detected_rate;
288
+ if (rate === undefined || !Number.isFinite(rate))
289
+ return undefined;
290
+ const detected = { rate: rate > 1 ? rate / 100 : rate };
291
+ const withSystem = args.detected_system === undefined ? detected : { ...detected, system: args.detected_system };
292
+ return withSystem;
293
+ }
294
+ /**
295
+ * 注册 `thesis_originality`:本地相似度扫描 / 复测 / 报告回填。
296
+ *
297
+ * 参数:`action`(scan | verify | report)、`corpus`(参考语料路径,文件或目录)、
298
+ * `chapter`(只处理某一章)、`baseline`(verify 的上次报告路径)、
299
+ * `detected_rate`(report 回填的学校检测重复率)、`detected_system`。
300
+ * 落盘:`08-合规/降重报告.md`;verify 另写 `08-合规/降重报告-复测.md`。
301
+ */
302
+ export function registerDedup(ctx, options) {
303
+ ctx.tools.register(definePaperTool({
304
+ name: 'thesis_originality',
305
+ description: '原创性自查(本地、确定性、零依赖、不联网):把正文段落与参考语料做 shingle 指纹比对,'
306
+ + '给出每段的包含率得分、命中来源、命中片段(行号与原文)、结构化改写处方与全文加权重复率估算,'
307
+ + '报告写入 08-合规/降重报告.md。'
308
+ + 'action=scan 首次扫描;verify 用 baseline 报告复测降幅(另写 08-合规/降重报告-复测.md);'
309
+ + 'report 把用户自行送检得到的重复率回填进报告。'
310
+ + '缺省语料为 02-文献/笔记、05-实验测试/结果、00-管理/材料 + 正文各章互查;corpus 可显式指定文件或目录。'
311
+ + '**本工具不接入知网/维普等收费查重系统**:本地结果是字符串重合度估算,权威口径是学校检测结果。',
312
+ parameters: {
313
+ action: { type: 'string', required: true, description: 'scan(扫描并落盘)| verify(对 baseline 复测降幅)| report(生成/回填学校检测结果)' },
314
+ corpus: { type: 'string', description: '参考语料路径(文件或目录,可多个用逗号分隔);缺省用 02-文献/笔记、05-实验测试/结果、00-管理/材料 + 正文各章互查' },
315
+ chapter: { type: 'string', description: '只处理某一章,如 01-绪论(可带 .md)' },
316
+ baseline: { type: 'string', description: 'verify 用:上一次 scan 落盘的报告路径,缺省 08-合规/降重报告.md' },
317
+ detected_rate: { type: 'number', description: 'report 用:用户自行送检得到的重复率(0.12 或 12 均可,>1 视为百分数)' },
318
+ detected_system: { type: 'string', description: 'report 用:检测系统名称,如「知网 PMLC」' },
319
+ },
320
+ output: textOutput(),
321
+ async execute(rawArgs, exec) {
322
+ return await runDedup(ctx.fs, sessionCwd(exec), rawArgs, options.similarity, exec.signal);
323
+ },
324
+ }));
325
+ // `/thesis-originality [scan|verify|report] [语料路径] [--chapter 01-绪论]`:与工具同一执行层,
326
+ // 命令只是给人用的快捷入口(命令名只允许 [a-z][a-z0-9_-]*)。
327
+ if (ctx.commands !== undefined) {
328
+ ctx.commands.register(dedupCommand(ctx.fs, options.similarity));
329
+ }
330
+ }
331
+ export { segmentsFromText, scan, buildReport, renderReport, weightedRateOf };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 降重模块的文本归一化与切分(确定性,无第三方依赖)。
3
+ *
4
+ * 两件事:
5
+ * 1. **归一化**:全角/半角统一(NFKC 的常用子集)、去 Markdown 标记与空白标点、
6
+ * 中文按字切分、拉丁按词切分(统一小写)。归一化后每个字符都带一条
7
+ * 「原文偏移」记录,使任何命中都能回到原始行号与原文片段。
8
+ * 2. **段落切分**:按空行与 Markdown 结构切段,标题行/代码块/公式行/
9
+ * 参考文献条目/表格行/引用块不算正文重复;短于 `minChars` 的段落跳过。
10
+ *
11
+ * 归一化与偏移必须一一对应:{@link NormalizedText.chars} 的第 i 个字符来自
12
+ * {@link NormalizedText.tokens} 中某个 token 的第 j 个字符,而该 token 记录了
13
+ * 它在**原文**中的起止偏移(`[start, end)`)。
14
+ *
15
+ * @module dsh-thesis/dedup
16
+ */
17
+ /** 单个词元:`text` 是归一化后的小写形式,`[start, end)` 是它在原文中的区间。 */
18
+ export interface NormalizedToken {
19
+ readonly text: string;
20
+ readonly start: number;
21
+ readonly end: number;
22
+ }
23
+ /** 归一化后的单个字符及其原文偏移(token 边界会打断连续区间)。 */
24
+ export interface NormalizedChar {
25
+ readonly ch: string;
26
+ readonly offset: number;
27
+ /** 该字符是否是新 token 的第一个字符(用于按词重建,而不是按字)。 */
28
+ readonly tokenStart: boolean;
29
+ readonly breakBefore: boolean;
30
+ }
31
+ export interface NormalizedText {
32
+ readonly text: string;
33
+ readonly tokens: readonly NormalizedToken[];
34
+ readonly chars: readonly NormalizedChar[];
35
+ }
36
+ /** 正文段落:归一化结果 + 它在原文件里的位置与原文。 */
37
+ export interface Segment {
38
+ /** 来源文件(相对论文工作区根,POSIX 分隔符)。 */
39
+ readonly file: string;
40
+ /** 章名(文件基名去掉 .md);非章节语料等于 file。 */
41
+ readonly chapter: string;
42
+ /** 段落起始行(1 基,闭区间)。 */
43
+ readonly lineStart: number;
44
+ /** 段落结束行(1 基,闭区间)。 */
45
+ readonly lineEnd: number;
46
+ /**
47
+ * 段落原文(全文行拼接,保留 Markdown 标记与全角标点,用于展示与处方)。
48
+ * 只做换行统一,字符偏移与原文一致。
49
+ */
50
+ readonly raw: string;
51
+ /** 归一化输入(全角→半角之后的整篇文本);所有偏移相对它计算。 */
52
+ readonly text: string;
53
+ /** 归一化结果。 */
54
+ readonly doc: NormalizedText;
55
+ }
56
+ export interface SegmentOptions {
57
+ /** 参与比对的最短段落长度(归一化字符数)。 */
58
+ readonly minChars: number;
59
+ }
60
+ /**
61
+ * 全角 → 半角、常见中文标点 → ASCII、统一大小写与换行符。
62
+ * 除 BOM(丢弃)与 CRLF(合并为一个 `\n`)外逐字符等长替换,
63
+ * 因此行号与原文字符偏移都保持可对应。
64
+ */
65
+ export declare function toHalfWidth(input: string): string;
66
+ /** 标题行:`# 标题`。 */
67
+ export declare function isHeadingLine(line: string): boolean;
68
+ /** 围栏代码块:``` 或 ~~~(≥3 个)。 */
69
+ export declare function isCodeFence(line: string): boolean;
70
+ /** 缩进代码块(4 空格 / 1 个制表符开头)。 */
71
+ export declare function isIndentedCode(line: string): boolean;
72
+ /** 独立公式行:`$$...$$`、`\begin{...}` 环境,或整行基本由数学符号构成。 */
73
+ export declare function isFormulaLine(line: string): boolean;
74
+ /** 图片行、水平分割线、HTML 注释行。 */
75
+ export declare function isStructuralLine(line: string): boolean;
76
+ /** 表格行。 */
77
+ export declare function isTableLine(line: string): boolean;
78
+ /** 引用块行(以 `>` 开头的说明性文字)。 */
79
+ export declare function isQuoteLine(line: string): boolean;
80
+ /** 列表项行(提纲/清单,不是连续论述)。 */
81
+ export declare function isListItemLine(line: string): boolean;
82
+ /** 参考文献标题(`## 参考文献` / `# References` 等)。 */
83
+ export declare function isReferenceHeading(line: string): boolean;
84
+ /** 参考文献条目行:`[1] 作者. 题名. 期刊, 年.` */
85
+ export declare function isReferenceEntry(line: string): boolean;
86
+ /** 模板脚手架提示行(`(写作要求:...)`、`(待补)` 等)。 */
87
+ export declare function isPlaceholderLine(line: string): boolean;
88
+ /** 该行是否属于「不构成正文重复」的结构行(不含空行判断)。 */
89
+ export declare function isSkippedLine(line: string): boolean;
90
+ /**
91
+ * 归一化一段文本,并记录每个归一化字符的原文偏移。
92
+ *
93
+ * 保留中文、拉丁词(含数字与下划线)与独立数字;标点、空白、Markdown 标记
94
+ * 全部丢弃 —— 字符级过滤不会破坏偏移映射(字符是逐个追加的)。
95
+ */
96
+ export declare function normalizeText(text: string): NormalizedText;
97
+ /**
98
+ * 把归一化字符区间 `[from, to)` 映射回**原文**的字符区间。
99
+ * 返回闭开区间 `{ start, end }`;空区间返回 `null`。
100
+ */
101
+ export declare function offsetsFor(doc: NormalizedText, from: number, to: number): {
102
+ start: number;
103
+ end: number;
104
+ } | null;
105
+ /** 原文区间 `[start, end)` 的文本(越界自动截断)。 */
106
+ export declare function sliceRaw(raw: string, start: number, end: number): string;
107
+ /** 原始文本中某个字符偏移对应的 1 基行号。 */
108
+ export declare function lineAt(raw: string, offset: number): number;
109
+ /**
110
+ * 按空行与 Markdown 结构切段。
111
+ *
112
+ * 规则(全部确定性):
113
+ * - 围栏代码块(``` 之间)、标题行、公式行、表格行、引用块、列表项行、
114
+ * 参考文献条目/文献区、模板占位行,一律**不参与**比对;
115
+ * - 连续的非跳过行合并为一个段落,段落边界在空行或跳过行处断开;
116
+ * - 归一化后不足 `minChars` 个字符的段落跳过(返回 `skipped` 便于解释原因)。
117
+ */
118
+ export declare function segmentsFromText(text: string, options: {
119
+ file: string;
120
+ chapter?: string;
121
+ minChars: number;
122
+ }): {
123
+ segments: Segment[];
124
+ skippedShort: number;
125
+ };