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.
- package/README.md +5 -4
- package/docs/config.md +12 -0
- package/docs/design.md +95 -2
- package/docs/release.md +94 -85
- package/docs/tools.md +8 -5
- package/docs/troubleshooting.md +18 -0
- package/docs/usage.md +4 -1
- package/lib/config.d.ts +13 -0
- package/lib/config.js +8 -1
- package/lib/figure/actions.d.ts +83 -0
- package/lib/figure/actions.js +439 -0
- package/lib/figure/index.d.ts +24 -0
- package/lib/figure/index.js +40 -0
- package/lib/figure/lib/check.d.ts +83 -0
- package/lib/figure/lib/check.js +214 -0
- package/lib/figure/lib/drawio.d.ts +123 -0
- package/lib/figure/lib/drawio.js +156 -0
- package/lib/figure/lib/export.d.ts +120 -0
- package/lib/figure/lib/export.js +308 -0
- package/lib/figure/lib/layout.d.ts +78 -0
- package/lib/figure/lib/layout.js +496 -0
- package/lib/figure/lib/spec.d.ts +114 -0
- package/lib/figure/lib/spec.js +290 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +5 -1
- package/lib/paper/lib/check.d.ts +5 -0
- package/lib/paper/lib/check.js +21 -0
- package/lib/paper/lib/docx.d.ts +26 -0
- package/lib/paper/lib/docx.js +190 -27
- package/lib/paper/lib/image.d.ts +19 -0
- package/lib/paper/lib/image.js +82 -0
- package/lib/paper/lib/layout.js +26 -1
- package/lib/paper/lib/lit-refs.d.ts +30 -0
- package/lib/paper/lib/lit-refs.js +394 -0
- package/lib/paper/lib/lit-verify.d.ts +79 -0
- package/lib/paper/lib/lit-verify.js +556 -0
- package/lib/paper/register.d.ts +1 -1
- package/lib/paper/register.js +37 -1
- package/lib/paper/tools/build.d.ts +4 -0
- package/lib/paper/tools/build.js +79 -4
- package/lib/paper/tools/lit-verify.d.ts +35 -0
- package/lib/paper/tools/lit-verify.js +193 -0
- package/lib/ppt/check.js +5 -0
- package/lib/ppt/design.d.ts +86 -0
- package/lib/ppt/design.js +218 -0
- package/package.json +4 -1
- package/skills/thesis-defense/SKILL.md +25 -1
- package/skills/thesis-eng-design/SKILL.md +40 -20
- package/skills/thesis-literature/SKILL.md +28 -5
- package/skills/thesis-opener/SKILL.md +1 -1
- package/skills/thesis-pipeline/SKILL.md +4 -3
- package/skills/thesis-writing/SKILL.md +4 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 候选文献解析:把 Undermind / Elicit / Consensus / Zotero / Google Scholar
|
|
3
|
+
* 导出的 `.bib`、`.ris`、`.csv` 读成统一的 `CandidateRef[]`。
|
|
4
|
+
*
|
|
5
|
+
* 只做**解析**,不做判断:解析结果一律视为「声称值」,必须再过
|
|
6
|
+
* `lit-verify.ts` 的权威库对账才能进 refs.bib。解析器不联网、不改写元数据,
|
|
7
|
+
* 解析不了的字段留空并给出警告(警告会出现在核验报告里)。
|
|
8
|
+
*/
|
|
9
|
+
const BIB_EXT = /\.(bib|bibtex)$/i;
|
|
10
|
+
const RIS_EXT = /\.ris$/i;
|
|
11
|
+
const CSV_EXT = /\.(csv|tsv)$/i;
|
|
12
|
+
/** 按扩展名与内容特征判断格式;都不像时返回 null。 */
|
|
13
|
+
export function detectRefFormat(fileName, text) {
|
|
14
|
+
if (BIB_EXT.test(fileName))
|
|
15
|
+
return 'bibtex';
|
|
16
|
+
if (RIS_EXT.test(fileName))
|
|
17
|
+
return 'ris';
|
|
18
|
+
if (CSV_EXT.test(fileName))
|
|
19
|
+
return 'csv';
|
|
20
|
+
if (/^\s*@\w+\s*\{/m.test(text))
|
|
21
|
+
return 'bibtex';
|
|
22
|
+
if (/^\s*TY\s{2}-\s/m.test(text))
|
|
23
|
+
return 'ris';
|
|
24
|
+
if (/^\s*(title|题名|"title")\s*[,;\t]/im.test(text))
|
|
25
|
+
return 'csv';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function parseRefs(fileName, text, source) {
|
|
29
|
+
const format = detectRefFormat(fileName, text);
|
|
30
|
+
if (format === null) {
|
|
31
|
+
throw new Error(`无法识别 ${fileName} 的格式。支持 Undermind / Elicit / Consensus / Zotero 导出的 .bib、.ris、.csv。` +
|
|
32
|
+
`请在这些工具里选择「Export → BibTeX / RIS / CSV」后重试;不支持直接粘贴的散乱文本(那种只能逐条用 thesis_lit_verify 的 title/doi 参数核验)。`);
|
|
33
|
+
}
|
|
34
|
+
if (format === 'bibtex')
|
|
35
|
+
return { format, ...parseBibtex(text, source) };
|
|
36
|
+
if (format === 'ris')
|
|
37
|
+
return { format, ...parseRis(text, source) };
|
|
38
|
+
return { format, ...parseCsv(text, source) };
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// 作者拆分
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
/** 作者串 → 姓名数组。兼容 `A; B`、`A and B`、JSON 数组、`A, B, C`(≥3 个时的兜底)。 */
|
|
44
|
+
export function splitAuthors(raw) {
|
|
45
|
+
const text = raw.trim();
|
|
46
|
+
if (text === '')
|
|
47
|
+
return [];
|
|
48
|
+
if (text.startsWith('[') && text.endsWith(']')) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(text);
|
|
51
|
+
if (Array.isArray(parsed)) {
|
|
52
|
+
return parsed.map(item => (typeof item === 'string' ? item : '')).map(s => s.trim()).filter(s => s !== '');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// 不是合法 JSON:继续按分隔符拆。
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const clean = text.replace(/\s+and\s+/gi, ';').replace(/\s*&\s*/g, ';');
|
|
60
|
+
const semicolon = clean.split(';').map(s => s.trim()).filter(s => s !== '');
|
|
61
|
+
if (semicolon.length > 1)
|
|
62
|
+
return semicolon;
|
|
63
|
+
if (semicolon.length === 1) {
|
|
64
|
+
// `Zhang, San, Li, Si` 这种没有分号的全名列表:只在 ≥4 段且段数成对时按逗号拆。
|
|
65
|
+
const parts = clean.split(',').map(s => s.trim()).filter(s => s !== '');
|
|
66
|
+
if (parts.length >= 4 && parts.length % 2 === 0) {
|
|
67
|
+
const grouped = [];
|
|
68
|
+
for (let i = 0; i < parts.length; i += 2)
|
|
69
|
+
grouped.push(`${parts[i]} ${parts[i + 1]}`);
|
|
70
|
+
return grouped;
|
|
71
|
+
}
|
|
72
|
+
return semicolon;
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
function toYear(raw) {
|
|
77
|
+
if (raw === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
const m = /\b(1[89]\d{2}|20\d{2})\b/.exec(raw);
|
|
80
|
+
return m === null ? null : Number.parseInt(m[1], 10);
|
|
81
|
+
}
|
|
82
|
+
/** 扫出所有 `@type{key, body}`;用花括号配对找条目结尾,能容忍嵌套 `{}` 与多行字段。 */
|
|
83
|
+
export function bibEntries(text) {
|
|
84
|
+
const entries = [];
|
|
85
|
+
const start = /@(\w+)\s*\{/g;
|
|
86
|
+
let match;
|
|
87
|
+
while ((match = start.exec(text)) !== null) {
|
|
88
|
+
const type = (match[1] ?? '').toLowerCase();
|
|
89
|
+
if (type === 'comment' || type === 'preamble' || type === 'string') {
|
|
90
|
+
start.lastIndex = match.index + match[0].length;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const open = text.indexOf('{', match.index);
|
|
94
|
+
let depth = 0;
|
|
95
|
+
let end = open;
|
|
96
|
+
for (; end < text.length; end += 1) {
|
|
97
|
+
const ch = text[end];
|
|
98
|
+
if (ch === '{')
|
|
99
|
+
depth += 1;
|
|
100
|
+
else if (ch === '}') {
|
|
101
|
+
depth -= 1;
|
|
102
|
+
if (depth === 0)
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const inner = text.slice(open + 1, end);
|
|
107
|
+
const comma = inner.indexOf(',');
|
|
108
|
+
const key = (comma === -1 ? inner : inner.slice(0, comma)).trim();
|
|
109
|
+
const body = comma === -1 ? '' : inner.slice(comma + 1);
|
|
110
|
+
entries.push({ type, key, fields: parseBibFields(body) });
|
|
111
|
+
start.lastIndex = end + 1;
|
|
112
|
+
}
|
|
113
|
+
return entries;
|
|
114
|
+
}
|
|
115
|
+
function parseBibFields(body) {
|
|
116
|
+
const fields = new Map();
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < body.length) {
|
|
119
|
+
// 跳过空白与逗号
|
|
120
|
+
while (i < body.length && /[\s,]/.test(body[i]))
|
|
121
|
+
i += 1;
|
|
122
|
+
const eq = body.indexOf('=', i);
|
|
123
|
+
if (eq === -1)
|
|
124
|
+
break;
|
|
125
|
+
const name = body.slice(i, eq).trim().toLowerCase();
|
|
126
|
+
i = eq + 1;
|
|
127
|
+
while (i < body.length && /\s/.test(body[i]))
|
|
128
|
+
i += 1;
|
|
129
|
+
let value = '';
|
|
130
|
+
if (body[i] === '{') {
|
|
131
|
+
let depth = 0;
|
|
132
|
+
const from = i;
|
|
133
|
+
for (; i < body.length; i += 1) {
|
|
134
|
+
if (body[i] === '{')
|
|
135
|
+
depth += 1;
|
|
136
|
+
else if (body[i] === '}') {
|
|
137
|
+
depth -= 1;
|
|
138
|
+
if (depth === 0) {
|
|
139
|
+
i += 1;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
value = body.slice(from + 1, i - 1);
|
|
145
|
+
}
|
|
146
|
+
else if (body[i] === '"') {
|
|
147
|
+
const from = i + 1;
|
|
148
|
+
i = body.indexOf('"', from);
|
|
149
|
+
if (i === -1)
|
|
150
|
+
i = body.length;
|
|
151
|
+
value = body.slice(from, i);
|
|
152
|
+
i += 1;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const from = i;
|
|
156
|
+
while (i < body.length && !/[,}\n]/.test(body[i]))
|
|
157
|
+
i += 1;
|
|
158
|
+
value = body.slice(from, i);
|
|
159
|
+
}
|
|
160
|
+
if (name !== '')
|
|
161
|
+
fields.set(name, cleanBibValue(value));
|
|
162
|
+
}
|
|
163
|
+
return fields;
|
|
164
|
+
}
|
|
165
|
+
function cleanBibValue(raw) {
|
|
166
|
+
return raw
|
|
167
|
+
.replace(/\\&/g, '&')
|
|
168
|
+
.replace(/\\%/g, '%')
|
|
169
|
+
.replace(/[{}]/g, '')
|
|
170
|
+
.replace(/\s+/g, ' ')
|
|
171
|
+
.trim();
|
|
172
|
+
}
|
|
173
|
+
function parseBibtex(text, source) {
|
|
174
|
+
const warnings = [];
|
|
175
|
+
const candidates = [];
|
|
176
|
+
const entries = bibEntries(text);
|
|
177
|
+
entries.forEach((entry, index) => {
|
|
178
|
+
const title = entry.fields.get('title') ?? '';
|
|
179
|
+
if (title === '') {
|
|
180
|
+
warnings.push(`第 ${index + 1} 条(${entry.key || '无 key'})没有 title,已跳过。`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const doi = entry.fields.get('doi');
|
|
184
|
+
const candidate = {
|
|
185
|
+
title,
|
|
186
|
+
authors: splitAuthors(entry.fields.get('author') ?? entry.fields.get('editor') ?? ''),
|
|
187
|
+
year: toYear(entry.fields.get('year')),
|
|
188
|
+
venue: entry.fields.get('journal') ?? entry.fields.get('booktitle') ?? entry.fields.get('publisher') ?? entry.fields.get('school') ?? '',
|
|
189
|
+
...(doi !== undefined ? { doi } : {}),
|
|
190
|
+
...(entry.fields.get('url') !== undefined ? { url: entry.fields.get('url') } : {}),
|
|
191
|
+
source,
|
|
192
|
+
ordinal: index + 1,
|
|
193
|
+
};
|
|
194
|
+
candidates.push(candidate);
|
|
195
|
+
});
|
|
196
|
+
return { candidates, warnings };
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// RIS
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
const RIS_FIELD = {
|
|
202
|
+
TI: 'title',
|
|
203
|
+
T1: 'title',
|
|
204
|
+
BT: 'title',
|
|
205
|
+
AU: 'author',
|
|
206
|
+
A1: 'author',
|
|
207
|
+
PY: 'year',
|
|
208
|
+
Y1: 'year',
|
|
209
|
+
JO: 'venue',
|
|
210
|
+
JF: 'venue',
|
|
211
|
+
T2: 'venue',
|
|
212
|
+
JA: 'venue',
|
|
213
|
+
DO: 'doi',
|
|
214
|
+
UR: 'url',
|
|
215
|
+
L2: 'url',
|
|
216
|
+
};
|
|
217
|
+
function parseRis(text, source) {
|
|
218
|
+
const warnings = [];
|
|
219
|
+
const candidates = [];
|
|
220
|
+
const records = [];
|
|
221
|
+
let current = new Map();
|
|
222
|
+
let open = false;
|
|
223
|
+
for (const line of text.split(/\r?\n/)) {
|
|
224
|
+
const m = /^([A-Z][A-Z0-9])\s{2}-\s?(.*)$/.exec(line);
|
|
225
|
+
if (m === null)
|
|
226
|
+
continue;
|
|
227
|
+
const tag = m[1];
|
|
228
|
+
const value = m[2].trim();
|
|
229
|
+
if (tag === 'TY') {
|
|
230
|
+
current = new Map();
|
|
231
|
+
open = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (tag === 'ER') {
|
|
235
|
+
if (open && current.size > 0)
|
|
236
|
+
records.push(current);
|
|
237
|
+
open = false;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!open)
|
|
241
|
+
continue;
|
|
242
|
+
const field = RIS_FIELD[tag];
|
|
243
|
+
if (field === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
current.set(field, [...(current.get(field) ?? []), value]);
|
|
246
|
+
}
|
|
247
|
+
records.forEach((record, index) => {
|
|
248
|
+
const title = record.get('title')?.[0]?.replace(/<[^>]+>/g, '').trim() ?? '';
|
|
249
|
+
if (title === '') {
|
|
250
|
+
warnings.push(`第 ${index + 1} 条 RIS 没有 TI(题名),已跳过。`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const doi = record.get('doi')?.[0];
|
|
254
|
+
const url = record.get('url')?.[0];
|
|
255
|
+
candidates.push({
|
|
256
|
+
title,
|
|
257
|
+
authors: (record.get('author') ?? []).flatMap(splitAuthors),
|
|
258
|
+
year: toYear(record.get('year')?.[0]),
|
|
259
|
+
venue: record.get('venue')?.[0] ?? '',
|
|
260
|
+
...(doi !== undefined ? { doi } : {}),
|
|
261
|
+
...(url !== undefined ? { url } : {}),
|
|
262
|
+
source,
|
|
263
|
+
ordinal: index + 1,
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
return { candidates, warnings };
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// CSV(Elicit / Consensus / Scopus 导出的表格)
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
/** RFC4180 风格解析:支持双引号包裹、`""` 转义、字段内换行。 */
|
|
272
|
+
export function parseCsvRows(text, delimiter = ',') {
|
|
273
|
+
const rows = [];
|
|
274
|
+
let row = [];
|
|
275
|
+
let field = '';
|
|
276
|
+
let quoted = false;
|
|
277
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
278
|
+
const ch = text[i];
|
|
279
|
+
if (quoted) {
|
|
280
|
+
if (ch === '"') {
|
|
281
|
+
if (text[i + 1] === '"') {
|
|
282
|
+
field += '"';
|
|
283
|
+
i += 1;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
quoted = false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
field += ch;
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === '"') {
|
|
295
|
+
quoted = true;
|
|
296
|
+
}
|
|
297
|
+
else if (ch === delimiter) {
|
|
298
|
+
row.push(field);
|
|
299
|
+
field = '';
|
|
300
|
+
}
|
|
301
|
+
else if (ch === '\n' || ch === '\r') {
|
|
302
|
+
if (ch === '\r' && text[i + 1] === '\n')
|
|
303
|
+
i += 1;
|
|
304
|
+
row.push(field);
|
|
305
|
+
field = '';
|
|
306
|
+
if (row.some(cell => cell.trim() !== ''))
|
|
307
|
+
rows.push(row);
|
|
308
|
+
row = [];
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
field += ch;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
row.push(field);
|
|
315
|
+
if (row.some(cell => cell.trim() !== ''))
|
|
316
|
+
rows.push(row);
|
|
317
|
+
return rows;
|
|
318
|
+
}
|
|
319
|
+
const HEADER_ALIASES = {
|
|
320
|
+
title: ['title', '题名', '标题', 'paper title', 'name', 'document title'],
|
|
321
|
+
authors: ['authors', 'author', '作者', 'creators', 'author names'],
|
|
322
|
+
year: ['year', 'publication year', '出版年', '年份', 'published', 'date'],
|
|
323
|
+
venue: ['venue', 'journal', 'source title', 'source', 'publication', '期刊', '会议', 'published in', 'container title'],
|
|
324
|
+
doi: ['doi', 'doi link', 'doi url', 'digital object identifier'],
|
|
325
|
+
url: ['url', 'link', 'uri', 'pdf', 'landing page url'],
|
|
326
|
+
};
|
|
327
|
+
function normalizeHeader(raw) {
|
|
328
|
+
return raw.toLowerCase().replace(/[*_"]/g, '').replace(/\s+/g, ' ').trim();
|
|
329
|
+
}
|
|
330
|
+
function mapHeaders(header) {
|
|
331
|
+
const mapped = new Map();
|
|
332
|
+
header.forEach((cell, index) => {
|
|
333
|
+
const norm = normalizeHeader(cell);
|
|
334
|
+
for (const field of Object.keys(HEADER_ALIASES)) {
|
|
335
|
+
if (mapped.has(field))
|
|
336
|
+
continue;
|
|
337
|
+
if (HEADER_ALIASES[field].some(alias => norm === alias || norm.startsWith(`${alias} (`) || norm.startsWith(`${alias} -`))) {
|
|
338
|
+
mapped.set(field, index);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return mapped;
|
|
343
|
+
}
|
|
344
|
+
function detectDelimiter(text) {
|
|
345
|
+
const firstLine = text.split(/\r?\n/, 1)[0] ?? '';
|
|
346
|
+
const counts = [[',', 0], [';', 0], ['\t', 0]];
|
|
347
|
+
for (const ch of firstLine) {
|
|
348
|
+
const slot = counts.find(([d]) => d === ch);
|
|
349
|
+
if (slot !== undefined)
|
|
350
|
+
slot[1] += 1;
|
|
351
|
+
}
|
|
352
|
+
counts.sort((a, b) => b[1] - a[1]);
|
|
353
|
+
return counts[0][1] > 0 ? counts[0][0] : ',';
|
|
354
|
+
}
|
|
355
|
+
function parseCsv(text, source) {
|
|
356
|
+
const warnings = [];
|
|
357
|
+
const rows = parseCsvRows(text.replace(/^\uFEFF/, ''), detectDelimiter(text));
|
|
358
|
+
if (rows.length === 0)
|
|
359
|
+
return { candidates: [], warnings: ['文件为空。'] };
|
|
360
|
+
const header = rows[0];
|
|
361
|
+
const mapped = mapHeaders(header);
|
|
362
|
+
if (!mapped.has('title')) {
|
|
363
|
+
throw new Error(`CSV 表头里没有题名列(现有:${header.slice(0, 8).join(' / ')})。` +
|
|
364
|
+
`请确认导出的是文献列表(列名含 Title/题名),而不是筛选结果或统计表。`);
|
|
365
|
+
}
|
|
366
|
+
const get = (row, field) => {
|
|
367
|
+
const index = mapped.get(field);
|
|
368
|
+
if (index === undefined)
|
|
369
|
+
return undefined;
|
|
370
|
+
const value = row[index]?.trim();
|
|
371
|
+
return value === undefined || value === '' ? undefined : value;
|
|
372
|
+
};
|
|
373
|
+
const candidates = [];
|
|
374
|
+
rows.slice(1).forEach((row, index) => {
|
|
375
|
+
const title = get(row, 'title');
|
|
376
|
+
if (title === undefined) {
|
|
377
|
+
warnings.push(`第 ${index + 2} 行没有题名,已跳过。`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const doi = get(row, 'doi');
|
|
381
|
+
const url = get(row, 'url');
|
|
382
|
+
candidates.push({
|
|
383
|
+
title,
|
|
384
|
+
authors: splitAuthors(get(row, 'authors') ?? ''),
|
|
385
|
+
year: toYear(get(row, 'year')),
|
|
386
|
+
venue: get(row, 'venue') ?? '',
|
|
387
|
+
...(doi !== undefined ? { doi } : {}),
|
|
388
|
+
...(url !== undefined ? { url } : {}),
|
|
389
|
+
source,
|
|
390
|
+
ordinal: index + 2,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
return { candidates, warnings };
|
|
394
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { type FetchLike, type LitRecord } from './lit-api.ts';
|
|
22
|
+
export type VerifyStatus = 'verified' | 'mismatch' | 'not-found' | 'invalid' | 'unchecked';
|
|
23
|
+
/** 待核验的候选文献。字段是"声称值",不保证为真。 */
|
|
24
|
+
export interface CandidateRef {
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly authors: readonly string[];
|
|
27
|
+
readonly year: number | null;
|
|
28
|
+
readonly venue: string;
|
|
29
|
+
readonly doi?: string;
|
|
30
|
+
readonly url?: string;
|
|
31
|
+
/** 来源标签:undermind / elicit / consensus / zotero / manual / paste… */
|
|
32
|
+
readonly source: string;
|
|
33
|
+
/** 在原文件中的位置(第几条/第几行),用于回指。 */
|
|
34
|
+
readonly ordinal?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface ReferenceCheck {
|
|
37
|
+
readonly field: '题名' | '作者' | '年份' | 'DOI' | '出处';
|
|
38
|
+
readonly claimed: string;
|
|
39
|
+
readonly registered: string;
|
|
40
|
+
/** ok=一致;warn=有差异但可接受;fail=冲突(禁止入库) */
|
|
41
|
+
readonly severity: 'ok' | 'warn' | 'fail';
|
|
42
|
+
}
|
|
43
|
+
export interface VerifyScreen {
|
|
44
|
+
readonly registry: string;
|
|
45
|
+
readonly query: string;
|
|
46
|
+
readonly ok: boolean;
|
|
47
|
+
readonly detail: string;
|
|
48
|
+
}
|
|
49
|
+
export interface VerifyOutcome {
|
|
50
|
+
readonly candidate: CandidateRef;
|
|
51
|
+
readonly status: VerifyStatus;
|
|
52
|
+
/** 题名相似度 0..1(无对照记录时为 0)。 */
|
|
53
|
+
readonly score: number;
|
|
54
|
+
readonly record?: LitRecord;
|
|
55
|
+
readonly checks: readonly ReferenceCheck[];
|
|
56
|
+
readonly screens: readonly VerifyScreen[];
|
|
57
|
+
readonly message: string;
|
|
58
|
+
}
|
|
59
|
+
export declare const VERIFY_REPORT_REL = "08-\u5408\u89C4/\u6587\u732E\u6838\u9A8C\u62A5\u544A.md";
|
|
60
|
+
/** 去掉 `https://doi.org/`、`doi:` 前缀与空白;统一小写。不是合法 DOI 形状时返回 undefined。 */
|
|
61
|
+
export declare function normalizeDoi(raw: string | undefined): string | undefined;
|
|
62
|
+
export declare function doiShapeValid(doi: string): boolean;
|
|
63
|
+
/** 题名归一化:去重音、去标点、压缩空白、小写。中文按字切分。 */
|
|
64
|
+
export declare function normalizeTitle(raw: string): string;
|
|
65
|
+
/** 题名相似度:中文按双字 shingle,西文按词,取 Dice 系数。 */
|
|
66
|
+
export declare function titleSimilarity(a: string, b: string): number;
|
|
67
|
+
/** 姓氏:`Zhang, Wei` 取逗号前;西文取最后一个词;中文姓名取第一个字;去掉 Jr./van 等缀词。 */
|
|
68
|
+
export declare function familyName(author: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* 核验单条候选文献。网络全部失败时返回 `unchecked`(**不是** verified):
|
|
71
|
+
* 拿不到权威库就宁可不入库,这是「零假文献」的 fail-closed 语义。
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyCandidate(fetchImpl: FetchLike, candidate: CandidateRef, signal?: AbortSignal): Promise<VerifyOutcome>;
|
|
74
|
+
/** 批量核验(顺序执行,礼貌对待免费库;单条失败不影响其它条)。 */
|
|
75
|
+
export declare function verifyAll(fetchImpl: FetchLike, candidates: readonly CandidateRef[], signal?: AbortSignal): Promise<VerifyOutcome[]>;
|
|
76
|
+
export declare function summarizeOutcomes(outcomes: readonly VerifyOutcome[]): string;
|
|
77
|
+
export declare function renderOutcome(outcome: VerifyOutcome): string;
|
|
78
|
+
export declare function renderVerifyReport(date: string, outcomes: readonly VerifyOutcome[], label: string): string;
|
|
79
|
+
export declare const VERIFY_REPORT_HEADER = "# \u6587\u732E\u6838\u9A8C\u62A5\u544A\n\n> \u53E3\u5F84\uFF1A\u6BCF\u6761\u5019\u9009\u6587\u732E\u5FC5\u987B\u7531 Crossref / OpenAlex / DataCite / PubMed \u4E2D\u7684\u6743\u5A01\u5E93\u786E\u8BA4\u5B58\u5728\uFF0C\n> \u4E14\u9898\u540D\u76F8\u4F3C\u5EA6 \u22650.92\u3001\u7B2C\u4E00\u4F5C\u8005\u59D3\u6C0F\u4E00\u81F4\u3001\u5E74\u4EFD\u5DEE \u22641\uFF0C\u624D\u5141\u8BB8\u5199\u5165 02-\u6587\u732E/refs.bib\u3002\n> Undermind / Elicit / Consensus \u53EA\u8D1F\u8D23\u300C\u627E\u5230\u5019\u9009\u300D\uFF0C\u5165\u5E93\u4E00\u5F8B\u4EE5\u672C\u62A5\u544A\u7684\u6838\u9A8C\u7ED3\u8BBA\u4E3A\u51C6\u3002\n";
|