Files
anti-plagiarism/scripts/ops/backfill_pmc_fulltext.py
jze9 d7c004af76 feat(ops): углубление индексации КиберЛенинки + общий store_full_text
Проверка глубины вскрыла главную слабость корпуса: 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 <noreply@anthropic.com>
2026-08-28 17:45:48 +05:00

125 lines
5.5 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()