From d7c004af768a9c4d291d17540bb98bb8e27ae86b Mon Sep 17 00:00:00 2001 From: jze9 Date: Fri, 28 Aug 2026 17:45:48 +0500 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D1=83=D0=B3=D0=BB=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=D1=86=D0=B8=D0=B8=20=D0=9A=D0=B8=D0=B1=D0=B5=D1=80?= =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D0=B8=D0=BD=D0=BA=D0=B8=20+=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20store=5Ffull=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка глубины вскрыла главную слабость корпуса: 99 883 документа КиберЛенинки (56% базы) имеют в среднем 30 отпечатков — заголовок с аннотацией. Списывание из тела русской статьи L1 не находит, хотя сервис рассчитан именно на русских студентов. У PMC и arXiv глубина 97% и 94%. Отдельно: мерить глубину по minio_key оказалось неверно — PMC кладёт тело статьи в отпечатки при заливке, не сохраняя файл, поэтому «27.5% с полным текстом» занижало картину для одних источников и скрывало провал у другой. Честный показатель — число отпечатков на документ, по нему всё и пересчитано. - backfill_cyberleninka_pdf.py: PDF берётся прямым адресом {url}/pdf (докачка по обычному url бесполезна — там HTML, замер 0 из 8). Проверено на 50: углублено 44, в среднем 23 тыс. символов, глубина 30 → 1453 отпечатка; - backfill_pmc_fulltext.py: сохранение тела статьи из API в MinIO (отпечатки там уже глубокие — скрипт нужен для отчётов и подсветки, не для детекции); - store_full_text вынесен из index.enrich_full_text: один путь «текст получен → MinIO + пересчёт L1 + обновление L2» для задачи докачки и для бэкфиллов. Co-Authored-By: Claude Opus 5 --- docs/INGESTION.md | 37 +++--- scripts/ops/backfill_cyberleninka_pdf.py | 112 +++++++++++++++++++ scripts/ops/backfill_pmc_fulltext.py | 124 +++++++++++++++++++++ services/worker-indexer/app/tasks/index.py | 85 ++++++++------ 4 files changed, 309 insertions(+), 49 deletions(-) create mode 100755 scripts/ops/backfill_cyberleninka_pdf.py create mode 100755 scripts/ops/backfill_pmc_fulltext.py diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 7c06951..75b7959 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -12,21 +12,32 @@ 1490 из 1500 на источник): прирост дают только новые публикации. Реальный рост корпуса — поднятый `limit` или новые темы, а не повторный запуск. -### Глубина корпуса — чем реально располагает детекция (замер 2026-08-28) +### Глубина индексации — чем реально располагает детекция (замер 2026-08-28) -| Что | Значение | Следствие | -|-----|----------|-----------| -| Документов | 177 147 | — | -| С полным текстом в MinIO | 48 848 (27.5%) | arXiv 93.7%, КиберЛенинка 27.8%, PMC 17.0%, OpenAlex 12.7% | -| Отпечатков < 500 на документ | 128 246 (79%) | по этим документам L1 сравнивает фактически аннотацию, а не тело статьи | -| Векторов в индексе (L3) | 93 053 (52.5%) | остальные документы семантический поиск не видит | -| Ложных отметок `faiss_id` | 60 993 | вектора нет, но документ считается векторизованным — см. `faiss_reconcile.py` | +Мерить глубину по `minio_key` (сохранён ли текст в MinIO) **нельзя**: PMC, +например, кладёт тело статьи в отпечатки прямо при заливке, не сохраняя файл. +Честный показатель — **число отпечатков на документ**: аннотация даёт десятки, +полный текст — тысячи. -Это не поломка, а честная граница возможностей: **система ловит списывание из -того, что у неё есть целиком.** Скопированное из тела статьи, от которой в базе -только аннотация, L1 не найдёт. Рычаги углубления: `FETCH_FULL_TEXT=true` -(докачка OA-PDF, уже включена), приоритет источников с полным текстом (PMC, -arXiv), и добор эмбеддингов для L3. +| Источник | Документов | Глубоко (≥500 отпечатков) | Среднее отпечатков | +|----------|-----------:|--------------------------:|-------------------:| +| КиберЛенинка | 99 883 | 0 (0.0%) | **30** | +| OpenAlex | 49 276 | 6 240 (12.7%) | 907 | +| PMC | 14 910 | 14 519 (97.4%) | 2 208 | +| arXiv | 13 077 | 12 236 (93.6%) | 4 035 | + +**Главная слабость — русская часть корпуса.** 56% базы (КиберЛенинка) в индексе +представлено заголовком и аннотацией: списывание из тела русской статьи L1 не +найдёт, хотя сервис рассчитан именно на русских студентов. Причина не в +алгоритме: search API отдаёт только аннотацию и OCR-фрагмент (~700 символов), а +`url` ведёт на HTML-страницу — докачка по нему бесполезна (замер: 0 из 8). +Лечится `scripts/ops/backfill_cyberleninka_pdf.py`: PDF доступен прямым адресом +`{url}/pdf` (проверено: 44 из 50, в среднем 23 тыс. символов, глубина 30 → ~1450 +отпечатков). Полный прогон — около 48 часов при вежливом 1 req/s и порядка ++220 млн строк в `fingerprints` (~22 ГБ; место на сервере БД проверять заранее). + +Покрытие L3 на ту же дату — 93 053 вектора (52.5% корпуса); ещё 60 993 документа +числились векторизованными ошибочно, отметки сброшены `faiss_reconcile.py`. - OpenAlex при массовом запуске упирается в лимит вежливого пула (10 req/s на mailto, общий для всех воркеров) — пауза между страницами поднята до 1с. - Добавлен 4-й парсер — **PMC** (PubMed Central, `scripts/parsers/pmc.py`), diff --git a/scripts/ops/backfill_cyberleninka_pdf.py b/scripts/ops/backfill_cyberleninka_pdf.py new file mode 100755 index 0000000..847a917 --- /dev/null +++ b/scripts/ops/backfill_cyberleninka_pdf.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Углубить индексацию КиберЛенинки: скачать PDF статей и переиндексировать по ним. + +Самая большая слабость корпуса (замер 2026-08-28): 99 883 документа +КиберЛенинки — 56% всей базы — имеют в среднем 30 отпечатков. Это заголовок с +аннотацией, тела статьи в индексе нет. Списывание из русской статьи система +не найдёт, хотя сервис рассчитан именно на русских студентов. + +Причина: search API отдаёт только аннотацию и короткий OCR-фрагмент (~700 +символов), а `url` ведёт на HTML-страницу — докачка по нему бесполезна +(замер: 0 из 8). Зато PDF доступен прямым адресом `{url}/pdf` и извлекается: +проверка на 4 статьях дала 13-40 тыс. символов у трёх, у одной 0 (скан без +текстового слоя — такие пропускаем). + +Дальше — общий путь `store_full_text`: MinIO + пересчёт отпечатков L1 по +полному тексту + обновление MinHash LSH (L2). + +Масштаб полного прогона: ~28 часов при вежливом 1 req/s, порядка +300 млн строк +в fingerprints (~30 ГБ). Запускать в фоне (nohup) и следить за местом на БД. + +Запуск (в контейнере worker-indexer): + cd /home/user/anti-plagiarism + C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -" + $C < scripts/ops/backfill_cyberleninka_pdf.py # dry-run + $C --apply --limit 50 < scripts/ops/backfill_cyberleninka_pdf.py # пробная порция + $C --apply < scripts/ops/backfill_cyberleninka_pdf.py # всё (сутки) + +Идемпотентно: документы с уже проставленным minio_key пропускаются, так что +прерванный прогон продолжается с того же места. +""" + +import argparse +import time + +RATE_LIMIT_DELAY = 1.0 # КиберЛенинка не любит частых запросов +MIN_USEFUL_CHARS = 1500 # меньше — скан без текстового слоя либо обрывок +MAX_PDF_BYTES = 30 * 1024 * 1024 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--apply", action="store_true", help="реально качать и сохранять") + ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)") + args = ap.parse_args() + + import httpx + from app.db import db_session + from app.extractors.pdf import extract_text_from_pdf + from app.tasks.index import store_full_text + from sqlalchemy import text + + with db_session() as s: + sql = """ + SELECT id, url FROM documents + WHERE source = 'cyberleninka' AND minio_key IS NULL AND url IS NOT NULL + ORDER BY id + """ + if args.limit: + sql += f" LIMIT {int(args.limit)}" + rows = s.execute(text(sql)).all() + + print(f"документов КиберЛенинки без полного текста: {len(rows)}") + if not rows: + return + if not args.apply: + print(f"[dry-run] пример: {rows[0][1]}/pdf") + print(f"оценка полного прогона: ~{len(rows) * RATE_LIMIT_DELAY / 3600:.1f} ч, " + f"~{len(rows) * 3000 / 1e6:.0f} млн строк отпечатков") + print("Запустите с --apply (сначала --limit 50).") + return + + client = httpx.Client( + timeout=30, follow_redirects=True, + headers={"User-Agent": "Mozilla/5.0 (compatible; AcademicHelper/1.0; +noreply@jze9.ru)"}, + ) + done = no_text = failed = 0 + chars_total = 0 + t0 = time.time() + + for n, (doc_id, url) in enumerate(rows, 1): + try: + resp = client.get(url.rstrip("/") + "/pdf") + if resp.status_code != 200 or "pdf" not in resp.headers.get("content-type", "").lower() or len(resp.content) > MAX_PDF_BYTES: + no_text += 1 + else: + body = extract_text_from_pdf(resp.content) + if len(body) < MIN_USEFUL_CHARS: + no_text += 1 # скан без текстового слоя + else: + store_full_text(doc_id, body) + done += 1 + chars_total += len(body) + except Exception as e: + failed += 1 + if failed <= 5: + print(f" doc {doc_id}: {type(e).__name__}: {str(e)[:80]}") + + if n % 25 == 0: + el = time.time() - t0 + print(f" {n}/{len(rows)} · углублено {done} · без текста {no_text} · " + f"ошибок {failed} · {n / el:.2f} док/с · осталось ~{(len(rows) - n) / max(n / el, 0.01) / 3600:.1f} ч", + flush=True) + time.sleep(RATE_LIMIT_DELAY) + + avg = chars_total // done if done else 0 + print(f"\nГотово за {(time.time() - t0) / 60:.1f} мин: углублено {done}, " + f"без текстового слоя {no_text}, ошибок {failed}, средний объём {avg} симв.") + + +if __name__ == "__main__": + main() diff --git a/scripts/ops/backfill_pmc_fulltext.py b/scripts/ops/backfill_pmc_fulltext.py new file mode 100755 index 0000000..31cc979 --- /dev/null +++ b/scripts/ops/backfill_pmc_fulltext.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Бэкфилл полного текста для уже залитых документов PMC. + +Зачем: PMC отдаёт тело статьи прямо в ответе efetch (40-50 тыс. символов), но +документы, залитые до включения сохранения full_text, остались в базе с одной +аннотацией — L1 сравнивает по ней и не видит тело статьи. Докачка PDF по url +здесь не работает: ссылка ведёт на HTML-страницу NCBI (замер: 0 из 8). Поэтому +текст берём тем же путём, что и парсер, — через API. + +Замер на проде 2026-08-28: 12 368 документов PMC без полного текста. + +Что делает для каждого документа: efetch по PMC id → извлечение текста → +`store_full_text` (та же функция, что у задачи докачки: MinIO + пересчёт +fingerprints по полному тексту + обновление MinHash LSH). + +Идемпотентно: документы с уже проставленным minio_key не берутся. + +Запуск (в контейнере worker-indexer, репозиторий внутрь не смонтирован): + cd /home/user/anti-plagiarism + C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -" + $C < scripts/ops/backfill_pmc_fulltext.py # dry-run + $C --apply --limit 40 < scripts/ops/backfill_pmc_fulltext.py # пробная порция + $C --apply < scripts/ops/backfill_pmc_fulltext.py # всё + +Внимание: каждый документ добавляет ~3.5 тыс. строк в fingerprints (~4.5 ГБ +на все 12 тыс.). Перед полным прогоном стоит убедиться в свободном месте на +сервере БД. +""" + +import argparse +import sys +import time + +BATCH = 20 # столько id за один efetch — как в самом парсере + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--apply", action="store_true", help="реально сохранять (иначе dry-run)") + ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)") + args = ap.parse_args() + + if "/parsers" not in sys.path: + sys.path.insert(0, "/parsers") + + from app.db import db_session + from app.tasks.index import store_full_text + from sqlalchemy import text + + with db_session() as s: + sql = """ + SELECT id, ext_id FROM documents + WHERE source = 'pmc' AND minio_key IS NULL AND ext_id IS NOT NULL + ORDER BY id + """ + if args.limit: + sql += f" LIMIT {int(args.limit)}" + rows = s.execute(text(sql)).all() + + # ext_id формата "pmc:13521573" → числовой id для efetch + todo = {ext.split(":", 1)[-1]: doc_id for doc_id, ext in rows if ":" in ext} + print(f"документов PMC без полного текста: {len(rows)} (пригодных ext_id: {len(todo)})") + if not todo: + return + + if not args.apply: + print(f"[dry-run] первые id: {list(todo.items())[:5]}") + print("Запустите с --apply (рекомендуется сначала --limit 40).") + return + + from pmc import PMCParser + + parser = PMCParser() + ids = list(todo) + done = failed = empty = 0 + chars_total = 0 + t0 = time.time() + + for i in range(0, len(ids), BATCH): + batch = ids[i : i + BATCH] + try: + resp = parser.client.get( + "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi", + params=parser._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"), + ) + resp.raise_for_status() + articles = parser._parse_articles(resp.text) + except Exception as e: + failed += len(batch) + print(f" батч {i // BATCH}: ошибка запроса: {e}") + continue + + for raw in articles: + doc = parser.transform(raw) + ext = (doc.get("ext_id") or "").split(":", 1)[-1] + doc_id = todo.get(ext) + full = doc.get("full_text") or "" + if not doc_id: + continue + if len(full) < 1000: # аннотация вместо тела — сохранять нечего + empty += 1 + continue + try: + store_full_text(doc_id, full) + done += 1 + chars_total += len(full) + except Exception as e: + failed += 1 + print(f" doc {doc_id}: не сохранён: {e}") + + if (i // BATCH) % 10 == 0: + speed = done / max(time.time() - t0, 1) + print(f" обработано {i + len(batch)}/{len(ids)} · сохранено {done} · " + f"{speed:.1f} док/с") + time.sleep(0.35) # лимит NCBI без ключа — 3 запроса/с + + avg = chars_total // done if done else 0 + print(f"\nГотово за {time.time() - t0:.0f}с: сохранено {done}, " + f"без тела статьи {empty}, ошибок {failed}, средний объём {avg} симв.") + + +if __name__ == "__main__": + main() diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index ac8a81f..41ce0cd 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -347,6 +347,53 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[ return {"status": "indexed", "doc_id": doc_id} +def store_full_text(doc_id: int, text: str) -> dict[str, Any]: + """Сохранить полный текст документа и переиндексировать его по нему. + + Общая часть для всех путей получения полного текста: скачанный PDF + (enrich_full_text) и текст, пришедший прямо из API источника (бэкфилл PMC — + scripts/ops/). Провизорные fingerprints, посчитанные по аннотации, + заменяются на посчитанные по полному тексту — ради этого всё и делается: + L1 начинает видеть тело статьи, а не только её краткое описание. + + Args: + doc_id: документ в PostgreSQL + text: полный текст статьи + + Returns: + dict со статусом, объёмом текста и числом отпечатков + """ + from sqlalchemy import delete + + from app.models import Document, Fingerprint + + minio = get_minio() + key = f"corpus/{doc_id}.txt" + data = text.encode("utf-8") + minio.put_object( + settings.MINIO_BUCKET_DOCS, key, io.BytesIO(data), length=len(data), + content_type="text/plain; charset=utf-8", + ) + + hashes = list(winnow(text))[: settings.MAX_FINGERPRINTS_PER_DOC] + + with db_session() as session: + doc = session.get(Document, doc_id) + if doc is None: + return {"status": "doc_gone", "doc_id": doc_id} + doc.minio_key = key + session.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id)) + for i, hash_val in enumerate(hashes): + session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i)) + session.commit() + + # MinHash LSH (L2) тоже должен считаться по полному тексту + add_to_lsh(f"doc:{doc_id}", text) + + logger.info(f"store_full_text: doc={doc_id} {len(text)} симв., fingerprints={len(hashes)}") + return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)} + + @celery_app.task( name="index.enrich_full_text", bind=True, @@ -366,51 +413,17 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]: Недоступный/не-PDF источник — не ошибка: возвращаем no_fulltext. """ from app.fulltext import fetch_full_text - from app.models import Document, Fingerprint text = fetch_full_text(url) if not text: return {"status": "no_fulltext", "doc_id": doc_id} - # Сохранить полный текст в MinIO try: - minio = get_minio() - key = f"corpus/{doc_id}.txt" - data = text.encode("utf-8") - minio.put_object( - settings.MINIO_BUCKET_DOCS, key, io.BytesIO(data), length=len(data), - content_type="text/plain; charset=utf-8", - ) + return store_full_text(doc_id, text) except Exception as exc: - logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}") + logger.error(f"enrich_full_text: не удалось сохранить текст для {doc_id}: {exc}") raise self.retry(exc=exc, countdown=120) from exc - # Пересчитать fingerprints по полному тексту - doc_fp = winnow(text) - hashes = list(doc_fp)[: settings.MAX_FINGERPRINTS_PER_DOC] - - from sqlalchemy import delete - - with db_session() as session: - doc = session.get(Document, doc_id) - if doc is None: - return {"status": "doc_gone", "doc_id": doc_id} - doc.minio_key = key - # Удалить провизорные fingerprints и записать новые - session.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id)) - for i, hash_val in enumerate(hashes): - session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i)) - session.commit() - - # Обновить MinHash LSH по полному тексту - add_to_lsh(f"doc:{doc_id}", text) - - logger.info( - f"enrich_full_text: doc={doc_id} полный текст {len(text)} симв., " - f"fingerprints={len(hashes)}" - ) - return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)} - @celery_app.task( name="index.auto_approve_submission",