dsh-thesis 2.0.2 → 2.1.1

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 (52) hide show
  1. package/README.md +5 -4
  2. package/docs/config.md +12 -0
  3. package/docs/design.md +95 -2
  4. package/docs/release.md +94 -85
  5. package/docs/tools.md +8 -5
  6. package/docs/troubleshooting.md +18 -0
  7. package/docs/usage.md +4 -1
  8. package/lib/config.d.ts +13 -0
  9. package/lib/config.js +8 -1
  10. package/lib/figure/actions.d.ts +83 -0
  11. package/lib/figure/actions.js +439 -0
  12. package/lib/figure/index.d.ts +24 -0
  13. package/lib/figure/index.js +40 -0
  14. package/lib/figure/lib/check.d.ts +83 -0
  15. package/lib/figure/lib/check.js +214 -0
  16. package/lib/figure/lib/drawio.d.ts +123 -0
  17. package/lib/figure/lib/drawio.js +156 -0
  18. package/lib/figure/lib/export.d.ts +120 -0
  19. package/lib/figure/lib/export.js +308 -0
  20. package/lib/figure/lib/layout.d.ts +78 -0
  21. package/lib/figure/lib/layout.js +496 -0
  22. package/lib/figure/lib/spec.d.ts +114 -0
  23. package/lib/figure/lib/spec.js +290 -0
  24. package/lib/index.d.ts +1 -0
  25. package/lib/index.js +5 -1
  26. package/lib/paper/lib/check.d.ts +5 -0
  27. package/lib/paper/lib/check.js +21 -0
  28. package/lib/paper/lib/docx.d.ts +26 -0
  29. package/lib/paper/lib/docx.js +190 -27
  30. package/lib/paper/lib/image.d.ts +19 -0
  31. package/lib/paper/lib/image.js +82 -0
  32. package/lib/paper/lib/layout.js +26 -1
  33. package/lib/paper/lib/lit-refs.d.ts +30 -0
  34. package/lib/paper/lib/lit-refs.js +394 -0
  35. package/lib/paper/lib/lit-verify.d.ts +79 -0
  36. package/lib/paper/lib/lit-verify.js +556 -0
  37. package/lib/paper/register.d.ts +1 -1
  38. package/lib/paper/register.js +37 -1
  39. package/lib/paper/tools/build.d.ts +4 -0
  40. package/lib/paper/tools/build.js +79 -4
  41. package/lib/paper/tools/lit-verify.d.ts +35 -0
  42. package/lib/paper/tools/lit-verify.js +193 -0
  43. package/lib/ppt/check.js +5 -0
  44. package/lib/ppt/design.d.ts +86 -0
  45. package/lib/ppt/design.js +218 -0
  46. package/package.json +4 -1
  47. package/skills/thesis-defense/SKILL.md +25 -1
  48. package/skills/thesis-eng-design/SKILL.md +40 -20
  49. package/skills/thesis-literature/SKILL.md +28 -5
  50. package/skills/thesis-opener/SKILL.md +1 -1
  51. package/skills/thesis-pipeline/SKILL.md +4 -3
  52. package/skills/thesis-writing/SKILL.md +4 -1
@@ -0,0 +1,556 @@
1
+ /**
2
+ * 文献核验:把「候选文献」(AI 研究助手导出、粘贴、学长给的 pdf 清单)逐条
3
+ * 拿到权威库对账,只有对得上的才允许进 refs.bib。
4
+ *
5
+ * 为什么需要这一层:Undermind / Elicit / Consensus 这类助手是**发现**工具,
6
+ * 它们的导出里既有真论文,也有识别错误的题名、错位的年份、编造的 DOI。
7
+ * 「零假文献」不能靠"我信任这个来源",只能靠**逐条对账 + 失败即拒**。
8
+ *
9
+ * 权威库(全部免费、无需 key):
10
+ * - Crossref https://api.crossref.org/works/{doi} 与 ?query.bibliographic=
11
+ * - OpenAlex https://api.openalex.org/works/doi:{doi} 与 ?search=
12
+ * - DataCite https://api.datacite.org/dois/{doi}(数据集/学位论文/预印本)
13
+ * - PubMed https://eutils.ncbi.nlm.nih.gov/entrez/eutils/(生物医学)
14
+ *
15
+ * 判定口径(写死,不给"感觉差不多"留口子):
16
+ * - verified:权威库存在该文献,且题名相似度 ≥0.92、第一作者姓氏对得上、年份差 ≤1;
17
+ * - mismatch:权威库有记录但题名/作者/年份冲突(多半是张冠李戴,须人工看原文);
18
+ * - not-found:权威库查无此文(DOI 不存在 / 题名检索无命中)——**疑似编造**;
19
+ * - unchecked:网络不可用或库限流,本轮没核验(fail closed:不许入库)。
20
+ */
21
+ import { makeId } from "./lit-api.js";
22
+ export const VERIFY_REPORT_REL = '08-合规/文献核验报告.md';
23
+ const REQUEST_TIMEOUT_MS = 15000;
24
+ const TITLE_VERIFIED = 0.92;
25
+ const TITLE_CANDIDATE = 0.75;
26
+ // ---------------------------------------------------------------------------
27
+ // 归一化与相似度(纯函数,可独立单测)
28
+ // ---------------------------------------------------------------------------
29
+ /** 去掉 `https://doi.org/`、`doi:` 前缀与空白;统一小写。不是合法 DOI 形状时返回 undefined。 */
30
+ export function normalizeDoi(raw) {
31
+ if (raw === undefined)
32
+ return undefined;
33
+ const text = raw
34
+ .trim()
35
+ .replace(/^doi\s*:\s*/i, '')
36
+ .replace(/^https?:\/\/(dx\.)?doi\.org\//i, '')
37
+ .replace(/^https?:\/\/doi\.org\//i, '')
38
+ .replace(/[.\s]+$/, '');
39
+ return text === '' ? undefined : text.toLowerCase();
40
+ }
41
+ export function doiShapeValid(doi) {
42
+ return /^10\.\d{4,9}\/\S+$/.test(doi);
43
+ }
44
+ /** 题名归一化:去重音、去标点、压缩空白、小写。中文按字切分。 */
45
+ export function normalizeTitle(raw) {
46
+ return raw
47
+ .normalize('NFKD')
48
+ .replace(/[\u0300-\u036f]/g, '')
49
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
50
+ .replace(/\s+/g, ' ')
51
+ .trim()
52
+ .toLowerCase();
53
+ }
54
+ /** 题名相似度:中文按双字 shingle,西文按词,取 Dice 系数。 */
55
+ export function titleSimilarity(a, b) {
56
+ const ta = titleTokens(a);
57
+ const tb = titleTokens(b);
58
+ if (ta.length === 0 || tb.length === 0)
59
+ return 0;
60
+ const setB = new Map();
61
+ for (const token of tb)
62
+ setB.set(token, (setB.get(token) ?? 0) + 1);
63
+ let hit = 0;
64
+ for (const token of ta) {
65
+ const n = setB.get(token) ?? 0;
66
+ if (n > 0) {
67
+ hit += 1;
68
+ setB.set(token, n - 1);
69
+ }
70
+ }
71
+ return (2 * hit) / (ta.length + tb.length);
72
+ }
73
+ function titleTokens(raw) {
74
+ const text = normalizeTitle(raw);
75
+ if (text === '')
76
+ return [];
77
+ const words = text.split(' ');
78
+ const hasCjk = /[\u3400-\u9fff]/.test(text);
79
+ if (!hasCjk)
80
+ return words;
81
+ // 中文:连续汉字取双字 shingle(单字题名退化为单字)。
82
+ const tokens = [];
83
+ for (const word of words) {
84
+ if (!/[\u3400-\u9fff]/.test(word)) {
85
+ tokens.push(word);
86
+ continue;
87
+ }
88
+ if (word.length === 1)
89
+ tokens.push(word);
90
+ else
91
+ for (let i = 0; i < word.length - 1; i += 1)
92
+ tokens.push(word.slice(i, i + 2));
93
+ }
94
+ return tokens;
95
+ }
96
+ /** 姓氏:`Zhang, Wei` 取逗号前;西文取最后一个词;中文姓名取第一个字;去掉 Jr./van 等缀词。 */
97
+ export function familyName(author) {
98
+ const cleaned = author.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[.]/g, ' ').trim();
99
+ if (cleaned === '')
100
+ return '';
101
+ if (/[\u3400-\u9fff]/.test(cleaned))
102
+ return cleaned.replace(/[\s,]+/g, '').slice(0, 1);
103
+ // "Zhang, Wei" / "Zhang, W.":逗号前就是姓。
104
+ const comma = cleaned.indexOf(',');
105
+ if (comma > 0) {
106
+ const before = cleaned.slice(0, comma).trim();
107
+ if (before !== '')
108
+ return before.split(/\s+/).at(-1).toLowerCase();
109
+ }
110
+ const parts = cleaned.split(/\s+/).filter(p => p !== '' && !/^(jr|sr|ii|iii|iv|van|von|de|der|da|bin|al)$/i.test(p));
111
+ return (parts.at(-1) ?? '').toLowerCase();
112
+ }
113
+ function authorOverlap(claimed, registered) {
114
+ const reg = new Set(registered.map(familyName).filter(n => n !== ''));
115
+ const own = claimed.map(familyName).filter(n => n !== '');
116
+ const matched = own.filter(n => reg.has(n)).length;
117
+ return { matched, total: own.length };
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // 启发式本地检查(不联网就能挡掉一批脏数据)
121
+ // ---------------------------------------------------------------------------
122
+ const PLACEHOLDER = /^(待补|待定|todo|tbd|xxx+|test|example|示例|样例|无|none|null|n\/a)$/i;
123
+ function localChecks(candidate) {
124
+ const checks = [];
125
+ const title = candidate.title.trim();
126
+ if (title === '' || PLACEHOLDER.test(title) || title.length < 6) {
127
+ checks.push({ field: '题名', claimed: title === '' ? '(空)' : title, registered: '需要一个真实题名', severity: 'fail' });
128
+ }
129
+ if (candidate.doi !== undefined && !doiShapeValid(candidate.doi)) {
130
+ checks.push({ field: 'DOI', claimed: candidate.doi, registered: '形如 10.1145/xxxx.xxxx', severity: 'fail' });
131
+ }
132
+ const year = candidate.year;
133
+ const maxYear = new Date().getFullYear() + 1;
134
+ if (year !== null && (year < 1900 || year > maxYear)) {
135
+ checks.push({ field: '年份', claimed: String(year), registered: `应在 1900–${maxYear}`, severity: 'fail' });
136
+ }
137
+ if (candidate.authors.length === 0) {
138
+ checks.push({ field: '作者', claimed: '(空)', registered: '需要作者姓名', severity: 'warn' });
139
+ }
140
+ else if (candidate.authors.every(a => /^(et\s*al\.?|等|others)$/i.test(a.trim()))) {
141
+ checks.push({ field: '作者', claimed: candidate.authors.join(', '), registered: '需要具体姓名', severity: 'warn' });
142
+ }
143
+ if (candidate.venue.trim() === '') {
144
+ checks.push({ field: '出处', claimed: '(空)', registered: '期刊/会议/出版者', severity: 'warn' });
145
+ }
146
+ return checks;
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // 与权威记录逐字段对账
150
+ // ---------------------------------------------------------------------------
151
+ function compareWithRecord(candidate, record) {
152
+ const checks = [];
153
+ const score = titleSimilarity(candidate.title, record.title);
154
+ checks.push({
155
+ field: '题名',
156
+ claimed: candidate.title,
157
+ registered: record.title,
158
+ severity: score >= TITLE_VERIFIED ? 'ok' : score >= TITLE_CANDIDATE ? 'warn' : 'fail',
159
+ });
160
+ const overlap = authorOverlap(candidate.authors, record.authors);
161
+ const firstOk = candidate.authors.length > 0 && record.authors.length > 0
162
+ && familyName(candidate.authors[0]) === familyName(record.authors[0]);
163
+ checks.push({
164
+ field: '作者',
165
+ claimed: candidate.authors.join(', ') || '(空)',
166
+ registered: record.authors.join(', ') || '(空)',
167
+ severity: candidate.authors.length === 0 || record.authors.length === 0
168
+ ? 'warn'
169
+ : firstOk || overlap.matched >= Math.ceil(overlap.total / 2)
170
+ ? 'ok'
171
+ : 'fail',
172
+ });
173
+ if (candidate.year !== null && record.year !== null) {
174
+ const gap = Math.abs(candidate.year - record.year);
175
+ checks.push({
176
+ field: '年份',
177
+ claimed: String(candidate.year),
178
+ registered: String(record.year),
179
+ severity: gap === 0 ? 'ok' : gap === 1 ? 'warn' : 'fail',
180
+ });
181
+ }
182
+ if (candidate.doi !== undefined && record.doi !== undefined) {
183
+ const claimedDoi = normalizeDoi(candidate.doi);
184
+ const registeredDoi = normalizeDoi(record.doi);
185
+ checks.push({
186
+ field: 'DOI',
187
+ claimed: claimedDoi ?? candidate.doi,
188
+ registered: registeredDoi ?? record.doi,
189
+ severity: claimedDoi === registeredDoi ? 'ok' : 'fail',
190
+ });
191
+ }
192
+ if (candidate.venue !== '' && record.venue !== '') {
193
+ const sim = titleSimilarity(candidate.venue, record.venue);
194
+ checks.push({ field: '出处', claimed: candidate.venue, registered: record.venue, severity: sim > 0.2 ? 'ok' : 'warn' });
195
+ }
196
+ const status = checks.some(c => c.severity === 'fail') ? 'mismatch' : 'verified';
197
+ return { checks, score, status };
198
+ }
199
+ async function getJson(fetchImpl, url, signal) {
200
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
201
+ const merged = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
202
+ try {
203
+ const res = await fetchImpl(url, { signal: merged });
204
+ if (!res.ok)
205
+ return { status: res.status, ok: false, body: undefined };
206
+ return { status: res.status, ok: true, body: await res.json() };
207
+ }
208
+ catch (error) {
209
+ return { status: 0, ok: false, body: undefined, error: error instanceof Error ? error.message : String(error) };
210
+ }
211
+ }
212
+ function guessType(hint, venue) {
213
+ const text = `${hint} ${venue}`.toLowerCase();
214
+ if (/proceedings|conference|symposium|workshop|会议/.test(text))
215
+ return 'inproceedings';
216
+ if (/dissertation|thesis|学位/.test(text))
217
+ return 'phdthesis';
218
+ if (/book|monograph|专著/.test(text))
219
+ return 'book';
220
+ if (/posted-content|preprint|arxiv/.test(text))
221
+ return 'misc';
222
+ return 'article';
223
+ }
224
+ function fromCrossref(item) {
225
+ const doi = item.DOI;
226
+ const title = (item.title?.[0] ?? '').replace(/\s+/g, ' ').trim();
227
+ if (doi === undefined || title === '')
228
+ return undefined;
229
+ const venue = item['container-title']?.[0] ?? '';
230
+ return {
231
+ id: makeId(doi, item.URL, title),
232
+ title,
233
+ authors: (item.author ?? []).map(a => a.name ?? [a.given, a.family].filter(Boolean).join(' ').trim()).filter(n => n !== ''),
234
+ year: item.issued?.['date-parts']?.[0]?.[0] ?? null,
235
+ venue,
236
+ type: guessType(item.type ?? '', venue),
237
+ doi: doi.toLowerCase(),
238
+ ...(item.URL !== undefined ? { url: item.URL } : {}),
239
+ source: 'crossref',
240
+ };
241
+ }
242
+ function fromOpenAlex(work) {
243
+ const title = (work.display_name ?? '').replace(/\s+/g, ' ').trim();
244
+ const doi = normalizeDoi(work.doi ?? undefined);
245
+ const url = work.id?.replace('https://openalex.org/', 'https://openalex.org/works/');
246
+ if (title === '')
247
+ return undefined;
248
+ if (doi === undefined && url === undefined)
249
+ return undefined;
250
+ const venue = work.primary_location?.source?.display_name ?? '';
251
+ return {
252
+ id: makeId(doi, url, title),
253
+ title,
254
+ authors: (work.authorships ?? []).map(a => a.author?.display_name ?? '').filter(n => n !== ''),
255
+ year: work.publication_year ?? null,
256
+ venue,
257
+ type: guessType(work.type ?? '', venue),
258
+ ...(doi !== undefined ? { doi } : {}),
259
+ ...(url !== undefined ? { url } : {}),
260
+ ...(work.cited_by_count !== undefined ? { citationCount: work.cited_by_count } : {}),
261
+ source: 'openalex',
262
+ };
263
+ }
264
+ function fromDatacite(body) {
265
+ const attrs = body.data?.attributes;
266
+ if (attrs === undefined)
267
+ return undefined;
268
+ const doi = normalizeDoi(attrs.doi);
269
+ const title = (attrs.titles?.[0]?.title ?? '').replace(/\s+/g, ' ').trim();
270
+ if (doi === undefined || title === '')
271
+ return undefined;
272
+ const publisher = typeof attrs.publisher === 'string' ? attrs.publisher : attrs.publisher?.name ?? '';
273
+ return {
274
+ id: makeId(doi, attrs.url, title),
275
+ title,
276
+ authors: (attrs.creators ?? []).map(c => c.name ?? [c.givenName, c.familyName].filter(Boolean).join(' ').trim()).filter(n => n !== ''),
277
+ year: attrs.publicationYear ?? null,
278
+ venue: publisher,
279
+ type: guessType(attrs.types?.resourceTypeGeneral ?? '', publisher),
280
+ doi,
281
+ ...(attrs.url !== undefined ? { url: attrs.url } : {}),
282
+ source: 'datacite',
283
+ };
284
+ }
285
+ function fromPubmed(summary) {
286
+ const title = (summary.title ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
287
+ if (title === '')
288
+ return undefined;
289
+ const doi = normalizeDoi(summary.articleids?.find(a => a.idtype === 'doi')?.value);
290
+ const pmid = summary.articleids?.find(a => a.idtype === 'pubmed')?.value;
291
+ const url = pmid === undefined ? undefined : `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`;
292
+ const yearRaw = Number.parseInt((summary.pubdate ?? '').slice(0, 4), 10);
293
+ return {
294
+ id: makeId(doi, url, title),
295
+ title,
296
+ authors: (summary.authors ?? []).map(a => a.name ?? '').filter(n => n !== ''),
297
+ year: Number.isNaN(yearRaw) ? null : yearRaw,
298
+ venue: summary.fulljournalname ?? summary.source ?? '',
299
+ type: 'article',
300
+ ...(doi !== undefined ? { doi } : {}),
301
+ ...(url !== undefined ? { url } : {}),
302
+ source: 'pubmed',
303
+ };
304
+ }
305
+ async function crossrefByDoi(fetchImpl, doi, signal) {
306
+ const url = `https://api.crossref.org/works/${encodeURIComponent(doi)}`;
307
+ const res = await getJson(fetchImpl, url, signal);
308
+ if (res.error !== undefined)
309
+ return { registry: 'Crossref', query: doi, detail: `请求失败:${res.error}`, conclusive: false };
310
+ if (res.status === 404)
311
+ return { registry: 'Crossref', query: doi, detail: '该 DOI 在 Crossref 不存在', conclusive: true };
312
+ if (!res.ok)
313
+ return { registry: 'Crossref', query: doi, detail: `HTTP ${res.status}`, conclusive: false };
314
+ const record = fromCrossref(res.body.message ?? {});
315
+ return record === undefined
316
+ ? { registry: 'Crossref', query: doi, detail: '返回体缺少题名/DOI', conclusive: true }
317
+ : { registry: 'Crossref', query: doi, record, detail: '命中', conclusive: true };
318
+ }
319
+ async function crossrefByTitle(fetchImpl, title, signal) {
320
+ const params = new URLSearchParams({ 'query.bibliographic': title, rows: '5', select: 'DOI,title,author,issued,container-title,type,URL' });
321
+ const url = `https://api.crossref.org/works?${params}`;
322
+ const res = await getJson(fetchImpl, url, signal);
323
+ if (res.error !== undefined)
324
+ return { registry: 'Crossref', query: title, detail: `请求失败:${res.error}`, conclusive: false };
325
+ if (!res.ok)
326
+ return { registry: 'Crossref', query: title, detail: `HTTP ${res.status}`, conclusive: false };
327
+ const items = res.body.message?.items ?? [];
328
+ const records = items.map(fromCrossref).filter((r) => r !== undefined);
329
+ return bestTitleHit('Crossref', title, records);
330
+ }
331
+ async function openAlexByDoi(fetchImpl, doi, signal) {
332
+ const url = `https://api.openalex.org/works/doi:${encodeURIComponent(doi)}`;
333
+ const res = await getJson(fetchImpl, url, signal);
334
+ if (res.error !== undefined)
335
+ return { registry: 'OpenAlex', query: doi, detail: `请求失败:${res.error}`, conclusive: false };
336
+ if (res.status === 404)
337
+ return { registry: 'OpenAlex', query: doi, detail: '该 DOI 在 OpenAlex 不存在', conclusive: true };
338
+ if (!res.ok)
339
+ return { registry: 'OpenAlex', query: doi, detail: `HTTP ${res.status}`, conclusive: false };
340
+ const record = fromOpenAlex(res.body);
341
+ return record === undefined
342
+ ? { registry: 'OpenAlex', query: doi, detail: '返回体缺少题名', conclusive: true }
343
+ : { registry: 'OpenAlex', query: doi, record, detail: '命中', conclusive: true };
344
+ }
345
+ async function openAlexByTitle(fetchImpl, title, signal) {
346
+ const params = new URLSearchParams({ search: title, 'per-page': '5', select: 'id,doi,display_name,publication_year,authorships,primary_location,type,cited_by_count' });
347
+ const res = await getJson(fetchImpl, `https://api.openalex.org/works?${params}`, signal);
348
+ if (res.error !== undefined)
349
+ return { registry: 'OpenAlex', query: title, detail: `请求失败:${res.error}`, conclusive: false };
350
+ if (!res.ok)
351
+ return { registry: 'OpenAlex', query: title, detail: `HTTP ${res.status}`, conclusive: false };
352
+ const results = res.body.results ?? [];
353
+ return bestTitleHit('OpenAlex', title, results.map(fromOpenAlex).filter((r) => r !== undefined));
354
+ }
355
+ async function dataciteByDoi(fetchImpl, doi, signal) {
356
+ const res = await getJson(fetchImpl, `https://api.datacite.org/dois/${encodeURIComponent(doi)}`, signal);
357
+ if (res.error !== undefined)
358
+ return { registry: 'DataCite', query: doi, detail: `请求失败:${res.error}`, conclusive: false };
359
+ if (res.status === 404)
360
+ return { registry: 'DataCite', query: doi, detail: '该 DOI 在 DataCite 不存在', conclusive: true };
361
+ if (!res.ok)
362
+ return { registry: 'DataCite', query: doi, detail: `HTTP ${res.status}`, conclusive: false };
363
+ const record = fromDatacite(res.body);
364
+ return record === undefined
365
+ ? { registry: 'DataCite', query: doi, detail: '返回体缺少题名', conclusive: true }
366
+ : { registry: 'DataCite', query: doi, record, detail: '命中', conclusive: true };
367
+ }
368
+ async function pubmed(fetchImpl, term, signal) {
369
+ const esearch = await getJson(fetchImpl, `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&retmode=json&term=${encodeURIComponent(term)}`, signal);
370
+ if (esearch.error !== undefined)
371
+ return { registry: 'PubMed', query: term, detail: `请求失败:${esearch.error}`, conclusive: false };
372
+ if (!esearch.ok)
373
+ return { registry: 'PubMed', query: term, detail: `HTTP ${esearch.status}`, conclusive: false };
374
+ const ids = esearch.body.esearchresult?.idlist ?? [];
375
+ if (ids.length === 0)
376
+ return { registry: 'PubMed', query: term, detail: '无命中', conclusive: true };
377
+ const esummary = await getJson(fetchImpl, `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&id=${encodeURIComponent(ids[0])}`, signal);
378
+ if (!esummary.ok)
379
+ return { registry: 'PubMed', query: term, detail: `摘要请求 HTTP ${esummary.status}`, conclusive: false };
380
+ const result = esummary.body.result;
381
+ const summary = result?.[ids[0]];
382
+ const record = summary === undefined || Array.isArray(summary) ? undefined : fromPubmed(summary);
383
+ return record === undefined
384
+ ? { registry: 'PubMed', query: term, detail: '摘要缺少题名', conclusive: true }
385
+ : { registry: 'PubMed', query: term, record, detail: '命中', conclusive: true };
386
+ }
387
+ function bestTitleHit(registry, title, records) {
388
+ if (records.length === 0)
389
+ return { registry, query: title, detail: '无命中', conclusive: true };
390
+ let best = records[0];
391
+ let bestScore = titleSimilarity(title, best.title);
392
+ for (const record of records.slice(1)) {
393
+ const score = titleSimilarity(title, record.title);
394
+ if (score > bestScore) {
395
+ best = record;
396
+ bestScore = score;
397
+ }
398
+ }
399
+ return { registry, query: title, record: best, detail: `最佳命中相似度 ${bestScore.toFixed(2)}`, conclusive: true };
400
+ }
401
+ // ---------------------------------------------------------------------------
402
+ // 核验主流程
403
+ // ---------------------------------------------------------------------------
404
+ /**
405
+ * 核验单条候选文献。网络全部失败时返回 `unchecked`(**不是** verified):
406
+ * 拿不到权威库就宁可不入库,这是「零假文献」的 fail-closed 语义。
407
+ */
408
+ export async function verifyCandidate(fetchImpl, candidate, signal) {
409
+ const local = localChecks(candidate);
410
+ if (local.some(c => c.severity === 'fail')) {
411
+ return {
412
+ candidate,
413
+ status: 'invalid',
414
+ score: 0,
415
+ checks: local,
416
+ screens: [],
417
+ message: `本地检查不通过:${local.filter(c => c.severity === 'fail').map(c => `${c.field}「${c.claimed}」`).join('、')}`,
418
+ };
419
+ }
420
+ const screens = [];
421
+ const doi = normalizeDoi(candidate.doi);
422
+ const hits = [];
423
+ if (doi !== undefined) {
424
+ for (const lookup of [crossrefByDoi, openAlexByDoi, dataciteByDoi]) {
425
+ const hit = await lookup(fetchImpl, doi, signal);
426
+ screens.push({ registry: hit.registry, query: hit.query, ok: hit.record !== undefined, detail: hit.detail });
427
+ hits.push(hit);
428
+ if (hit.record !== undefined)
429
+ break;
430
+ }
431
+ if (!hits.some(h => h.record !== undefined)) {
432
+ const hit = await pubmed(fetchImpl, `${doi}[DOI]`, signal);
433
+ screens.push({ registry: hit.registry, query: hit.query, ok: hit.record !== undefined, detail: hit.detail });
434
+ hits.push(hit);
435
+ }
436
+ }
437
+ else {
438
+ for (const lookup of [crossrefByTitle, openAlexByTitle]) {
439
+ const hit = await lookup(fetchImpl, candidate.title, signal);
440
+ screens.push({ registry: hit.registry, query: hit.query, ok: hit.record !== undefined, detail: hit.detail });
441
+ hits.push(hit);
442
+ if (hit.record !== undefined && titleSimilarity(candidate.title, hit.record.title) >= TITLE_VERIFIED)
443
+ break;
444
+ }
445
+ if (!hits.some(h => h.record !== undefined)) {
446
+ const hit = await pubmed(fetchImpl, `"${candidate.title}"[Title]`, signal);
447
+ screens.push({ registry: hit.registry, query: hit.query, ok: hit.record !== undefined, detail: hit.detail });
448
+ hits.push(hit);
449
+ }
450
+ }
451
+ const withRecord = hits.filter(h => h.record !== undefined);
452
+ if (withRecord.length === 0) {
453
+ const conclusive = hits.length > 0 && hits.every(h => h.conclusive);
454
+ const networkDown = hits.some(h => !h.conclusive) && !conclusive;
455
+ return {
456
+ candidate,
457
+ status: networkDown ? 'unchecked' : 'not-found',
458
+ score: 0,
459
+ checks: local,
460
+ screens,
461
+ message: networkDown
462
+ ? '权威库没连上,本轮未核验(fail closed:核验通过前不许写入 refs.bib)。请联网后重跑 thesis_lit_verify。'
463
+ : 'Crossref / OpenAlex / DataCite / PubMed 均查无此文:疑似编造或题名/DOI 有误,禁止入库。',
464
+ };
465
+ }
466
+ // 选相似度最高的那条权威记录做对账。
467
+ let best = withRecord[0];
468
+ let bestScore = titleSimilarity(candidate.title, best.record.title);
469
+ for (const hit of withRecord.slice(1)) {
470
+ const score = titleSimilarity(candidate.title, hit.record.title);
471
+ if (score > bestScore) {
472
+ best = hit;
473
+ bestScore = score;
474
+ }
475
+ }
476
+ const record = best.record;
477
+ const comparison = compareWithRecord(candidate, record);
478
+ const checks = [...local, ...comparison.checks];
479
+ const verified = comparison.status === 'verified' && comparison.score >= TITLE_VERIFIED;
480
+ return {
481
+ candidate,
482
+ status: verified ? 'verified' : 'mismatch',
483
+ score: comparison.score,
484
+ record,
485
+ checks,
486
+ screens,
487
+ message: verified
488
+ ? `已由 ${best.registry} 确认:题名相似度 ${comparison.score.toFixed(2)}。`
489
+ : `${best.registry} 有近似记录但字段冲突(相似度 ${comparison.score.toFixed(2)}):请核对原文,别按候选值写引用。`,
490
+ };
491
+ }
492
+ /** 批量核验(顺序执行,礼貌对待免费库;单条失败不影响其它条)。 */
493
+ export async function verifyAll(fetchImpl, candidates, signal) {
494
+ const out = [];
495
+ for (const candidate of candidates) {
496
+ if (signal?.aborted === true)
497
+ throw signal.reason ?? new Error('已取消');
498
+ out.push(await verifyCandidate(fetchImpl, candidate, signal));
499
+ }
500
+ return out;
501
+ }
502
+ // ---------------------------------------------------------------------------
503
+ // 报告
504
+ // ---------------------------------------------------------------------------
505
+ export function summarizeOutcomes(outcomes) {
506
+ const count = (status) => outcomes.filter(o => o.status === status).length;
507
+ return `共 ${outcomes.length} 条:通过 ${count('verified')},冲突 ${count('mismatch')},查无 ${count('not-found')},格式不合法 ${count('invalid')},未核验 ${count('unchecked')}`;
508
+ }
509
+ export function renderOutcome(outcome) {
510
+ const badge = {
511
+ verified: '✓ 通过',
512
+ mismatch: '⚠ 冲突',
513
+ 'not-found': '✗ 查无',
514
+ invalid: '✗ 不合法',
515
+ unchecked: '◌ 未核验',
516
+ };
517
+ const lines = [`${badge[outcome.status]} ${outcome.candidate.title || '(无题名)'}`];
518
+ const meta = [];
519
+ if (outcome.candidate.authors.length > 0)
520
+ meta.push(outcome.candidate.authors.slice(0, 3).join(', '));
521
+ if (outcome.candidate.year !== null)
522
+ meta.push(String(outcome.candidate.year));
523
+ if (outcome.candidate.venue !== '')
524
+ meta.push(outcome.candidate.venue);
525
+ if (meta.length > 0)
526
+ lines.push(` ${meta.join(';')}`);
527
+ if (outcome.record !== undefined)
528
+ lines.push(` 权威记录:${outcome.record.title}(${outcome.record.authors.slice(0, 3).join(', ')},${outcome.record.year ?? '年未知'},${outcome.record.venue || '出处未知'})`);
529
+ for (const check of outcome.checks.filter(c => c.severity !== 'ok')) {
530
+ lines.push(` ${check.severity === 'fail' ? '✗' : '△'} ${check.field}:候选「${check.claimed}」 vs 权威「${check.registered}」`);
531
+ }
532
+ lines.push(` ${outcome.message}`);
533
+ return lines.join('\n');
534
+ }
535
+ export function renderVerifyReport(date, outcomes, label) {
536
+ const lines = [`## ${date} 核验(${label})`, '', summarizeOutcomes(outcomes), ''];
537
+ lines.push('| # | 结论 | 题名 | 年份 | DOI | 说明 |');
538
+ lines.push('|---|---|---|---|---|---|');
539
+ outcomes.forEach((o, index) => {
540
+ const title = o.candidate.title.replaceAll('|', '\\|').slice(0, 60);
541
+ lines.push(`| ${index + 1} | ${o.status} | ${title} | ${o.candidate.year ?? '—'} | ${o.candidate.doi ?? '—'} | ${o.message.replaceAll('|', '\\|')} |`);
542
+ });
543
+ const problems = outcomes.filter(o => o.status !== 'verified');
544
+ if (problems.length > 0) {
545
+ lines.push('', '### 未通过的条目(不得写入 refs.bib)', '');
546
+ for (const outcome of problems)
547
+ lines.push(renderOutcome(outcome), '');
548
+ }
549
+ return lines.join('\n');
550
+ }
551
+ export const VERIFY_REPORT_HEADER = `# 文献核验报告
552
+
553
+ > 口径:每条候选文献必须由 Crossref / OpenAlex / DataCite / PubMed 中的权威库确认存在,
554
+ > 且题名相似度 ≥0.92、第一作者姓氏一致、年份差 ≤1,才允许写入 02-文献/refs.bib。
555
+ > Undermind / Elicit / Consensus 只负责「找到候选」,入库一律以本报告的核验结论为准。
556
+ `;
@@ -13,7 +13,7 @@ export interface PaperOptions {
13
13
  readonly skillsDir: string;
14
14
  }
15
15
  /**
16
- * 注册 11 个论文工具 + 5 个斜杠命令。
16
+ * 注册 13 个论文工具(本插件不提供斜杠命令,流程由自动驾驶推进)。
17
17
  *
18
18
  * 命令名只允许 `[a-z][a-z0-9_-]*`,故统一 `thesis-` 前缀。
19
19
  */
@@ -11,6 +11,7 @@ import { initSummary, runInit } from "./tools/init.js";
11
11
  import { runProgress } from "./tools/progress.js";
12
12
  import { runDecision } from "./tools/decision.js";
13
13
  import { runLitNote, runLitSave, runLitSearch } from "./tools/lit.js";
14
+ import { runLitImport, runLitVerify } from "./tools/lit-verify.js";
14
15
  import { runReview } from "./tools/review.js";
15
16
  import { buildSummary, runBuild } from "./tools/build.js";
16
17
  import { runCheck } from "./tools/check.js";
@@ -18,7 +19,7 @@ import { runAiSelfcheckTool } from "./tools/aicheck.js";
18
19
  import { runDefensePrep } from "./tools/defense.js";
19
20
  import { LIT_SOURCES } from "./lib/lit-api.js";
20
21
  /**
21
- * 注册 11 个论文工具 + 5 个斜杠命令。
22
+ * 注册 13 个论文工具(本插件不提供斜杠命令,流程由自动驾驶推进)。
22
23
  *
23
24
  * 命令名只允许 `[a-z][a-z0-9_-]*`,故统一 `thesis-` 前缀。
24
25
  */
@@ -113,6 +114,41 @@ export function registerPaper(ctx, options) {
113
114
  return await runLitNote(ctx.fs, rawArgs, sessionCwd(exec), exec.signal);
114
115
  },
115
116
  }));
117
+ ctx.tools.register(definePaperTool({
118
+ name: 'thesis_lit_verify',
119
+ description: '核验候选文献是否真实存在:逐条拿 Crossref / OpenAlex / DataCite / PubMed 对账题名、作者、年份、DOI,' +
120
+ '只出结论、不改 refs.bib。用于核验 Undermind / Elicit / Consensus 导出或别人给的文献清单;' +
121
+ '结论 verified / mismatch / not-found / invalid / unchecked,报告写入 08-合规/文献核验报告.md。',
122
+ parameters: {
123
+ title: { type: 'string', description: '题名(单条核验时给 title 或 doi)' },
124
+ doi: { type: 'string', description: 'DOI(形如 10.1145/xxxx.xxxx;给了 DOI 就按 DOI 精确对账)' },
125
+ authors: { type: 'array', items: { type: 'string' }, description: '作者姓名列表(用于核对第一作者姓氏)' },
126
+ year: { type: 'number', description: '年份(与权威记录差 ≤1 视为一致)' },
127
+ venue: { type: 'string', description: '期刊/会议名(仅作参考核对)' },
128
+ path: { type: 'string', description: '批量核验:导出的 .bib / .ris / .csv 路径(相对论文仓库或绝对路径)' },
129
+ save: { type: 'boolean', description: '核验通过后是否顺手收录进 refs.bib,默认 false' },
130
+ },
131
+ output: textOutput(),
132
+ async execute(rawArgs, exec) {
133
+ return await runLitVerify(ctx.fs, rawArgs, sessionCwd(exec), exec.signal, fetch);
134
+ },
135
+ }));
136
+ ctx.tools.register(definePaperTool({
137
+ name: 'thesis_lit_import',
138
+ description: '导入 Undermind / Elicit / Consensus / Zotero 导出的 .bib / .ris / .csv:逐条核验,' +
139
+ '**只有核验通过的**才写进 02-文献/refs.bib,未通过的写进 08-合规/文献核验报告.md 并说明原因。' +
140
+ '解析只读不改写元数据;网络不通时不入库(fail closed)——这是「零假文献」的入口闸门。',
141
+ parameters: {
142
+ path: { type: 'string', required: true, description: '导出文件路径(.bib / .ris / .csv)' },
143
+ source: { type: 'string', description: '来源标签:undermind | elicit | consensus | zotero | google-scholar,缺省按文件名推断' },
144
+ limit: { type: 'number', description: '只核验前 N 条(导出很大时先试跑)' },
145
+ save: { type: 'boolean', description: '默认 true:核验通过的直接入库;设 false 只核验' },
146
+ },
147
+ output: textOutput(),
148
+ async execute(rawArgs, exec) {
149
+ return await runLitImport(ctx.fs, rawArgs, sessionCwd(exec), exec.signal, fetch);
150
+ },
151
+ }));
116
152
  ctx.tools.register(definePaperTool({
117
153
  name: 'thesis_review',
118
154
  description: '对论文单章做确定性评审(六项可机器验证的检查):字数是否在目标区间、大纲小节是否齐全、' +
@@ -22,6 +22,10 @@ export interface BuildOutcome {
22
22
  readonly template: string | undefined;
23
23
  readonly chapters: readonly string[];
24
24
  readonly missing: readonly string[];
25
+ /** 成功嵌入 docx 的图片数量。 */
26
+ readonly images: number;
27
+ /** 图片方面的问题(路径不存在 / 格式不支持),逐条写进构建说明。 */
28
+ readonly imageProblems: readonly string[];
25
29
  }
26
30
  export declare function runBuild(fs: FileSystem, args: BuildArgs, cwd: string | undefined, signal: AbortSignal | undefined, writeBinary: WriteBinary): Promise<BuildOutcome>;
27
31
  export declare function buildSummary(outcome: BuildOutcome): string;