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,309 @@
1
+ /**
2
+ * 降重报告的渲染:分级、行号、命中来源、原文片段、处方、全文加权重复率估算,
3
+ * 以及可回填的「学校检测结果」区块。
4
+ *
5
+ * 报告口径(必须与文字一起出现,不得省去):
6
+ * - 本地相似度是**字符串重合度估算**,算法为 shingle 指纹 + 包含率,
7
+ * 只在用户给定的参考语料内比较,**不等于**知网/维普等系统的重复率;
8
+ * - 本插件不接入任何收费查重系统(无账号、也不应接入);学校结果是权威口径,
9
+ * 用户自行送检后可把重复率回填进本报告,用于对齐两套口径的差距。
10
+ *
11
+ * @module dsh-thesis/dedup
12
+ */
13
+ import { planRewrite, renderPlan } from "./rewrite.js";
14
+ const EXCERPT_LIMIT = 120;
15
+ /** 估算口径说明(单一来源,Markdown 与结构化对象共用)。 */
16
+ export const ALGORITHM_NOTE = '字符 4-gram shingle 指纹 + 段内包含率(containment);只在给定参考语料内比较字符串重合,'
17
+ + '不含语义改写、不含跨语言、不含图片/公式。因此本报告的"重复率"是**估算值**,'
18
+ + '与知网/维普等系统的口径、语料、算法都不同,绝不等同于学校检测结果。';
19
+ /** 边界说明(写入报告,避免误用)。 */
20
+ export const BOUNDARY_NOTES = [
21
+ '本工具**不接入**知网、维普、万方等收费查重系统:既没有账号授权,也不应绕过其授权接入。',
22
+ '本地估算只覆盖"用户给定的参考语料"——语料之外的来源不会被发现,语料越全越接近真实风险。',
23
+ '本地估算识别不了同义替换、语序调整、翻译改写等"洗稿"手段,指标低不等于绝对安全。',
24
+ '学校检测结果是权威口径;请把自行送检得到的重复率回填到本报告,用于对齐两套口径的差距。',
25
+ ];
26
+ function clip(text, max) {
27
+ const flat = text.replace(/\s+/g, ' ').trim();
28
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
29
+ }
30
+ /**
31
+ * 全文加权重复率估计(口径明确、可复现):
32
+ * `Σ(命中段落归一化字符数 × 该段最佳命中得分) / 全文参与比对的字符总数`。
33
+ * 分母包含**未命中段落**(按 0 计入),因此该值是保守估计(不会低报)。
34
+ */
35
+ export function weightedRateOf(scanResult) {
36
+ if (scanResult.totalChars === 0)
37
+ return 0;
38
+ let weighted = 0;
39
+ for (const item of scanResult.items) {
40
+ weighted += item.segment.doc.chars.length * item.best.score;
41
+ }
42
+ return weighted / scanResult.totalChars;
43
+ }
44
+ function levelOf(score, threshold) {
45
+ if (score >= threshold)
46
+ return 'high';
47
+ if (score >= threshold * 0.6)
48
+ return 'medium';
49
+ return 'low';
50
+ }
51
+ /** 结构化报告对象即「报告的结构化形态」。 */
52
+ export function buildReport(scanResult, options) {
53
+ const sources = new Set();
54
+ const risks = [];
55
+ for (const item of scanResult.items) {
56
+ const level = levelOf(item.best.score, options.threshold);
57
+ if (level === 'low')
58
+ continue;
59
+ sources.add(item.best.label);
60
+ const plan = planRewrite(item);
61
+ risks.push({
62
+ level,
63
+ chapter: item.segment.chapter,
64
+ file: item.segment.file,
65
+ lineStart: item.segment.lineStart,
66
+ lineEnd: item.segment.lineEnd,
67
+ score: item.best.score,
68
+ source: item.best.label,
69
+ excerpt: clip(item.segment.raw, EXCERPT_LIMIT),
70
+ matches: item.best.spans.map(span => ({
71
+ lineStart: span.lineStart,
72
+ lineEnd: span.lineEnd,
73
+ text: clip(span.text, EXCERPT_LIMIT),
74
+ })),
75
+ plan,
76
+ text: item.segment.raw,
77
+ });
78
+ }
79
+ risks.sort((a, b) => b.score - a.score
80
+ || (a.chapter < b.chapter ? -1 : a.chapter > b.chapter ? 1 : 0)
81
+ || a.lineStart - b.lineStart);
82
+ const report = {
83
+ summary: {
84
+ paragraphCount: scanResult.paragraphCount,
85
+ matchedCount: scanResult.matchedCount,
86
+ high: scanResult.high,
87
+ medium: scanResult.medium,
88
+ low: scanResult.low,
89
+ sourceCount: sources.size,
90
+ weightedRate: weightedRateOf(scanResult),
91
+ },
92
+ risks,
93
+ algorithm: ALGORITHM_NOTE,
94
+ generatedAt: (options.now ?? new Date()).toISOString(),
95
+ boundaries: [...BOUNDARY_NOTES],
96
+ };
97
+ if (options.detected !== undefined) {
98
+ return { ...report, detected: normalizeDetected(options.detected) };
99
+ }
100
+ return report;
101
+ }
102
+ function normalizeDetected(detected) {
103
+ const rate = detected.rate > 1 ? detected.rate / 100 : detected.rate;
104
+ const normalized = { rate: Math.max(0, Math.min(1, rate)) };
105
+ const withSystem = detected.system === undefined ? normalized : { ...normalized, system: detected.system };
106
+ return detected.date === undefined ? withSystem : { ...withSystem, date: detected.date };
107
+ }
108
+ export function levelLabel(level) {
109
+ if (level === 'high')
110
+ return '高';
111
+ if (level === 'medium')
112
+ return '中';
113
+ return '低';
114
+ }
115
+ /** 渲染 Markdown 报告。 */
116
+ export function renderReport(report, options) {
117
+ const lines = [];
118
+ const summary = report.summary;
119
+ lines.push('# 降重报告(本地相似度度量)');
120
+ lines.push('');
121
+ lines.push(`> 工具:thesis_originality;生成时间:${report.generatedAt}`);
122
+ lines.push(`> 参数:shingle=${options.shingle},threshold=${options.threshold},minChars=${options.minChars}`);
123
+ if (options.corpusLabels !== undefined && options.corpusLabels.length > 0) {
124
+ lines.push(`> 参考语料:${options.corpusLabels.join('、')}`);
125
+ }
126
+ lines.push('');
127
+ lines.push('## 0 边界声明(先读这一段)');
128
+ lines.push('');
129
+ for (const note of report.boundaries)
130
+ lines.push(`- ${note}`);
131
+ lines.push('');
132
+ lines.push('## 1 汇总');
133
+ lines.push('');
134
+ lines.push(`- 正文段落:${summary.paragraphCount} 段(跳过标题/代码块/公式行/参考文献/表格与短段落)`);
135
+ lines.push(`- 有命中段落:${summary.matchedCount} 段;高风险 ${summary.high} 段,中风险 ${summary.medium} 段,低风险 ${summary.low} 段`);
136
+ lines.push(`- 命中来源:${summary.sourceCount} 个`);
137
+ lines.push(`- 全文加权重复率**估算|**:${(summary.weightedRate * 100).toFixed(1)}%`);
138
+ lines.push('');
139
+ lines.push(`估算口径:${report.algorithm}`);
140
+ lines.push('');
141
+ lines.push('## 2 Top 风险段落');
142
+ lines.push('');
143
+ const top = report.risks.slice(0, 5);
144
+ if (top.length === 0) {
145
+ lines.push('无中/高风险段落。注意:指标低不等于绝对安全,见第 0 节边界声明。');
146
+ }
147
+ else {
148
+ lines.push('| # | 分级 | 章节 | 行号 | 得分 | 命中来源 |');
149
+ lines.push('|---|---|---|---|---|---|');
150
+ top.forEach((risk, index) => {
151
+ lines.push(`| ${index + 1} | ${levelLabel(risk.level)} | ${risk.chapter} | ${risk.lineStart}-${risk.lineEnd} | ${(risk.score * 100).toFixed(1)}% | ${risk.source} |`);
152
+ });
153
+ lines.push('');
154
+ for (const risk of top)
155
+ lines.push(`${risk.excerpt}`, '');
156
+ }
157
+ lines.push('## 3 高风险段落与改写处方');
158
+ lines.push('');
159
+ const high = report.risks.filter(risk => risk.level === 'high');
160
+ if (high.length === 0) {
161
+ lines.push('无高风险段落(≥ 阈值的段落)。中风险段落见第 4 节。');
162
+ }
163
+ for (const risk of high) {
164
+ lines.push(`**原文(${risk.chapter} 第 ${risk.lineStart}-${risk.lineEnd} 行,${risk.file})**`);
165
+ lines.push('');
166
+ lines.push(`> ${risk.excerpt}`);
167
+ lines.push('');
168
+ lines.push(renderPlan(risk.plan));
169
+ }
170
+ lines.push('## 4 中风险段落(逐条判断后改写)');
171
+ lines.push('');
172
+ const medium = report.risks.filter(risk => risk.level === 'medium');
173
+ if (medium.length === 0) {
174
+ lines.push('无中风险段落。');
175
+ lines.push('');
176
+ }
177
+ else {
178
+ lines.push('| 章节 | 行号 | 得分 | 命中来源 | 命中片段 |');
179
+ lines.push('|---|---|---|---|---|');
180
+ for (const risk of medium) {
181
+ const span = risk.matches[0];
182
+ lines.push(`| ${risk.chapter} | ${risk.lineStart}-${risk.lineEnd} | ${(risk.score * 100).toFixed(1)}% | ${risk.source} | ${span === undefined ? '(碎片化重合)' : clip(span.text, 40)} |`);
183
+ }
184
+ lines.push('');
185
+ }
186
+ lines.push('## 5 学校检测结果(用户自行送检后回填)');
187
+ lines.push('');
188
+ if (report.detected === undefined) {
189
+ lines.push('- 学校/检测系统名称:________');
190
+ lines.push('- 送检日期:________');
191
+ lines.push('- 检测重复率:________%');
192
+ lines.push('- 与本地估算的差距说明:________');
193
+ lines.push('');
194
+ lines.push('回填方式:把上述数字作为 `thesis_originality` 的 `detected_rate` 重新执行 `report`,本区块会自动填好。');
195
+ }
196
+ else {
197
+ const detected = report.detected;
198
+ const gap = detected.rate - summary.weightedRate;
199
+ lines.push(`- 检测系统:${detected.system ?? '(未填写)'}`);
200
+ lines.push(`- 送检日期:${detected.date ?? '(未填写)'}`);
201
+ lines.push(`- 检测重复率:${(detected.rate * 100).toFixed(1)}%(权威口径)`);
202
+ lines.push(`- 本地估算:${(summary.weightedRate * 100).toFixed(1)}%`);
203
+ lines.push(`- 差距:${gap >= 0 ? '+' : ''}${(gap * 100).toFixed(1)} 个百分点(本地${gap >= 0 ? '偏低' : '偏高'})`);
204
+ lines.push('');
205
+ lines.push('差距常见原因:本地语料不含学校比对库;同义改写/翻译改写无法被字符串指纹发现。'
206
+ + '若学校结果明显更高,请扩充参考语料(把可能的来源文本放进语料目录)并重跑 scan。');
207
+ }
208
+ lines.push('');
209
+ lines.push('## 6 下一步建议');
210
+ lines.push('');
211
+ lines.push('1. 先处理高风险段落:按处方改结构(拆句、换语序、主被动),保留全部数字与引用。');
212
+ lines.push('2. 命中来源若为文献笔记,改为直接引用 + `[n]` 标注,不要改写成自己的话。');
213
+ lines.push('3. 改写完成后执行 `thesis_originality` `verify`,用本次报告作 `baseline` 复测降幅。');
214
+ lines.push('4. 送检后把真实重复率回填(`detected_rate`),对齐本地估算与学校口径。');
215
+ lines.push('');
216
+ return lines.join('\n');
217
+ }
218
+ /** 从 baseline 报告(Markdown)解析出汇总指标;解析失败返回 null。 */
219
+ export function parseBaseline(markdown) {
220
+ const paragraph = /正文段落:(\d+)\s*段/.exec(markdown);
221
+ const matched = /有命中段落:(\d+)\s*段/.exec(markdown);
222
+ const high = /高风险\s*(\d+)\s*段/.exec(markdown);
223
+ const medium = /中风险\s*(\d+)\s*段/.exec(markdown);
224
+ const rate = /全文加权重复率\*\*估算|\*\*:([0-9.]+)%/.exec(markdown);
225
+ if (paragraph === null || matched === null || rate === null)
226
+ return null;
227
+ return {
228
+ paragraphCount: Number(paragraph[1]),
229
+ matchedCount: Number(matched[1]),
230
+ high: high === null ? 0 : Number(high[1]),
231
+ medium: medium === null ? 0 : Number(medium[1]),
232
+ weightedRate: Number(rate[1]) / 100,
233
+ };
234
+ }
235
+ /** 计算复测降幅(正数 = 下降)。 */
236
+ export function verifyDelta(baseline, current) {
237
+ const rateDrop = baseline.weightedRate - current.weightedRate;
238
+ const rateDropRatio = baseline.weightedRate === 0 ? 0 : rateDrop / baseline.weightedRate;
239
+ let verdict;
240
+ if (current.high > 0) {
241
+ verdict = `仍有 ${current.high} 段高风险:继续按处方改写后再次 verify。`;
242
+ }
243
+ else if (rateDrop <= 0 && baseline.weightedRate > 0) {
244
+ verdict = '加权重复率未下降:本次改写没有改变指纹重合(常见原因是只换同义词),请改结构后复测。';
245
+ }
246
+ else if (rateDropRatio >= 0.5) {
247
+ verdict = '降幅达标(≥50%):保持当前写法,进入下一轮自查(AI 味 + 引用检查)。';
248
+ }
249
+ else {
250
+ verdict = '降幅有限:优先处理剩余中风险段落,或把命中来源改为直接引用。';
251
+ }
252
+ return {
253
+ baseline,
254
+ current,
255
+ rateDrop,
256
+ rateDropRatio,
257
+ highDrop: baseline.high - current.high,
258
+ mediumDrop: baseline.medium - current.medium,
259
+ verdict,
260
+ };
261
+ }
262
+ /** 渲染复测报告(降重报告-复测.md)。 */
263
+ export function renderVerifyReport(delta, options, riskSource) {
264
+ const lines = [];
265
+ lines.push('# 降重复测报告');
266
+ lines.push('');
267
+ lines.push(`> 工具:thesis_originality verify;生成时间:${riskSource.generatedAt}`);
268
+ lines.push(`> 参数:shingle=${options.shingle},threshold=${options.threshold},minChars=${options.minChars}`);
269
+ lines.push('');
270
+ lines.push('## 1 降幅');
271
+ lines.push('');
272
+ lines.push('| 指标 | 上次(baseline) | 本次(复测) | 变化 |');
273
+ lines.push('|---|---|---|---|');
274
+ lines.push(`| 正文段落 | ${delta.baseline.paragraphCount} | ${delta.current.paragraphCount} | ${signed(delta.current.paragraphCount - delta.baseline.paragraphCount)} |`);
275
+ lines.push(`| 有命中段落 | ${delta.baseline.matchedCount} | ${delta.current.matchedCount} | ${signed(delta.current.matchedCount - delta.baseline.matchedCount)} |`);
276
+ lines.push(`| 高风险段落 | ${delta.baseline.high} | ${delta.current.high} | ${signed(-delta.highDrop)} |`);
277
+ lines.push(`| 中风险段落 | ${delta.baseline.medium} | ${delta.current.medium} | ${signed(-delta.mediumDrop)} |`);
278
+ lines.push(`| 加权重复率估算 | ${(delta.baseline.weightedRate * 100).toFixed(1)}% | ${(delta.current.weightedRate * 100).toFixed(1)}% | ${signed(-delta.rateDrop * 100)} 个百分点 |`);
279
+ lines.push('');
280
+ lines.push(`- 加权重复率降幅:**${(delta.rateDrop * 100).toFixed(1)} 个百分点**(相对降幅 ${(delta.rateDropRatio * 100).toFixed(1)}%)`);
281
+ lines.push(`- 结论:${delta.verdict}`);
282
+ lines.push('');
283
+ lines.push(`口径:${riskSource.algorithm}`);
284
+ lines.push('');
285
+ lines.push('## 2 仍中/高风险段落');
286
+ lines.push('');
287
+ const remaining = riskSource.risks.filter(risk => risk.level !== 'low');
288
+ if (remaining.length === 0) {
289
+ lines.push('无。');
290
+ }
291
+ else {
292
+ lines.push('| 分级 | 章节 | 行号 | 得分 | 命中来源 |');
293
+ lines.push('|---|---|---|---|---|');
294
+ for (const risk of remaining) {
295
+ lines.push(`| ${levelLabel(risk.level)} | ${risk.chapter} | ${risk.lineStart}-${risk.lineEnd} | ${(risk.score * 100).toFixed(1)}% | ${risk.source} |`);
296
+ }
297
+ }
298
+ lines.push('');
299
+ lines.push('## 3 边界声明');
300
+ lines.push('');
301
+ for (const note of riskSource.boundaries)
302
+ lines.push(`- ${note}`);
303
+ lines.push('');
304
+ return lines.join('\n');
305
+ }
306
+ function signed(value) {
307
+ const rounded = Math.round(value * 10) / 10;
308
+ return `${rounded > 0 ? '+' : ''}${rounded}`;
309
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 降重的「改写处方」:确定性规则,不调用 LLM。
3
+ *
4
+ * 对每个高风险段落产出结构化处方:
5
+ * 1. **必须保留的要素** —— 数字与单位、引用标记 `[n]`、代码标识符、双引号术语。
6
+ * 降重绝不能靠改数据、改术语、删引用来实现。
7
+ * 2. **可替换的句式** —— 逐句给出候选结构:拆短长句、主被动转换、并列改递进、
8
+ * 删除冗余连接词、调整语序(保留全部要素、只动结构)。
9
+ * 3. **引用化位置** —— 来源是文献笔记/他人段落时,提示改为直接引用 + 标注。
10
+ * 4. **禁止事项** —— 不得为了降重改动数据与结论,不得改动专业术语与引文。
11
+ *
12
+ * @module dsh-thesis/dedup
13
+ */
14
+ import type { RiskItem } from './similarity.ts';
15
+ /** 必须原样保留的要素。 */
16
+ export interface ProtectedElement {
17
+ /** `number` 数字与单位 | `citation` 引用标记 | `code` 代码标识符 | `term` 双引号术语。 */
18
+ readonly kind: 'number' | 'citation' | 'code' | 'term';
19
+ readonly text: string;
20
+ /** 在归一化段文本中的起始下标。 */
21
+ readonly index: number;
22
+ }
23
+ /** 一条句式改写候选。 */
24
+ export interface RewriteSuggestion {
25
+ /** `split` 拆短 | `voice` 转换 | `connector` 改递进 | `delete` 删冗余 | `reorder` 调整语序。 */
26
+ readonly kind: 'split' | 'voice' | 'connector' | 'delete' | 'reorder';
27
+ readonly target: string;
28
+ readonly detail: string;
29
+ }
30
+ /** 引用化建议(命中来源不是自己原创时)。 */
31
+ export interface CitationSuggestion {
32
+ readonly needed: boolean;
33
+ readonly source: string;
34
+ readonly detail: string;
35
+ }
36
+ /** 命中片段(原文坐标,供用户定位)。 */
37
+ export interface MatchExcerpt {
38
+ readonly length: number;
39
+ readonly text: string;
40
+ readonly lineStart: number;
41
+ readonly lineEnd: number;
42
+ }
43
+ export interface RewritePlan {
44
+ /** 章节/文件标识。 */
45
+ readonly chapter: string;
46
+ readonly lineStart: number;
47
+ readonly lineEnd: number;
48
+ readonly score: number;
49
+ readonly source: string;
50
+ /** 命中片段(原文坐标)。 */
51
+ readonly matches: readonly MatchExcerpt[];
52
+ /** 必须保留的要素(去重后按出现顺序)。 */
53
+ readonly mustKeep: readonly ProtectedElement[];
54
+ /** 可替换的句式。 */
55
+ readonly suggestions: readonly RewriteSuggestion[];
56
+ readonly citation: CitationSuggestion;
57
+ /** 禁止事项(含数据/结论红线)。 */
58
+ readonly forbidden: readonly string[];
59
+ /** 改写锚点:先换结构再换词,改完用 thesis_originality verify 复测。 */
60
+ readonly anchors: readonly string[];
61
+ }
62
+ /** 识别必须保留的要素(数字/单位、引用标记、代码标识符、双引号术语)。 */
63
+ export declare function protectedElements(text: string): ProtectedElement[];
64
+ /** 逐句生成候选句式(确定性;不调用 LLM,也不做同义词替换)。 */
65
+ export declare function sentenceSuggestions(rawText: string): RewriteSuggestion[];
66
+ /** 单个高风险段落 → 改写处方。 */
67
+ export declare function planRewrite(risk: RiskItem): RewritePlan;
68
+ /** 处方对象 → Markdown 片段。 */
69
+ export declare function renderPlan(plan: RewritePlan): string;
70
+ /** 批量渲染处方(报告用)。 */
71
+ export declare function renderPlans(plans: readonly RewritePlan[]): string[];
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 降重的「改写处方」:确定性规则,不调用 LLM。
3
+ *
4
+ * 对每个高风险段落产出结构化处方:
5
+ * 1. **必须保留的要素** —— 数字与单位、引用标记 `[n]`、代码标识符、双引号术语。
6
+ * 降重绝不能靠改数据、改术语、删引用来实现。
7
+ * 2. **可替换的句式** —— 逐句给出候选结构:拆短长句、主被动转换、并列改递进、
8
+ * 删除冗余连接词、调整语序(保留全部要素、只动结构)。
9
+ * 3. **引用化位置** —— 来源是文献笔记/他人段落时,提示改为直接引用 + 标注。
10
+ * 4. **禁止事项** —— 不得为了降重改动数据与结论,不得改动专业术语与引文。
11
+ *
12
+ * @module dsh-thesis/dedup
13
+ */
14
+ const FORBIDDEN = [
15
+ '不得为了降重改动任何数据、单位、图号、表号或测试结果(改动即学术不端)',
16
+ '不得为了降重改动结论、删减论证或删除必要引用',
17
+ '不得改动专业术语、专有名词与文献引文(引文另有规范,不能"改写")',
18
+ '不得删除引用标注 [n] 或把引文改写成自己的话而不标注',
19
+ '不得用同义词回填(同义词替换不改变指纹重合,且会破坏术语一致性)',
20
+ ];
21
+ const ANCHORS = [
22
+ '先改结构(拆句/换语序/换主被动),再改措辞——结构变了,指标才会真正下降',
23
+ '改写后跑 thesis_originality verify,用 baseline 对比降幅,别凭感觉判断',
24
+ '命中片段若来自文献笔记,改为"直接引用 + [n] 标注"比改写更安全',
25
+ ];
26
+ function pushUnique(list, element) {
27
+ if (list.some(item => item.text === element.text))
28
+ return;
29
+ list.push(element);
30
+ }
31
+ /** 识别必须保留的要素(数字/单位、引用标记、代码标识符、双引号术语)。 */
32
+ export function protectedElements(text) {
33
+ const found = [];
34
+ for (const m of text.matchAll(/[0-9]+(?:\.[0-9]+)*(?:%|‰|[万亿千百]?[年月日个次条项分秒人台例篇倍种]|[a-zA-Z]{1,8})?/g)) {
35
+ pushUnique(found, { kind: 'number', text: m[0], index: m.index ?? 0 });
36
+ }
37
+ for (const m of text.matchAll(/\[\d+(?:\s*[-,]\s*\d+)*\]/g)) {
38
+ pushUnique(found, { kind: 'citation', text: m[0], index: m.index ?? 0 });
39
+ }
40
+ for (const m of text.matchAll(/`[^`\n]{1,40}`/g)) {
41
+ pushUnique(found, { kind: 'code', text: m[0], index: m.index ?? 0 });
42
+ }
43
+ for (const m of text.matchAll(/\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b/g)) {
44
+ pushUnique(found, { kind: 'code', text: m[0], index: m.index ?? 0 });
45
+ }
46
+ for (const m of text.matchAll(/[a-z]+[A-Z][A-Za-z0-9]*/g)) {
47
+ pushUnique(found, { kind: 'code', text: m[0], index: m.index ?? 0 });
48
+ }
49
+ for (const m of text.matchAll(/[A-Za-z][A-Za-z0-9]*\(\)/g)) {
50
+ pushUnique(found, { kind: 'code', text: m[0], index: m.index ?? 0 });
51
+ }
52
+ for (const m of text.matchAll(/[""]([^""\n]{2,30})[""]/g)) {
53
+ pushUnique(found, { kind: 'term', text: m[1], index: (m.index ?? 0) + 1 });
54
+ }
55
+ return found.sort((a, b) => a.index - b.index);
56
+ }
57
+ const SPLIT_MARKERS = [';', ';', ',而', ',而', ',同时', ',并且', ',进而', ',从而'];
58
+ function clip(text, max) {
59
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
60
+ }
61
+ /** 逐句生成候选句式(确定性;不调用 LLM,也不做同义词替换)。 */
62
+ export function sentenceSuggestions(rawText) {
63
+ const suggestions = [];
64
+ const sentences = rawText
65
+ .split(/(?<=[。!?!?])|(?<=\.\s)/)
66
+ .map(s => s.trim())
67
+ .filter(s => s.length > 0);
68
+ for (const sentence of sentences) {
69
+ const marker = SPLIT_MARKERS.find(m => sentence.includes(m));
70
+ if (sentence.length >= 40 || marker !== undefined) {
71
+ suggestions.push({
72
+ kind: 'split',
73
+ target: clip(sentence, 40),
74
+ detail: '拆成 2-3 句:每句只留一个主谓结构,中间用"因此/其中/在此基础上"衔接,句间加主语。',
75
+ });
76
+ }
77
+ const passive = /被[^,。;]{1,20}(用于|采用|实现|提出|设计|完成)/.exec(sentence);
78
+ if (passive !== null) {
79
+ suggestions.push({
80
+ kind: 'voice',
81
+ target: passive[0],
82
+ detail: '主被动转换:把施动者提到句首("本文采用…"/"系统通过…实现…"),去掉"被"字结构。',
83
+ });
84
+ }
85
+ const parallel = /不仅[^,。]{1,40}而且/.exec(sentence) ?? /既[^,。]{1,40}又/.exec(sentence);
86
+ if (parallel !== null) {
87
+ suggestions.push({
88
+ kind: 'connector',
89
+ target: parallel[0],
90
+ detail: '并列改递进:改成"在…基础上进一步…",让两个分句体现推进关系而不是简单罗列。',
91
+ });
92
+ }
93
+ if (marker !== undefined) {
94
+ suggestions.push({
95
+ kind: 'connector',
96
+ target: marker,
97
+ detail: '长并列常靠连接词堆叠;改成短句 + 分号或句号,删掉可推断的连接词。',
98
+ });
99
+ }
100
+ for (const filler of ['综上所述', '总而言之', '值得注意的是', '众所周知', '不难发现', '显而易见', '在当今', '近年来']) {
101
+ if (sentence.includes(filler)) {
102
+ suggestions.push({
103
+ kind: 'delete',
104
+ target: filler,
105
+ detail: '删除该冗余连接词;套话既不加分也不降重,只会被判定为模板化表达。',
106
+ });
107
+ }
108
+ }
109
+ const lead = /^(基于|针对|随着|通过)[^,。]{1,24}[,,]/.exec(sentence);
110
+ if (lead !== null) {
111
+ suggestions.push({
112
+ kind: 'reorder',
113
+ target: lead[0],
114
+ detail: '调整语序:把结论或对象提到句首("…(对象)在…(条件)下表现为…"),避免全段同一开头。',
115
+ });
116
+ }
117
+ }
118
+ if (suggestions.length === 0) {
119
+ suggestions.push({
120
+ kind: 'reorder',
121
+ target: clip(rawText, 40),
122
+ detail: '本段无长句/被动/套话特征,按"结论先行 + 证据后置"重排语序,并补一个具体锚点(数字/图表/案例)。',
123
+ });
124
+ }
125
+ return suggestions;
126
+ }
127
+ function isExternal(label) {
128
+ return /文献|笔记|refs\.bib|\.bib|paper|arxiv|知网|万方|维普|他人|references/i.test(label);
129
+ }
130
+ function citationAdvice(label) {
131
+ if (isExternal(label)) {
132
+ return {
133
+ needed: true,
134
+ source: label,
135
+ detail: `命中来源「${label}」属于文献笔记/外部材料:应改为直接引用(引号 + 逐字照录)并在句末标注 [n],而不是把别人的话改写成自己的表述。`,
136
+ };
137
+ }
138
+ return {
139
+ needed: false,
140
+ source: label,
141
+ detail: `命中来源「${label}」在本文内部:正常做法是改写这段并交叉引用("如第 x 节所述")或删除重复表述,不必再加引用标注。`,
142
+ };
143
+ }
144
+ function toExcerpts(spans) {
145
+ return spans.map(span => ({
146
+ length: span.length,
147
+ text: span.text,
148
+ lineStart: span.lineStart,
149
+ lineEnd: span.lineEnd,
150
+ }));
151
+ }
152
+ /** 单个高风险段落 → 改写处方。 */
153
+ export function planRewrite(risk) {
154
+ const rawText = risk.segment.raw;
155
+ const citation = citationAdvice(risk.best.label);
156
+ return {
157
+ chapter: risk.segment.chapter,
158
+ lineStart: risk.segment.lineStart,
159
+ lineEnd: risk.segment.lineEnd,
160
+ score: risk.best.score,
161
+ source: risk.best.label,
162
+ matches: toExcerpts(risk.best.spans),
163
+ mustKeep: protectedElements(rawText),
164
+ suggestions: sentenceSuggestions(rawText),
165
+ citation,
166
+ forbidden: [...FORBIDDEN],
167
+ anchors: [...ANCHORS],
168
+ };
169
+ }
170
+ /** 处方对象 → Markdown 片段。 */
171
+ export function renderPlan(plan) {
172
+ const lines = [];
173
+ lines.push(`### ${plan.chapter} 第 ${plan.lineStart}-${plan.lineEnd} 行(相似度 ${(plan.score * 100).toFixed(1)}%)`);
174
+ lines.push('');
175
+ lines.push(`- 命中来源:${plan.source}`);
176
+ if (plan.matches.length === 0) {
177
+ lines.push('- 命中片段:(碎片化重合,无连续长片段)');
178
+ }
179
+ else {
180
+ lines.push(`- 命中片段(连续重合 ${plan.matches[0].length} 字起):`);
181
+ for (const match of plan.matches) {
182
+ lines.push(` - 第 ${match.lineStart}-${match.lineEnd} 行:${clip(match.text.replace(/\n/g, ' '), 120)}`);
183
+ }
184
+ }
185
+ lines.push('');
186
+ if (plan.mustKeep.length === 0) {
187
+ lines.push('**必须保留**:本段未检出数字/引用/代码标识符;专业术语与专有名词仍须原样保留。');
188
+ }
189
+ else {
190
+ lines.push('**必须保留(改写时不得改动)**:');
191
+ for (const element of plan.mustKeep) {
192
+ const label = element.kind === 'number' ? '数字/单位' : element.kind === 'citation' ? '引用标记' : element.kind === 'code' ? '代码标识符' : '术语';
193
+ lines.push(`- [${label}] \`${element.text}\``);
194
+ }
195
+ }
196
+ lines.push('');
197
+ lines.push('**可替换的句式**:');
198
+ for (const suggestion of plan.suggestions) {
199
+ lines.push(`- (${suggestionKindLabel(suggestion.kind)})\`${suggestion.target}\` → ${suggestion.detail}`);
200
+ }
201
+ lines.push('');
202
+ lines.push(`**引用化位置**:${plan.citation.needed ? '需要' : '不需要'}。${plan.citation.detail}`);
203
+ lines.push('');
204
+ lines.push('**禁止事项**:');
205
+ for (const item of plan.forbidden)
206
+ lines.push(`- ${item}`);
207
+ lines.push('');
208
+ lines.push('**改写锚点**:');
209
+ for (const item of plan.anchors)
210
+ lines.push(`- ${item}`);
211
+ lines.push('');
212
+ return lines.join('\n');
213
+ }
214
+ function suggestionKindLabel(kind) {
215
+ if (kind === 'split')
216
+ return '拆短长句';
217
+ if (kind === 'voice')
218
+ return '主被动转换';
219
+ if (kind === 'connector')
220
+ return '并列改递进/删连接词';
221
+ if (kind === 'delete')
222
+ return '删冗余连接词';
223
+ return '调整语序';
224
+ }
225
+ /** 批量渲染处方(报告用)。 */
226
+ export function renderPlans(plans) {
227
+ return plans.map(plan => renderPlan(plan));
228
+ }