med-pdf-nmo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -0
- package/README.ru.md +298 -0
- package/dist/bm25.d.ts +47 -0
- package/dist/bm25.js +86 -0
- package/dist/browser-shims/buffer.d.ts +30 -0
- package/dist/browser-shims/buffer.js +31 -0
- package/dist/browser-shims/crypto.d.ts +33 -0
- package/dist/browser-shims/crypto.js +45 -0
- package/dist/browser-shims/fs-promises.d.ts +13 -0
- package/dist/browser-shims/fs-promises.js +25 -0
- package/dist/browser-shims/fs.d.ts +14 -0
- package/dist/browser-shims/fs.js +24 -0
- package/dist/browser-shims/globals.d.ts +9 -0
- package/dist/browser-shims/globals.js +23 -0
- package/dist/browser-shims/path.d.ts +57 -0
- package/dist/browser-shims/path.js +65 -0
- package/dist/browser-shims/process.d.ts +22 -0
- package/dist/browser-shims/process.js +27 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +12 -0
- package/dist/chunk.d.ts +15 -0
- package/dist/chunk.js +76 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +87 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +51 -0
- package/dist/med-pdf-nmo.browser.js +40413 -0
- package/dist/med-pdf-nmo.browser.mjs +40395 -0
- package/dist/normalize.d.ts +73 -0
- package/dist/normalize.js +477 -0
- package/dist/pdf.d.ts +35 -0
- package/dist/pdf.js +396 -0
- package/dist/predictor/config.d.ts +28 -0
- package/dist/predictor/config.js +26 -0
- package/dist/predictor/constants.d.ts +3 -0
- package/dist/predictor/constants.js +59 -0
- package/dist/predictor/runtime.d.ts +15 -0
- package/dist/predictor/runtime.js +59 -0
- package/dist/predictor/scorers/biomedical-symbols.d.ts +36 -0
- package/dist/predictor/scorers/biomedical-symbols.js +347 -0
- package/dist/predictor/scorers/coordinate-table.d.ts +82 -0
- package/dist/predictor/scorers/coordinate-table.js +1210 -0
- package/dist/predictor/scorers/direction.d.ts +71 -0
- package/dist/predictor/scorers/direction.js +345 -0
- package/dist/predictor/scorers/drug-dose.d.ts +6 -0
- package/dist/predictor/scorers/drug-dose.js +221 -0
- package/dist/predictor/scorers/exact-answer.d.ts +10 -0
- package/dist/predictor/scorers/exact-answer.js +75 -0
- package/dist/predictor/scorers/fibrosis-stage.d.ts +6 -0
- package/dist/predictor/scorers/fibrosis-stage.js +103 -0
- package/dist/predictor/scorers/focused.d.ts +40 -0
- package/dist/predictor/scorers/focused.js +204 -0
- package/dist/predictor/scorers/frequency.d.ts +10 -0
- package/dist/predictor/scorers/frequency.js +203 -0
- package/dist/predictor/scorers/numeric.d.ts +77 -0
- package/dist/predictor/scorers/numeric.js +1161 -0
- package/dist/predictor/scorers/recommendation-item.d.ts +27 -0
- package/dist/predictor/scorers/recommendation-item.js +469 -0
- package/dist/predictor/scorers/search.d.ts +41 -0
- package/dist/predictor/scorers/search.js +515 -0
- package/dist/predictor/selection.d.ts +30 -0
- package/dist/predictor/selection.js +370 -0
- package/dist/predictor/text-utils.d.ts +49 -0
- package/dist/predictor/text-utils.js +497 -0
- package/dist/predictor/types.d.ts +23 -0
- package/dist/predictor/types.js +1 -0
- package/dist/predictor.d.ts +52 -0
- package/dist/predictor.js +3834 -0
- package/package.json +82 -0
package/dist/pdf.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { normalizeForSearch, normalizeText } from "./normalize.js";
|
|
2
|
+
let configuredPdfJs = null;
|
|
3
|
+
/**
|
|
4
|
+
* Настраивает модуль PDF.js, который будет использовать runtime.
|
|
5
|
+
*
|
|
6
|
+
* В браузере это удобно вызывать, когда PDF.js загружен с CDN или через
|
|
7
|
+
* собственный bundler. В Node.js обычно достаточно пакетного импорта.
|
|
8
|
+
*
|
|
9
|
+
* @param pdfjsLib Объект модуля с методом `getDocument`.
|
|
10
|
+
*/
|
|
11
|
+
export function setPdfJsLib(pdfjsLib) {
|
|
12
|
+
configuredPdfJs = pdfjsLib;
|
|
13
|
+
}
|
|
14
|
+
async function resolvePdfJs(options = {}) {
|
|
15
|
+
if (options.pdfjsLib?.getDocument)
|
|
16
|
+
return options.pdfjsLib;
|
|
17
|
+
if (configuredPdfJs?.getDocument)
|
|
18
|
+
return configuredPdfJs;
|
|
19
|
+
const fromGlobal = globalThis.pdfjsLib ?? globalThis.PDFJS;
|
|
20
|
+
if (fromGlobal?.getDocument)
|
|
21
|
+
return fromGlobal;
|
|
22
|
+
try {
|
|
23
|
+
return await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error("PDF.js is not available. In the browser, include pdf.js before this library or call setPdfJsLib(pdfjsLib).");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function toUint8Array(input) {
|
|
30
|
+
if (input instanceof Uint8Array) {
|
|
31
|
+
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
32
|
+
}
|
|
33
|
+
if (input instanceof ArrayBuffer)
|
|
34
|
+
return new Uint8Array(input.slice(0));
|
|
35
|
+
if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
36
|
+
return new Uint8Array(await input.arrayBuffer());
|
|
37
|
+
}
|
|
38
|
+
if (typeof input === "string") {
|
|
39
|
+
if (typeof fetch !== "function") {
|
|
40
|
+
throw new Error("String PDF input is treated as a URL, but fetch() is not available in this environment.");
|
|
41
|
+
}
|
|
42
|
+
const response = await fetch(input);
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`);
|
|
45
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
46
|
+
}
|
|
47
|
+
if (input?.arrayBuffer && typeof input.arrayBuffer === "function") {
|
|
48
|
+
return new Uint8Array(await input.arrayBuffer());
|
|
49
|
+
}
|
|
50
|
+
throw new Error("PDF input must be ArrayBuffer, Uint8Array, Blob/File, or URL string.");
|
|
51
|
+
}
|
|
52
|
+
function lineKey(item) {
|
|
53
|
+
const [, , , , , y] = item.transform ?? [1, 0, 0, 1, 0, 0];
|
|
54
|
+
return Math.round(y / 3) * 3;
|
|
55
|
+
}
|
|
56
|
+
function itemX(item) {
|
|
57
|
+
return item.transform?.[4] ?? 0;
|
|
58
|
+
}
|
|
59
|
+
function itemY(item) {
|
|
60
|
+
return item.transform?.[5] ?? 0;
|
|
61
|
+
}
|
|
62
|
+
function groupItemsIntoLineObjects(items) {
|
|
63
|
+
const useful = items
|
|
64
|
+
.filter((item) => typeof item.str === "string" && item.str.trim())
|
|
65
|
+
.sort((a, b) => itemY(b) - itemY(a) || itemX(a) - itemX(b));
|
|
66
|
+
const groups = [];
|
|
67
|
+
for (const item of useful) {
|
|
68
|
+
const key = lineKey(item);
|
|
69
|
+
let group = groups.find((candidate) => Math.abs(candidate.key - key) <= 2);
|
|
70
|
+
if (!group) {
|
|
71
|
+
group = { key, items: [] };
|
|
72
|
+
groups.push(group);
|
|
73
|
+
}
|
|
74
|
+
group.items.push(item);
|
|
75
|
+
}
|
|
76
|
+
groups.sort((a, b) => b.key - a.key);
|
|
77
|
+
return groups
|
|
78
|
+
.map((group) => {
|
|
79
|
+
const sortedItems = group.items.sort((a, b) => itemX(a) - itemX(b));
|
|
80
|
+
const text = sortedItems
|
|
81
|
+
.map((item) => item.str.trim())
|
|
82
|
+
.join(" ")
|
|
83
|
+
.replace(/\s+/g, " ")
|
|
84
|
+
.trim();
|
|
85
|
+
return {
|
|
86
|
+
text,
|
|
87
|
+
y: group.key,
|
|
88
|
+
items: sortedItems.map((item) => ({
|
|
89
|
+
text: item.str.trim(),
|
|
90
|
+
x: itemX(item),
|
|
91
|
+
y: itemY(item),
|
|
92
|
+
width: item.width ?? 0,
|
|
93
|
+
height: item.height ?? 0,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
})
|
|
97
|
+
.filter((line) => line.text);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Убирает повторяющийся служебный текст PDF (running header/footer, ссылки),
|
|
101
|
+
* который не несет содержательной информации для скоринга.
|
|
102
|
+
*
|
|
103
|
+
* Сопоставление идет с `normalizeText` (чистая кириллица в нижнем регистре),
|
|
104
|
+
* а не с `normalizeForSearch`, потому что последняя сворачивает кириллические
|
|
105
|
+
* lookalike-символы в латиницу. Правила нарочно общие и не привязаны к
|
|
106
|
+
* конкретному документу: колонтитул "страница N из M", бегущий заголовок
|
|
107
|
+
* "клинические рекомендации - <название> - <годы>" и строки-ссылки.
|
|
108
|
+
*/
|
|
109
|
+
function stripLikelyBoilerplate(lines) {
|
|
110
|
+
return lines.filter((line) => {
|
|
111
|
+
const text = typeof line === "string" ? line : line.text;
|
|
112
|
+
if (!normalizeForSearch(text))
|
|
113
|
+
return false;
|
|
114
|
+
const clean = normalizeText(text);
|
|
115
|
+
if (/^страниц[аы]\s+\d+\s+из\s+\d+\b/.test(clean))
|
|
116
|
+
return false;
|
|
117
|
+
if (/^[-\s]*\d{1,3}[-\s]*$/.test(clean))
|
|
118
|
+
return false;
|
|
119
|
+
if (/(https?:\/\/|www\.|disuria\.ru)/.test(clean))
|
|
120
|
+
return false;
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function buildPageText(lines) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const previous = out[out.length - 1] ?? "";
|
|
128
|
+
const startsList = /^(\d+(?:\.\d+)*[.)]?|[-*•]|[a-zа-я]\))\s+/iu.test(line);
|
|
129
|
+
const previousEnds = /[.!?;:]$/.test(previous) || previous.length < 30;
|
|
130
|
+
if (out.length && !startsList && !previousEnds && line.length < 100) {
|
|
131
|
+
out[out.length - 1] = `${previous} ${line}`.replace(/\s+/g, " ");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
out.push(line);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out.join("\n");
|
|
138
|
+
}
|
|
139
|
+
const BIBLIO_HEADING = /^(список\s+литературы|литература|библиографи)/;
|
|
140
|
+
const BIBLIO_NEXT_SECTION = /^(приложение|критерии оценки качества|связанные документы)/;
|
|
141
|
+
const TOC_HEADING = /^(содержание|оглавление)\b/;
|
|
142
|
+
// Точечная выноска оглавления: "Диагностика ............ 12" или вариант с
|
|
143
|
+
// символом многоточия "Диагностика …………… 12". 4+ точек подряд или символ «…»
|
|
144
|
+
// в обычном тексте как выноска не встречаются. Одиночное «…» в прозе не опасно:
|
|
145
|
+
// удаление включается только для плотного блока таких строк в начале документа.
|
|
146
|
+
const TOC_LEADER = /\.(\s?\.){3,}|…/;
|
|
147
|
+
/** Плоский индекс всех строк документа: {p: индекс страницы, l: индекс строки в странице}. */
|
|
148
|
+
function buildFlatLineIndex(pages) {
|
|
149
|
+
const flat = [];
|
|
150
|
+
pages.forEach((page, p) => page.lines.forEach((_, l) => flat.push({ p, l })));
|
|
151
|
+
return flat;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Удаляет строки flat[start..end) со страниц и пересобирает text/normalized.
|
|
155
|
+
* Каждый вызов должен получать свежий flat-индекс, потому что предыдущее
|
|
156
|
+
* удаление меняет page.lines.
|
|
157
|
+
*/
|
|
158
|
+
function removeFlatLineSpan(pages, flat, start, end) {
|
|
159
|
+
const removeByPage = new Map();
|
|
160
|
+
for (let i = start; i < end; i += 1) {
|
|
161
|
+
const f = flat[i];
|
|
162
|
+
if (!removeByPage.has(f.p))
|
|
163
|
+
removeByPage.set(f.p, new Set());
|
|
164
|
+
removeByPage.get(f.p).add(f.l);
|
|
165
|
+
}
|
|
166
|
+
for (const [p, removed] of removeByPage) {
|
|
167
|
+
const page = pages[p];
|
|
168
|
+
page.lines = page.lines.filter((_, idx) => !removed.has(idx));
|
|
169
|
+
page.lineItems = page.lineItems.filter((_, idx) => !removed.has(idx));
|
|
170
|
+
page.text = buildPageText(page.lines);
|
|
171
|
+
page.normalized = normalizeForSearch(page.text);
|
|
172
|
+
page.charLength = page.text.length;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Удаляет оглавление в начале документа.
|
|
177
|
+
*
|
|
178
|
+
* В НМО-рекомендациях оглавление часто идет БЕЗ заголовка "Содержание" — сразу
|
|
179
|
+
* списком пунктов с точечными выносками ("Диагностика ........ 12"). Поэтому
|
|
180
|
+
* детект идет по сигнатуре выноски, а не по заголовку: в ранней части документа
|
|
181
|
+
* берется плотный блок от первой до последней строки с выноской и удаляется
|
|
182
|
+
* целиком (вместе с переносами длинных названий). Предшествующий заголовок
|
|
183
|
+
* "Содержание"/"Оглавление" тоже убирается, если он есть.
|
|
184
|
+
*
|
|
185
|
+
* Оглавление это навигация (названия разделов + номера страниц), оно дублирует
|
|
186
|
+
* реальные заголовки тела и не может быть ответом. Тело идет ПОСЛЕ оглавления и
|
|
187
|
+
* выносок не имеет, поэтому контент не страдает.
|
|
188
|
+
*/
|
|
189
|
+
function removeTableOfContents(pages) {
|
|
190
|
+
const flat = buildFlatLineIndex(pages);
|
|
191
|
+
if (!flat.length)
|
|
192
|
+
return;
|
|
193
|
+
const lineRaw = (f) => pages[f.p].lines[f.l];
|
|
194
|
+
// Пункты оглавления опознаются по точечной выноске; берем их в ранней части.
|
|
195
|
+
const earlyLimit = Math.max(1, Math.floor(flat.length * 0.4));
|
|
196
|
+
const leaderIdx = [];
|
|
197
|
+
for (let i = 0; i < earlyLimit; i += 1) {
|
|
198
|
+
if (TOC_LEADER.test(lineRaw(flat[i])))
|
|
199
|
+
leaderIdx.push(i);
|
|
200
|
+
}
|
|
201
|
+
if (leaderIdx.length < 5)
|
|
202
|
+
return;
|
|
203
|
+
let start = leaderIdx[0];
|
|
204
|
+
const end = leaderIdx[leaderIdx.length - 1] + 1;
|
|
205
|
+
// Блок должен быть плотным по выноскам (оглавление, а не случайные строки).
|
|
206
|
+
if (leaderIdx.length / Math.max(1, end - start) < 0.3)
|
|
207
|
+
return;
|
|
208
|
+
// Включить предшествующий заголовок "Содержание"/"Оглавление", если он есть.
|
|
209
|
+
const prev = start > 0 ? normalizeText(lineRaw(flat[start - 1])) : "";
|
|
210
|
+
if (prev && prev.length <= 30 && TOC_HEADING.test(prev))
|
|
211
|
+
start -= 1;
|
|
212
|
+
removeFlatLineSpan(pages, flat, start, end);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Удаляет секцию "Список литературы" со страниц PDF.
|
|
216
|
+
*
|
|
217
|
+
* Список литературы это ссылки и цитаты (авторы, журналы, годы), он занимает
|
|
218
|
+
* около пятой части текста и не может быть правильным ответом на клинический
|
|
219
|
+
* вопрос НМО, но засоряет поиск и числовые/латинские совпадения. Секция строго
|
|
220
|
+
* ограничена: от заголовка "Список литературы" до следующего раздела
|
|
221
|
+
* ("Приложение ..."), поэтому все приложения с клиническим контентом
|
|
222
|
+
* сохраняются. Берется последнее вхождение заголовка (чтобы не спутать с
|
|
223
|
+
* пунктом оглавления) и только в последней части документа.
|
|
224
|
+
*/
|
|
225
|
+
function removeBibliographySection(pages) {
|
|
226
|
+
const flat = buildFlatLineIndex(pages);
|
|
227
|
+
if (!flat.length)
|
|
228
|
+
return;
|
|
229
|
+
const lineText = (f) => normalizeText(pages[f.p].lines[f.l]);
|
|
230
|
+
let start = -1;
|
|
231
|
+
for (let i = 0; i < flat.length; i += 1) {
|
|
232
|
+
const t = lineText(flat[i]);
|
|
233
|
+
if (BIBLIO_HEADING.test(t) && t.length <= 30)
|
|
234
|
+
start = i;
|
|
235
|
+
}
|
|
236
|
+
if (start < 0 || start < flat.length * 0.4)
|
|
237
|
+
return;
|
|
238
|
+
let end = flat.length;
|
|
239
|
+
for (let i = start + 1; i < flat.length; i += 1) {
|
|
240
|
+
if (BIBLIO_NEXT_SECTION.test(lineText(flat[i]))) {
|
|
241
|
+
end = i;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
removeFlatLineSpan(pages, flat, start, end);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Ранг приложения по букве/номеру: А1=1, А2=2, А3=3, Б=4, В=5, Г=6, иначе 0.
|
|
249
|
+
*
|
|
250
|
+
* Используется, чтобы безопасно удалять только метаданные-приложения (А1-А3:
|
|
251
|
+
* состав рабочей группы, методология, связанные документы) и всегда сохранять
|
|
252
|
+
* клинические приложения (Б — алгоритмы, В — памятка пациенту, Г — шкалы).
|
|
253
|
+
*/
|
|
254
|
+
function appendixRank(t) {
|
|
255
|
+
if (/^приложение\s*а\s*1/.test(t) || /^приложениеа\s*1/.test(t))
|
|
256
|
+
return 1;
|
|
257
|
+
if (/^приложение\s*а\s*2/.test(t) || /^приложениеа\s*2/.test(t))
|
|
258
|
+
return 2;
|
|
259
|
+
if (/^приложение\s*а\s*3/.test(t) || /^приложениеа\s*3/.test(t))
|
|
260
|
+
return 3;
|
|
261
|
+
if (/^приложение\s*б/.test(t) || /^приложениеб/.test(t))
|
|
262
|
+
return 4;
|
|
263
|
+
if (/^приложение\s*в/.test(t) || /^приложениев/.test(t))
|
|
264
|
+
return 5;
|
|
265
|
+
if (/^приложение\s*г/.test(t) || /^приложениег/.test(t))
|
|
266
|
+
return 6;
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Удаляет метаданные-приложение: от приложения ранга `fromRank` до первого
|
|
271
|
+
* приложения с рангом >= `toRank`. Это служебные разделы (ФИО рабочей группы,
|
|
272
|
+
* методология, связанные документы), а не клинический контент. Удаление
|
|
273
|
+
* происходит ТОЛЬКО если найдены и стартовое приложение, и приложение-терминатор
|
|
274
|
+
* нужного ранга, иначе ничего не удаляется — так клинические приложения Б/В/Г
|
|
275
|
+
* не пострадают, даже если разметка PDF нестандартная. Диапазонная форма
|
|
276
|
+
* позволяет убрать одно приложение изолированно (например, только А3),
|
|
277
|
+
* сохранив соседние.
|
|
278
|
+
*/
|
|
279
|
+
function removeMetadataAppendices(pages, fromRank, toRank) {
|
|
280
|
+
const flat = buildFlatLineIndex(pages);
|
|
281
|
+
if (!flat.length)
|
|
282
|
+
return;
|
|
283
|
+
const lineText = (f) => normalizeText(pages[f.p].lines[f.l]);
|
|
284
|
+
let start = -1;
|
|
285
|
+
for (let i = 0; i < flat.length; i += 1) {
|
|
286
|
+
if (appendixRank(lineText(flat[i])) === fromRank)
|
|
287
|
+
start = i;
|
|
288
|
+
}
|
|
289
|
+
if (start < 0 || start < flat.length * 0.4)
|
|
290
|
+
return;
|
|
291
|
+
let end = -1;
|
|
292
|
+
for (let i = start + 1; i < flat.length; i += 1) {
|
|
293
|
+
if (appendixRank(lineText(flat[i])) >= toRank) {
|
|
294
|
+
end = i;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (end < 0)
|
|
299
|
+
return;
|
|
300
|
+
removeFlatLineSpan(pages, flat, start, end);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Удаляет список приложений из front matter (первые ~15% документа).
|
|
304
|
+
*
|
|
305
|
+
* В части PDF оглавление перечисляет приложения отдельным блоком без точечных
|
|
306
|
+
* выносок ("Приложение А1. Состав рабочей группы... / Приложение А2. Методология
|
|
307
|
+
* / Приложение А3. Справочные материалы"), поэтому leader-детект оглавления его
|
|
308
|
+
* не ловит. Это TOC-остаток (само тело приложений в конце документа). Удаляется
|
|
309
|
+
* только плотный блок из >=2 заголовков приложений в первых 15% — настоящие
|
|
310
|
+
* приложения тела лежат в последней части и не затрагиваются.
|
|
311
|
+
*/
|
|
312
|
+
function removeFrontMatterAppendixList(pages) {
|
|
313
|
+
const flat = buildFlatLineIndex(pages);
|
|
314
|
+
if (!flat.length)
|
|
315
|
+
return;
|
|
316
|
+
const limit = Math.max(1, Math.floor(flat.length * 0.15));
|
|
317
|
+
const idx = [];
|
|
318
|
+
for (let i = 0; i < limit; i += 1) {
|
|
319
|
+
if (appendixRank(normalizeText(pages[flat[i].p].lines[flat[i].l])) > 0)
|
|
320
|
+
idx.push(i);
|
|
321
|
+
}
|
|
322
|
+
if (idx.length < 2 || idx[idx.length - 1] - idx[0] > 15)
|
|
323
|
+
return;
|
|
324
|
+
const start = idx[0];
|
|
325
|
+
let end = idx[idx.length - 1] + 1;
|
|
326
|
+
// Захватить одну строку-перенос названия последнего приложения, если она короткая.
|
|
327
|
+
if (end < limit) {
|
|
328
|
+
const t = normalizeText(pages[flat[end].p].lines[flat[end].l]);
|
|
329
|
+
if (t.length > 0 && t.length < 60 && appendixRank(t) === 0 && !/^(список|термины|введение|1\b)/.test(t))
|
|
330
|
+
end += 1;
|
|
331
|
+
}
|
|
332
|
+
removeFlatLineSpan(pages, flat, start, end);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Извлекает текст и легкие layout-метаданные из PDF.
|
|
336
|
+
*
|
|
337
|
+
* Экстрактор принимает байты, браузерные File/Blob, ArrayBuffer-подобные
|
|
338
|
+
* объекты или URL-строки. Возвращает текст страниц, строки, нормализованный
|
|
339
|
+
* текст и флаг `ocrNeeded`, если в PDF найдено подозрительно мало текста.
|
|
340
|
+
*
|
|
341
|
+
* @param pdfInput Байты PDF, File/Blob, ArrayBuffer, Uint8Array или URL.
|
|
342
|
+
* @param options Необязательный `cacheKey` и явно переданный `pdfjsLib`.
|
|
343
|
+
* @returns Текст страниц и метаданные, которые использует predictor.
|
|
344
|
+
*/
|
|
345
|
+
export async function extractPdfText(pdfInput, options = {}) {
|
|
346
|
+
const pdfjs = await resolvePdfJs(options);
|
|
347
|
+
const data = await toUint8Array(pdfInput);
|
|
348
|
+
const loadingTask = pdfjs.getDocument({
|
|
349
|
+
data,
|
|
350
|
+
disableWorker: true,
|
|
351
|
+
useSystemFonts: true,
|
|
352
|
+
isEvalSupported: false,
|
|
353
|
+
});
|
|
354
|
+
const pdf = await loadingTask.promise;
|
|
355
|
+
const pages = [];
|
|
356
|
+
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
|
357
|
+
const page = await pdf.getPage(pageNumber);
|
|
358
|
+
const content = await page.getTextContent({
|
|
359
|
+
disableCombineTextItems: false,
|
|
360
|
+
includeMarkedContent: false,
|
|
361
|
+
});
|
|
362
|
+
const lineObjects = stripLikelyBoilerplate(groupItemsIntoLineObjects(content.items));
|
|
363
|
+
const lines = lineObjects.map((line) => line.text);
|
|
364
|
+
const text = buildPageText(lines);
|
|
365
|
+
pages.push({
|
|
366
|
+
page: pageNumber,
|
|
367
|
+
text,
|
|
368
|
+
lines,
|
|
369
|
+
lineItems: lineObjects,
|
|
370
|
+
normalized: normalizeForSearch(text),
|
|
371
|
+
charLength: text.length,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
removeTableOfContents(pages);
|
|
375
|
+
removeFrontMatterAppendixList(pages);
|
|
376
|
+
removeBibliographySection(pages);
|
|
377
|
+
removeMetadataAppendices(pages, 1, 2);
|
|
378
|
+
const pageTextChars = pages.reduce((sum, page) => sum + page.text.length, 0);
|
|
379
|
+
return {
|
|
380
|
+
pdfId: options.cacheKey ?? (typeof pdfInput === "string" ? pdfInput : "<browser-pdf>"),
|
|
381
|
+
cacheVersion: 1,
|
|
382
|
+
pageCount: pdf.numPages,
|
|
383
|
+
extractedAt: new Date().toISOString(),
|
|
384
|
+
pages,
|
|
385
|
+
ocrNeeded: pageTextChars < Math.max(1000, pdf.numPages * 100),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Очищает кеш извлечения PDF.
|
|
390
|
+
*
|
|
391
|
+
* Текущая browser-first реализация хранит текст PDF в runtime-кеше predictor,
|
|
392
|
+
* поэтому эта функция намеренно оставлена как совместимый no-op.
|
|
393
|
+
*/
|
|
394
|
+
export function clearPdfMemoryCache() {
|
|
395
|
+
// Browser build keeps PDF text in the predictor runtime cache.
|
|
396
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Зафиксированная конфигурация predictor, выбранная по результатам
|
|
3
|
+
* измеримых валидационных прогонов.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_CONFIG: {
|
|
6
|
+
multiRelativeThreshold: number;
|
|
7
|
+
multiAbsoluteThreshold: number;
|
|
8
|
+
multiGapThreshold: number;
|
|
9
|
+
multiMinAnswers: number;
|
|
10
|
+
multiThirdGapThreshold: number;
|
|
11
|
+
multiThirdRelativeThreshold: number;
|
|
12
|
+
frozenFeatureRanker: boolean;
|
|
13
|
+
multiCardinalityModel: boolean;
|
|
14
|
+
multiAllOptionsGuard: boolean;
|
|
15
|
+
multiCrowdedTailGuard: boolean;
|
|
16
|
+
pairwiseContrastRanker: boolean;
|
|
17
|
+
structuralClusterAdjustments: boolean;
|
|
18
|
+
singleSpecificityTieBreak: boolean;
|
|
19
|
+
singleTieMaxRawGap: number;
|
|
20
|
+
singleTieMinRawRatio: number;
|
|
21
|
+
singleTieSpecificityGap: number;
|
|
22
|
+
sharedMultiSegmentBoost: boolean;
|
|
23
|
+
countRelationBoost: boolean;
|
|
24
|
+
topQuestionChunks: number;
|
|
25
|
+
evidenceLimit: number;
|
|
26
|
+
};
|
|
27
|
+
/** Runtime-форма объекта конфигурации predictor. */
|
|
28
|
+
export type PredictorConfig = typeof DEFAULT_CONFIG;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Зафиксированная конфигурация predictor, выбранная по результатам
|
|
3
|
+
* измеримых валидационных прогонов.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
multiRelativeThreshold: 0.84,
|
|
7
|
+
multiAbsoluteThreshold: 12,
|
|
8
|
+
multiGapThreshold: 0.72,
|
|
9
|
+
multiMinAnswers: 2,
|
|
10
|
+
multiThirdGapThreshold: 0.45,
|
|
11
|
+
multiThirdRelativeThreshold: 0.55,
|
|
12
|
+
frozenFeatureRanker: true,
|
|
13
|
+
multiCardinalityModel: true,
|
|
14
|
+
multiAllOptionsGuard: true,
|
|
15
|
+
multiCrowdedTailGuard: true,
|
|
16
|
+
pairwiseContrastRanker: true,
|
|
17
|
+
structuralClusterAdjustments: true,
|
|
18
|
+
singleSpecificityTieBreak: true,
|
|
19
|
+
singleTieMaxRawGap: 0.2,
|
|
20
|
+
singleTieMinRawRatio: 0.94,
|
|
21
|
+
singleTieSpecificityGap: 0.5,
|
|
22
|
+
sharedMultiSegmentBoost: true,
|
|
23
|
+
countRelationBoost: true,
|
|
24
|
+
topQuestionChunks: 28,
|
|
25
|
+
evidenceLimit: 8,
|
|
26
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { normalizeForSearch, uniqueTokens } from "../normalize.js";
|
|
2
|
+
const FOCUS_STOPWORD_TEXT = [
|
|
3
|
+
"пациент",
|
|
4
|
+
"пациенты",
|
|
5
|
+
"пациентам",
|
|
6
|
+
"больной",
|
|
7
|
+
"больным",
|
|
8
|
+
"пострадавший",
|
|
9
|
+
"пострадавшим",
|
|
10
|
+
"заболевание",
|
|
11
|
+
"заболевания",
|
|
12
|
+
"состояние",
|
|
13
|
+
"состояния",
|
|
14
|
+
"группа",
|
|
15
|
+
"группы",
|
|
16
|
+
"острый",
|
|
17
|
+
"острым",
|
|
18
|
+
"хронический",
|
|
19
|
+
"рекомендуется",
|
|
20
|
+
"рекомендовано",
|
|
21
|
+
"рекомендованы",
|
|
22
|
+
"проведение",
|
|
23
|
+
"проводится",
|
|
24
|
+
"применение",
|
|
25
|
+
"назначается",
|
|
26
|
+
"исследование",
|
|
27
|
+
"является",
|
|
28
|
+
"являются",
|
|
29
|
+
"относятся",
|
|
30
|
+
"относится",
|
|
31
|
+
"следующие",
|
|
32
|
+
"метод",
|
|
33
|
+
"методы",
|
|
34
|
+
"цель",
|
|
35
|
+
"целью",
|
|
36
|
+
"данные",
|
|
37
|
+
"данных",
|
|
38
|
+
"наличие",
|
|
39
|
+
"форма",
|
|
40
|
+
"формы",
|
|
41
|
+
"тип",
|
|
42
|
+
"вид",
|
|
43
|
+
"виды",
|
|
44
|
+
];
|
|
45
|
+
export const FOCUS_STOPWORDS = new Set(FOCUS_STOPWORD_TEXT.flatMap((item) => uniqueTokens(item)));
|
|
46
|
+
export const LABEL_CUES = ["легк", "средн", "тяжел", "крайн", "умерен", "локализован", "генерализован"].map((item) => normalizeForSearch(item));
|
|
47
|
+
export const SECTION_GENERIC_TOKENS = new Set([
|
|
48
|
+
"поражение",
|
|
49
|
+
"поражения",
|
|
50
|
+
"повреждение",
|
|
51
|
+
"дыхательный",
|
|
52
|
+
"дыхательных",
|
|
53
|
+
"путь",
|
|
54
|
+
"путей",
|
|
55
|
+
"орган",
|
|
56
|
+
"органы",
|
|
57
|
+
"ткань",
|
|
58
|
+
"система",
|
|
59
|
+
].flatMap((item) => uniqueTokens(item)));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Нормализует публичные варианты ответа в стабильные объекты `{ id, text }`.
|
|
3
|
+
*/
|
|
4
|
+
export declare function normalizeAnswers(answers: any): any;
|
|
5
|
+
/**
|
|
6
|
+
* Создает или переиспользует runtime-состояние PDF для одного предсказания.
|
|
7
|
+
*
|
|
8
|
+
* Runtime содержит извлеченный текст PDF, поисковые чанки и BM25-индекс.
|
|
9
|
+
* Кеширование идет по явному `cacheKey`, URL-строке или identity объекта.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getPdfRuntime(pdfInput: any, options?: any): Promise<any>;
|
|
12
|
+
/**
|
|
13
|
+
* Очищает keyed runtime-кеш PDF.
|
|
14
|
+
*/
|
|
15
|
+
export declare function clearPdfRuntimeCache(): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BM25Index } from "../bm25.js";
|
|
2
|
+
import { buildChunks } from "../chunk.js";
|
|
3
|
+
import { extractPdfText } from "../pdf.js";
|
|
4
|
+
const keyedRuntimeCache = new Map();
|
|
5
|
+
const objectRuntimeCache = new WeakMap();
|
|
6
|
+
function answerId(index) {
|
|
7
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
8
|
+
if (index < alphabet.length)
|
|
9
|
+
return alphabet[index];
|
|
10
|
+
return `A${index + 1}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Нормализует публичные варианты ответа в стабильные объекты `{ id, text }`.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeAnswers(answers) {
|
|
16
|
+
return answers.map((answer, index) => {
|
|
17
|
+
if (typeof answer === "string") {
|
|
18
|
+
return { id: answerId(index), text: answer };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
id: String(answer.id ?? answerId(index)),
|
|
22
|
+
text: String(answer.text ?? ""),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function objectKey(input) {
|
|
27
|
+
return input && typeof input === "object" ? input : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Создает или переиспользует runtime-состояние PDF для одного предсказания.
|
|
31
|
+
*
|
|
32
|
+
* Runtime содержит извлеченный текст PDF, поисковые чанки и BM25-индекс.
|
|
33
|
+
* Кеширование идет по явному `cacheKey`, URL-строке или identity объекта.
|
|
34
|
+
*/
|
|
35
|
+
export async function getPdfRuntime(pdfInput, options = {}) {
|
|
36
|
+
const cacheKey = options.cacheKey ?? (typeof pdfInput === "string" ? pdfInput : null);
|
|
37
|
+
if (cacheKey && keyedRuntimeCache.has(cacheKey))
|
|
38
|
+
return keyedRuntimeCache.get(cacheKey);
|
|
39
|
+
const weakKey = objectKey(pdfInput);
|
|
40
|
+
if (!cacheKey && weakKey && objectRuntimeCache.has(weakKey))
|
|
41
|
+
return objectRuntimeCache.get(weakKey);
|
|
42
|
+
const runtimePromise = (async () => {
|
|
43
|
+
const pdfText = await extractPdfText(pdfInput, options);
|
|
44
|
+
const chunks = buildChunks(pdfText);
|
|
45
|
+
const index = new BM25Index(chunks);
|
|
46
|
+
return { pdfText, chunks, index };
|
|
47
|
+
})();
|
|
48
|
+
if (cacheKey)
|
|
49
|
+
keyedRuntimeCache.set(cacheKey, runtimePromise);
|
|
50
|
+
else if (weakKey)
|
|
51
|
+
objectRuntimeCache.set(weakKey, runtimePromise);
|
|
52
|
+
return runtimePromise;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Очищает keyed runtime-кеш PDF.
|
|
56
|
+
*/
|
|
57
|
+
export function clearPdfRuntimeCache() {
|
|
58
|
+
keyedRuntimeCache.clear();
|
|
59
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Возвращает короткие латинские/буквенно-цифровые токены из варианта ответа:
|
|
3
|
+
* гены, маркеры, коды и похожие biomedical-обозначения.
|
|
4
|
+
*/
|
|
5
|
+
export declare function latinAnswerTokens(text: any): [] | RegExpMatchArray;
|
|
6
|
+
/**
|
|
7
|
+
* Ищет OCR-поддержку латинского варианта ответа на релевантных страницах.
|
|
8
|
+
* Это общий fallback для коротких biomedical-кодов, а не медицинский словарь.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bestLatinFuzzySupport({ pages, topQuestionPages, questionTokens, answer }: {
|
|
11
|
+
pages: any;
|
|
12
|
+
topQuestionPages: any;
|
|
13
|
+
questionTokens: any;
|
|
14
|
+
answer: any;
|
|
15
|
+
}): any;
|
|
16
|
+
/**
|
|
17
|
+
* Проверяет, что вопрос действительно про мутации/полиморфизмы генов.
|
|
18
|
+
* Такой gate нужен, чтобы gene-specific OCR-логика не влияла на обычные вопросы.
|
|
19
|
+
*/
|
|
20
|
+
export declare function geneMutationQuestion(question: any): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Делит текст PDF на достаточно длинные предложения. Для biomedical-symbol
|
|
23
|
+
* задач предложение часто надежнее широкого окна, потому что список генов
|
|
24
|
+
* обычно находится в одной фразе.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sentenceSegments(text: any): string[];
|
|
27
|
+
/**
|
|
28
|
+
* Для вопросов про мутации генов выбирает предложение с фокусом вопроса и
|
|
29
|
+
* проверяет, есть ли в нем вариант ответа как латинский или OCR-искаженный код.
|
|
30
|
+
*/
|
|
31
|
+
export declare function bestGeneSentenceSupport({ pages, topQuestionPages, question, answer }: {
|
|
32
|
+
pages: any;
|
|
33
|
+
topQuestionPages: any;
|
|
34
|
+
question: any;
|
|
35
|
+
answer: any;
|
|
36
|
+
}): any;
|