Compare commits

...

55 Commits

Author SHA1 Message Date
jze9
d137074ed5 fix(core): ходить в CORE через sing-box — с российских адресов ключ не работает
All checks were successful
Deploy / test (push) Successful in 3m15s
Deploy / deploy (push) Successful in 3m43s
Заливка встала после двух порций: прогоны стали заканчиваться за две минуты
с нулём документов. Выглядело как поломка сети, и на ложные следы ушло время —
MTU в норме (1472 байта проходят), IPv6 ни при чём, Cloudflare из того же
контейнера качается на 2.2 МБ/с, ключ и квота целы (с домашней машины тот же
запрос отвечает за 6с, лимит нетронут).

Разница оказалась в адресе. С прода:
  напрямую      — код 000, обрыв на 25с (соединение есть, тело не приходит)
  через sing-box — код 200 за 1.7с, 207 КБ
Анонимные короткие запросы проходят и напрямую — поэтому блокировка и
маскировалась под сетевой сбой.

CORE ведёт себя как OpenRouter, ради которого singbox-proxy и заводили,
поэтому решение то же: httpx получает proxy из CORE_PROXY_URL (умолчание —
socks5://singbox-proxy:1080, пусто = напрямую для локальных прогонов).
В образ индексатора добавлен socksio: без него httpx не умеет SOCKS5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:48:43 +05:00
jze9
9123844a87 fix(ops): не хоронить источник после одного пустого прогона
All checks were successful
Deploy / test (push) Successful in 11m54s
Deploy / deploy (push) Successful in 8s
Сторож считал источник исчерпанным, если прошлый прогон не добавил и не
выбрал ни одной статьи. Но ровно так же выглядит обрыв связи с API и прогон
на устаревшем коде парсера: сегодня CORE после единственной неудачи был
помечен исчерпанным и больше не запускался — молча, без ошибки в интерфейсе.

Теперь нужно два пустых прогона подряд. Разовый сбой переживём, а реально
кончившийся источник остановится всего на один прогон позже.

Заодно таймаут запроса к CORE снижен с 60 до 30 секунд: три попытки по
минуте отъедали 180 секунд из 1500 бюджета на одной залипшей странице —
та же грабля, что чинили в pmc_bulk. Обычный ответ приходит за 7 секунд.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:34:14 +05:00
jze9
263a521d63 fix(ops): перезапускать worker-indexer, когда менялись парсеры
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / test (push) Has been cancelled
Парсеры не вшиты в образ, а смонтированы в worker-indexer, поэтому деплой
их не пересобирал — и не перезапускал контейнер, раз `services/` не менялся.
Но воркер держит модуль парсера в памяти с прошлого прогона: новый файл
лежит в контейнере, а работает старый код.

Поймано вживую на CORE: заливка после деплоя ушла ноль документов за 208
секунд, и только тело запросов в логах показало, что она всё ещё ходит по
старой схеме — с огромным OR-запросом, который на глубоком смещении трижды
упал по таймауту. Со стороны выглядело как «источник исчерпан», и сторож
чуть не выключил источник насовсем.

Теперь при изменениях в `scripts/parsers/` деплой перезапускает
worker-indexer — если образ и так не пересобирается на этом прогоне.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:32:11 +05:00
jze9
7adccd0362 fix(core): резать выборку по архивам, а не по годам — AND в API не работает
All checks were successful
Deploy / test (push) Successful in 7m41s
Deploy / deploy (push) Successful in 4s
Первый прогон на проде выдал документы 2008-2019 годов, хотя запрос просил
`yearPublished:2026`. Проверка показала: условия в запросе CORE через `AND`
не связываются. По отдельности каждое фильтрует честно (`yearPublished:2026`
— ровно 2026, `repositories.id:1298` — ровно этот архив), а вместе
`(архивы) AND yearPublished:2026` отдаёт 2.3 млн работ вперемешку, то есть
условия объединяются по «или», и год работает лишь подсказкой ранжированию.

Значит нарезка по годам не нарезала ничего: каждый «год» перебирал один и тот
же набор, а в выдачу подмешивались посторонние работы нужного года — включая
англоязычные, ради ухода от которых источник и заводился.

Теперь в запросе ровно одно условие — номер архива, и каждый архив
опрашивается отдельно. Позиция продолжения стала «архив:смещение». Это ещё и
честнее по потолку: `offset` упирается в 100 000, а самый крупный из наших
архивов содержит 63 749 работ, то есть влезает целиком.

Годы, если заданы в источнике, отсекаются теперь на нашей стороне. Список
архивов парсер принимает и простым списком номеров, и прежним выражением
`(repositories.id:N OR ...)` — настройку источника менять не нужно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:15:38 +05:00
jze9
7348051c7b chore(ci): перезапустить деплой после чистки сервера Gitea
All checks were successful
Deploy / test (push) Successful in 8m7s
Deploy / deploy (push) Successful in 24s
Прогон №50 упал не на коде: сервер Gitea подмешивал в поток git-протокола
вывод постороннего скрипта, и клонирование рвалось с `fatal: early EOF`.
Причина устранена на сервере, клонирование проверено — пустой коммит нужен
только чтобы дать раннеру новое задание.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:56:43 +05:00
jze9
3d4fcecbb2 feat(core): подключить CORE как источник полных текстов
Some checks failed
Deploy / test (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Оба массовых источника исчерпаны — wikipedia_ru и pmc_bulk отдают ноль,
сторож честно пишет «источник исчерпан, пропускаем», и корпус стоит.
Брать новые статьи неоткуда, а главная дыра прежняя: полный текст есть у
11% корпуса, по-русски — почти нигде (CyberLeninka отдаёт только HTML
страницы, тела статьи там нет вовсе).

CORE кладёт готовый текст прямо в выдачу поиска: его не надо ни качать
отдельно, ни извлекать из PDF. Парсер массовый, как Википедия и PMC —
отдаёт статьи генератором, пишется пачками через COPY, помнит позицию.

Что выяснено живьём и учтено в коде:

- у /v3/search/works обязателен слэш на конце, иначе 301, а на редиректе
  теряется заголовок Authorization и запрос уходит анонимным;
- offset упирается в 100 000 (под капотом Azure Search), поэтому выборка
  режется по годам, а позиция продолжения — строка «год:смещение»;
- fullText не фильтруемое поле, _exists_:fullText отвечает 500 — статьи с
  текстом приходится отбирать на своей стороне;
- CORE отдаёт одну статью под разными id из разных репозиториев, а корпус
  дедуплицируется только по ext_id: такие пары легли бы отдельными
  документами и ловились бы как заимствование друг у друга. Отсеиваем по
  хешу начала текста в пределах прогона.

Позиция указывает на страницу, из которой пришла статья, а не на
следующую: пачка может прерваться на середине страницы, и тогда прогон
перечитает её целиком. Лишний запрос дешевле потерянных статей, дубли
отсекает ON CONFLICT.

Замер на живом API: страница в 100 записей приходит за ~7с. В общем
потоке CORE кириллицы нет вовсе (0 из 48 полных текстов за 2019), язык
размечен негодно — сплошь None и «zz». Зато работает отбор по архиву:
запрос по 41 вузовскому репозиторию России и Беларуси даёт 79 полных
текстов из 100 записей, 78 из них кириллические. Список архивов живёт в
поле query источника, а не в коде — правится из админки без пересборки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:05:34 +05:00
jze9
20c1d00443 fix(pmc): не давать залипшему запросу съесть весь бюджет прогона
Таймаут запроса к бакету был 60с при бюджете таска 1500с — один
залипший GET отъедал почти весь прогон. Плюс map() отдаёт результаты
строго по порядку отправки, поэтому один медленный запрос блокировал
все 11 уже готовых потоков.

Таймаут снижен до 12с (обычный GET укладывается в доли секунды, 12с —
запас на джиттер), map() заменён на as_completed: готовые результаты
отдаются сразу, не дожидаясь залипшего соседа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 13:39:45 +05:00
jze9
5ceec1e2e3 fix(ops): сторож не снимает живой прогон на долгом COPY
Молчащий heartbeat сторож трактовал как смерть прогона и снимал его по
порогу 10 минут. Но запись большой пачки отпечатков в базу на HDD идёт
COPY'ем 6-15 минут без единого тика heartbeat — и сторож убивал вполне
живой прогон, тут же запуская дубль по тем же статьям. Корпус часами
топтался на месте: из лога видно десятки перезапусков подряд с нулевым
приростом.

Теперь перед снятием сторож спрашивает воркеров queue.index через
inspect().active(), выполняется ли ещё таск прогона. Снимаются только
настоящие зомби — те, чьего celery_task_id нет ни на одном воркере.
Если хоть один воркер не ответил, живость не проверить и не снимается
ничего (fail-safe). Проверено на живом прогоне: его таск попал в набор
активных, прогон помечен защищённым.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 13:39:36 +05:00
jze9
d86bb606c7 fix(wikipedia): позиция — байтовое смещение, а не номер статьи
All checks were successful
Deploy / test (push) Successful in 3m8s
Deploy / deploy (push) Successful in 30s
Заливка Википедии останавливалась сама собой: позиция хранилась как номер
статьи, и каждый прогон перечитывал дамп с начала. На 20 тысячах это стоило
6 минут из 25 доступных, на 27 тысячах — уже около десяти, а на сотне тысяч
съело бы весь бюджет и заливка встала бы совсем. Ровно это и наблюдалось:
прогон висел с нулём полученных статей, воркер на 100% CPU.

Переход на multistream-вариант дампа: он состоит из независимых bz2-блоков по
~95 статей, к нему прилагается индекс со смещениями. Позиция продолжения стала
байтовым смещением, прогон стартует мгновенно с нужного места.

Проверено на реальных файлах, а не по предположению: формат индекса
(offset:page_id:title), 21 уникальное смещение на 2001 статью, прыжок seek на
смещение из середины файла даёт валидный XML со страницами.

wikipedia_resume_offset.py — разовый пересчёт позиции при переходе: находит по
индексу блок с максимальным залитым page_id (получилось 273 281 821).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:26:16 +05:00
jze9
d3106efeee feat(ops): заливка идёт сама — автоперезапуск порций по cron
All checks were successful
Deploy / test (push) Successful in 3m8s
Deploy / deploy (push) Successful in 4s
Массовый источник за прогон берёт порцию, сохраняет позицию и останавливается
по бюджету времени. Без внешнего толчка заливка шла рывками — ровно столько,
сколько раз кто-то нажмёт «Запустить», и всё это время корпус стоял на месте.

keep_ingesting.py раз в 5 минут проверяет, не простаивают ли массовые
источники, и запускает следующую порцию. Идущие прогоны не трогает.
Останавливается сам, когда источник исчерпан (прогон закрылся с нулём
полученных статей), чтобы не крутить пустые запуски вечно.

Поставлен в cron на app-хосте рядом с монитором.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:56:48 +05:00
jze9
eb2dee9093 perf(ops): начинать листинг PMC после последней залитой статьи
All checks were successful
Deploy / test (push) Successful in 3m4s
Deploy / deploy (push) Successful in 29s
Первый прогон нового конвейера показал слабое место: листинг бакета шёл с
начала, и заливка часами перемалывала уже существующие статьи как дубли —
из 300 полученных 300 оказались дублями.

S3 умеет start-after, и стартовый ключ выводится прямо из базы: ext_id вида
`pmc:PMC10000000` соответствует ключу `PMC10000000.1`, бакет отдаётся
лексикографически. Теперь при отсутствии сохранённого токена (первый прогон
после ручных заливок) листинг начинается после максимального залитого.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:53:07 +05:00
jze9
a76e46c561 feat(ops): замер качества детекции — первые честные цифры
All checks were successful
Deploy / deploy (push) Successful in 4s
Deploy / test (push) Successful in 2m53s
О качестве проверки мы до сих пор знали только «механизм жив»: находит
подброшенный фрагмент. Продукт при этом продаёт процент заимствований, за
который никто не ручался — неизвестно было ни сколько списываний система
пропускает, ни как часто обвиняет невиновных.

Бенчмарк делает из документов корпуса «студенческие работы» четырёх видов и
гоняет их через настоящий путь L1. Первый замер на проде:

  дословно        100% найдено
  лёгкий рерайт   100% найдено
  сильный рерайт    0% — это работа L3/L4, не L1
  оригинал          0% ложных обвинений

То есть основа работает как задумано. Показательно другое: из 20 взятых
документов в замер попали 8 — у остальных нет полного текста нужной длины.
Узкое место не алгоритм, а глубина корпуса.

Запускать после изменения порогов и параметров winnowing — иначе непонятно,
улучшение сделано или ухудшение.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:45:59 +05:00
jze9
fed4b54cfc fix(ops): мониторинг под реальную топологию + проверка воркеров
Some checks failed
Deploy / test (push) Successful in 2m52s
Deploy / deploy (push) Has been cancelled
Монитор лежал только на сервере, не версионировался и месяц проверял
конфигурацию, которой уже нет: Ollama на CT 108 (.20.163), остановленном
27.08 при переходе на OpenRouter. Итог — вечный ложный DOWN, на фоне которого
теряются настоящие аварии, и полная слепота к реальному серверу эмбеддингов
(.1.40) и к воркерам.

- скрипт переехал в репозиторий и теперь деплоится вместе с кодом;
- адреса читаются из прод-.env, а не зашиты: сервер эмбеддингов переезжал
  дважды, и каждый раз монитор оставался со старым адресом;
- добавлена проверка воркеров Celery — 05.09 брокер лежал час, воркеры молчали,
  а монитор рапортовал, что всё хорошо, потому что смотрел только TCP-порт;
- разбор URL чинит падение на кредах в REDIS_URL/RABBITMQ_URL.

Проверено на проде: все восемь проверок отдают OK, включая Embeddings и
CeleryWorkers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:43:01 +05:00
jze9
26de1b9de1 refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s
Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей
инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой
видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на
20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс
потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый
механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery.

Теперь это обычные типы источника — wikipedia_ru и pmc_bulk:

- заводятся и запускаются из админки, как OpenAlex или КиберЛенинка;
- показывают ту же шкалу, журнал и кнопку остановки;
- падение воркера больше не теряет прогресс: позиция продолжения хранится в
  parse_sources.resume_token (номер статьи в дампе / токен страницы бакета),
  повторный запуск берёт следующую порцию;
- укладываются в бюджет времени таска — заливка идёт порциями, а не одним
  многосуточным процессом.

Чего не хватало конвейеру для миллионов и что добавлено:
- парсеры отдают генератор, а не список: 2 млн статей в память не влезают;
- app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против
  6.7 тыс. построчно) с переподключением к базе при обрыве;
- эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и
  стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py).

scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер.

Проверено на проде: оба парсера отдают документы, прогон через run_parser
завершается штатно, позиция продолжения сдвигается (300 → 600), повторный
запуск продолжает с неё.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:32:53 +05:00
jze9
1b1ca3b7e3 fix(admin): починить панель отладки — она отдавала 500
All checks were successful
Deploy / test (push) Successful in 2m59s
Deploy / deploy (push) Successful in 2m15s
Моя же правка про зависшие проверки уронила /admin/debug: колонки tasks в
PostgreSQL — timestamptz, а модель объявляет их без зоны, и asyncpg отверг
переданное из Python время («can't subtract offset-naive and offset-aware»).

Возраст задачи теперь считает сама база (now() - coalesce(updated_at,
created_at)), так что расхождение объявления и реального типа колонки роли не
играет. Проверено на живых данных: находит все 4 задачи, висящие с 30 мая.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:17:28 +05:00
jze9
39951d6a0f feat(admin): показывать зависшие проверки в панели отладки
All checks were successful
Deploy / test (push) Successful in 3m44s
Deploy / deploy (push) Successful in 8m39s
Нашлись 4 задачи в статусе processing, висящие с 30 мая: пользователь видит
вечное «обрабатывается», а в системе никаких следов. Теперь задачи без
движения дольше 2 часов попадают в /admin/debug рядом с зависшими прогонами
заливки, с указанием, сколько часов они стоят.

tasks.created_at/updated_at — timestamptz, поэтому сравнение идёт с
осведомлённым о зоне временем: naive utcnow() дал бы TypeError в рантайме.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:13:01 +05:00
jze9
f4092e5331 fix(ops): заливки переживают обрывы базы и сети
За неделю обе массовые заливки умерли молча и не возобновились: Википедия на
20 008 статьях из ~2 млн («server closed the connection»), PMC примерно на
85 тыс. из 100 тыс. (таймаут SSL-рукопожатия). Ни ретраев, ни возобновления.

- запись пачки повторяется с переподключением к базе (5 попыток с паузой);
- обрыв листинга бакета стоит паузы, а не всей заливки;
- у Википедии сохраняется позиция в дампе — после сбоя продолжаем с неё,
  а не проматываем с нуля, полагаясь на дедуп по ext_id;
- батч уменьшен (500 статей × 2000 отпечатков = миллион строк в одной
  транзакции — вероятная причина обрыва);
- обрезка отпечатков переведена на равномерную выборку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:13:01 +05:00
jze9
476682741e fix(L1): обрезать отпечатки равномерно по тексту, а не произвольно
winnow() возвращает set, поэтому list(fp)[:LIMIT] брал случайное подмножество:
у длинного документа целые куски оставались без отпечатков, и списывание
именно из них не находилось. Обнаружено при разборе того, почему фрагмент
статьи PMC не искался.

Добавлены winnow_ordered() — отпечатки в порядке появления в тексте, и
sample_evenly() — выборка каждого n-го элемента вместо первых N. Применено в
add_document и store_full_text.

7 тестов на главное свойство: выборка растянута по всей длине документа, шаг
ровный, порядок сохранён.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:13:01 +05:00
jze9
9145de8e54 docs(worker-gpu): объяснить, почему параллельность воркера жёстко равна 1
All checks were successful
Deploy / test (push) Successful in 3m9s
Deploy / deploy (push) Successful in 1m27s
В Dockerfile стояло -c 1 без пояснения, и это выглядит как недосмотр: панель
отладки показывает «параллельно: 1» рядом с воркерами на 4, 8 и 16 процессов.

На самом деле поднимать нельзя: FAISS-индекс — синглтон в памяти процесса, при
-c N каждый форк получит свою копию, и save() каждого затрёт чужие векторы.
Потери были бы молчаливыми — как с 60 993 ложными отметками faiss_id.

Смысла в параллельности тоже нет: Ollama занимает все ядра сервера обработкой
одного запроса (2.29 req/s при конкурентности 1 против 2.48 при 16). Поднимать
-c уместно только вместе с VECTOR_BACKEND=qdrant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:11:19 +05:00
jze9
780a0a1061 fix(ops): читать дамп Википедии с диска — сетевой поток рвётся на 5.6 ГБ
All checks were successful
Deploy / test (push) Successful in 3m49s
Deploy / deploy (push) Successful in 4s
Wikimedia закрывает долгие потоковые соединения: обрыв пришёлся на 32 МБ из
5.9 ГБ. Скачать файл с докачкой (curl -C -) и читать локально надёжнее, поэтому
у скрипта появился --dump-file; чтение из сети осталось запасным путём.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:54:59 +05:00
jze9
06363c7401 feat(ops): заливка русской Википедии — единственный доступный источник объёма
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / test (push) Has been cancelled
У корпуса катастрофически мало пригодного русского текста: 99 883 документа
КиберЛенинки лежат со средней глубиной 30 отпечатков, то есть заголовок с
аннотацией. Проверил все русскоязычные источники, о которых имело смысл думать:

- Википедия ru — работает: дамп 5.6 ГБ читается потоком (на app-хосте всего
  22 ГБ свободно, поэтому без сохранения на диск), ~919 отпечатков на статью.
  Wikimedia отдаёт 403 без осмысленного User-Agent — учтено;
- КиберЛенинка — блокирует выкачку после ~130 запросов (замер 28.08);
- OpenAlex language:ru + OA — заявлено 395 410 работ, но по прямым pdf_url
  скачалось 2 из 10, остальное 403 издателей; метка языка ненадёжна — в выдаче
  англоязычные журналы. Массово не годится;
- eLIBRARY.RU — только по договору, Math-Net.Ru — 403 на архиве.

Википедия формально не научный источник, но студенты копируют из неё чаще, чем
из статей, а по объёму связного русского текста альтернатив среди доступного нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:53:10 +05:00
jze9
e4ca4a7150 docs: сервер эмбеддингов переехал на .1.40 + честные итоги проверок
All checks were successful
Deploy / test (push) Successful in 2m59s
Deploy / deploy (push) Successful in 3s
Прод переключён на новый сервер эмбеддингов (VM 210 «embedding-cpu»,
192.168.1.40, хост pve2). Ключевое, чего не было в исходном плане переключения:
OLLAMA_URL живёт в Infisical, и deploy.sh генерирует .env из него на каждом
запуске — правка .env на сервере откатилась бы первым же деплоем.

Совместимость векторов проверена прямым сравнением, а не на слово: косинус
0.9999997, расхождение 1e-4 (округление AVX2 против AVX-512) — переиндексация
93 тыс. векторов не понадобилась.

Документация приведена в соответствие с фактами:
- ARCHITECTURE/DIAGRAM/README/CREDENTIALS: новый сервер, старый помечен как
  выведенный; убрано упоминание CUDA — GPU в проекте нет;
- DR-HA: эмбеддинги больше не висят на хосте .254, чьё падение 28.08 разом
  унесло брокер, прокси и секреты;
- INGESTION: исправлено собственное враньё про КиберЛенинку — «48 часов на
  100 тысяч» опровергнуто практикой, сайт блокирует выкачку после ~130 статей;
  снапшот OpenAlex вычеркнут как путь к миллионам (там только метаданные).

bulk_ingest_pmc.py: снята пометка «не закончен» — бага не было, первый прогон
упал уже после успешной вставки, а второй корректно пропустил дубли. Проверено:
100 статей, 183 090 отпечатков. Реальный темп 0.3 ст/с записан честно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:06:41 +05:00
jze9
2d38dfd1f4 feat(ops): путь к миллионам статей — массовая заливка из бакета PMC (не закончен)
All checks were successful
Deploy / test (push) Successful in 3m12s
Deploy / deploy (push) Successful in 1m56s
Разведка под задачу «нужны миллионы»: постраничные API дают 1-2 статьи в
секунду и упираются в rate limit, а OpenAlex-дамп содержит только метаданные —
миллионы аннотаций бесполезны, что уже доказано на КиберЛенинке (30 отпечатков
на документ, 0 глубоко проиндексированных).

Найден и проверен рабочий источник: AWS Open Data бакет pmc-oa-opendata открыт
без ключа, у каждой статьи лежит готовый извлечённый текст (33-130 КБ) и json с
метаданными. Замер: 12 статей/с в 10 потоков — миллион за сутки.

Замеры узких мест на проде (для планирования масштаба):
- winnowing 95 док/с на ядро — не ограничение;
- поиск L1 31 мс по 500 отпечаткам при 113 млн строк;
- вставка отпечатков построчно 6.7 тыс. строк/с → миллион статей это 2.5 млрд
  строк и четверо суток только на запись, поэтому в скрипте COPY;
- объём: 106 байт на строку → ~400 ГБ на миллион статей с индексами.

Скрипт НЕ закончен: документы вставляются верно, но шаг отпечатков ошибочно
пропускает свежие документы (пробные 200 записей удалены из базы). Ограничение
задокументировано прямо в скрипте, чтобы его не запустили на объёме.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:58:17 +05:00
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
jze9
d8630ca0f9 fix(admin): покрытие L3 мерить по индексу, а не по отметкам в базе
Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу:
показывала 154 046 «документов с эмбеддингом» (87%), тогда как в FAISS реально
93 053 вектора (52.5%). Колонка documents.faiss_id для этого непригодна —
отметка остаётся после пересоздания индекса (смена модели: 768 → 1024) и после
сбоев worker-gpu. Выборочная проверка: у 8 из 20 «отмеченных» вектора нет.

- gpu.index_stats — новая задача, отдаёт реальное содержимое активного
  векторного бэкенда; API спрашивает её для панели отладки;
- панель показывает «векторов в индексе» и отдельно предупреждает о ложных
  отметках (их 60 993), потому что такие документы молча выпадают из L3:
  в индексе их нет, а на пересчёт они не попадут — reembed ищет faiss_id IS NULL;
- scripts/ops/faiss_reconcile.py — сверяет отметки с индексом и обнуляет ложные,
  после чего reembed_missing.py отправляет их на пересчёт. Проверен вживую
  (dry-run на проде: 93 053 в индексе, 60 993 ложных отметок).

Документация: зафиксирована реальная глубина корпуса — полный текст только у
27.5% документов, у 79% меньше 500 отпечатков (уровень аннотации). Система
ловит списывание из того, что есть целиком; это граница, а не поломка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:17:41 +05:00
jze9
ea62f10829 feat(admin): состояние инфраструктуры на странице отладки
Сегодняшняя авария (упал гипервизор — вместе с ним брокер, Ollama, прокси)
проявлялась в отладке косвенно: пустой список воркеров и ошибка очередей.
Прямого ответа «что именно недоступно» страница не давала, хотя эндпоинт
/admin/health его уже знает.

Блок статусов рисуется и тогда, когда сам срез не собрался — именно в аварии
он нужен больше всего, а собирается в этот момент дольше обычного.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:00:19 +05:00
jze9
4008b5019f test(admin): закрепить смысл таймингов прогона + время работы в отладке
Тесты на queued_s/duration_s фиксируют ровно ту путаницу, из-за которой
метрика и разъехалась: ожидание в очереди и время работы — разные величины,
а у прогонов до миграции 006 длительности просто нет (вместо неё раньше
показывалось время в очереди).

Панель отладки теперь показывает, сколько идущий прогон уже работает —
по этому и виден застрявший, а не только по отсутствию heartbeat.

README: фактические числа тестов (150, проверено прогоном run_tests.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:58:42 +05:00
jze9
99bf14fe6a feat(admin): честная длительность прогона + добор эмбеддингов
Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона»
в 2.8 часа там, где парсинг занимал 50 секунд: started_at пишется в момент
постановки в очередь, а очередь из 173 источников разбирается часами. Теперь
момент реального старта пишется отдельно (run_started_at, миграция 006), а
схема отдаёт обе величины — сколько ждал очереди и сколько работал.

Плюс scripts/ops/reembed_missing.py: документ попадает в корпус сразу, а вектор
для L3 считает отдельная задача; когда worker-gpu или Ollama недоступны, эти
задачи теряются и документ остаётся невидимым для семантического поиска. Скрипт
находит faiss_id IS NULL и переотправляет пачками (dry-run по умолчанию) —
сейчас таких 23 101 из 177 147.

Документация: актуальные цифры корпуса, дубли при повторном прогоне, лимит
OpenAlex, и главное — гипервизор .254 зафиксирован в DR-HA как самая широкая
единая точка отказа (брокер, эмбеддинги, секреты и прокси на одном железе;
подтверждено аварией 28.08).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:51:25 +05:00
jze9
f1a8d07cb3 fix(openalex): пауза между страницами и живой backoff — заливка тонула в 429
All checks were successful
Deploy / test (push) Successful in 4m3s
Deploy / deploy (push) Successful in 5s
Массовый запуск показал: лимит вежливого пула OpenAlex (10 req/s) общий на
mailto, а не на процесс. Четыре воркера с паузой 0.1с получали сплошные 429,
и каждый прогон уходил в 900с бесполезного backoff, не забрав ничего.

- RATE_LIMIT_DELAY 0.1 → 1.0с (≈4 req/s на четырёх воркерах);
- backoff спит кусками по 5с и отчитывается через progress_cb: прогон больше
  не выглядит зависшим в админке и отменяется во время ожидания, а не после.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:54:42 +05:00
jze9
78e27806b9 feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
All checks were successful
Deploy / test (push) Successful in 3m56s
Deploy / deploy (push) Successful in 31s
Заливка корпуса была чёрным ящиком: у источника только last_status
(idle/running/done/error), без «сколько из скольки», без причины падения и без
способа остановить начатое. Теперь каждый запуск создаёт строку parse_runs,
куда воркер раз в ~2с пишет стадию, счётчики и журнал событий.

Админка:
- шкала загрузки у каждого источника (0→50% выборка, 50→100% индексация),
  раскрытая строка — журнал прогона по шагам с таймингами;
- «Запустить всё» / «Остановить всё» и остановка по одному источнику
  (кооперативная отмена: воркер останавливается сам, не рвя запись в базу);
- пакетное добавление источников (тип + список тем), тип pmc в форме;
- загрузка PDF/DOCX/TXT прямо в базу сравнения (index.ingest_upload);
- страница «Отладка»: воркеры Celery и их текущие таски, очереди RabbitMQ,
  покрытие корпуса эмбеддингами, зависшие и упавшие прогоны, конфиг бэкендов.

Защита от краш-лупа по consumer_timeout RabbitMQ (docs/DR-HA.md §6), без неё
массовый запуск 170+ источников гарантированно ронял воркер:
- PARSER_TIME_BUDGET_S (1500с) — прогон закругляется сам и помечается partial;
- worker_prefetch_multiplier=1 — таймаут считается от ДОСТАВКИ сообщения, и с
  дефолтным префетчем очередь долгих run_parser убивала канал на задачах,
  которые ещё не начинались.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:40:23 +05:00
jze9
95d2903766 docs(DR-HA): зафиксировать риск RabbitMQ consumer_timeout + Infisical failover
All checks were successful
Deploy / test (push) Successful in 4m5s
Deploy / deploy (push) Successful in 18m13s
Обнаруженный на практике 26.08 краш-луп (долгий retry в Celery-таске дольше
1800с consumer_timeout → брокер рвёт канал → падение → бесконечный повтор)
стоит знать при добавлении новых долгих retry-циклов. Заодно поправлена
инструкция failover PostgreSQL — POSTGRES_HOST теперь меняется в Infisical,
не в .env на сервере (перезапишется следующим деплоем).
2026-08-27 16:54:26 +05:00
jze9
fc40793f4d docs: актуализировать документацию (Infisical, OpenRouter, bge-m3, PMC) + чистка
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / test (push) Has been cancelled
Документация сильно разошлась с кодом за последние недели работы — привёл в
соответствие ARCHITECTURE.md, README, DIAGRAM.md, INGESTION.md:
- LLM (L4) и эмбеддинги (L3) теперь переключаемые бэкенды (LLM_BACKEND,
  EMBED_BACKEND), а не жёстко Ollama/qwen2.5:7b — старая модель эмбеддингов
  (768d mpnet) заменена на bge-m3 (1024d); L4 может идти через OpenRouter
  (DeepSeek) с прокси singbox для обхода геоблокировки.
- .env на проде больше не редактируется руками — на каждом деплое
  генерируется из Infisical; старая инструкция "cp .env.example .env; nano
  .env" вводила в заблуждение (правки терялись бы на следующем деплое).
- README "Три docker-compose файла"/deploy разделы противоречили реальности:
  фронтенд+TLS реально раздаёт хостовой nginx на CT 102, не докеризованный
  nginx-сервис из compose (тот не запускается вообще).
  добавлен PMC-парсер, self-match исключение объяснено, число тестов (113)
  синхронизировано между файлами (было 82/122 в разных местах).
- INGESTION.md: снимок "42K доков, 0 русских" от 12.08 заменён актуальным
  (165K доков, ru теперь большинство) — иначе документ откровенно врёт.
- Diagram: узлы под текущую топологию (embedding-gpu, OpenRouter, Infisical).

Заодно, раз перепроверял код на соответствие докам:
- Удалён мёртвый и битый эндпоинт GET /reports/{task_id} — фильтровал по
  внутреннему Task.id вместо public_id (никогда не мог сработать через
  обычный клиентский поток), фронтенд его всё равно не вызывал —
  функциональность полностью дублирует рабочий GET /tasks/{public_id}.
- Убран мёртвый конфиг FAISS_NLIST/FAISS_NPROBE (worker-gpu/config.py) —
  индекс всегда IndexFlatIP, IVF нигде не строится, эти поля ничего не делали.
2026-08-27 16:51:31 +05:00
jze9
6c53213103 fix(compose): опубликовать порт 8000 у api — прод отвечал 502 на все запросы
Some checks failed
Deploy / test (push) Successful in 5m2s
Deploy / deploy (push) Has been cancelled
Реверс-прокси на CT102 (nginx, /etc/nginx/sites-available/academic)
проксирует /health, /api/, /ws/ на 192.168.1.32:8000 напрямую — а у сервиса
api в docker-compose.prod.yml не было ports:, порт нигде не публиковался на
хост. Статика (/ ) отдавалась нормально (CT102 сам её раздаёт с диска), но
любой запрос к бэкенду падал с 502. Применено вручную на проде и подтверждено
живьём перед коммитом — прод не будет ждать полного деплоя, чтобы починиться.
2026-08-27 16:38:27 +05:00
jze9
c152fafa70 feat(deploy): переключить .env на проде на Infisical вместо ручного файла
All checks were successful
Deploy / test (push) Successful in 3m23s
Deploy / deploy (push) Successful in 13s
deploy.sh теперь на каждом запуске логинится в Infisical (Machine Identity
Universal Auth) и генерирует .env из окружения prod перед синком кода и
поднятием контейнеров. Если Infisical недоступен или вернул подозрительно
мало ключей — деплой падает раньше, не трогая рабочий .env на сервере.

Добавлен шаг compose up -d без --build для всех бэкенд-сервисов после
сборки изменившихся — иначе правка секрета без изменения кода не попадала
бы в уже запущенные контейнеры (docker compose пересоздаёт только то, чей
эффективный конфиг реально изменился, остальное не трогает).
2026-08-27 16:25:28 +05:00
jze9
1dd3f1569f fix(indexer): не превышать consumer_timeout RabbitMQ в retry-бэкоффе OpenAlex
All checks were successful
Deploy / test (push) Successful in 3m12s
Deploy / deploy (push) Successful in 3s
5 попыток (1860с суммарно) были чуть больше дефолтного consumer_timeout
RabbitMQ (1800с) — брокер рвал канал раньше, чем таск успевал сдаться и
подтвердиться, воркер падал, docker его перезапускал, сообщение
редоставлялось и весь цикл забега начинался заново с попытки 1 —
бесконечный краш-луп, блокирующий весь пул воркера (concurrency=4).
2026-08-26 20:08:16 +05:00
jze9
76e221f856 fix(embeddings): не терять батч на таймауте Ollama, уменьшить пачку
All checks were successful
Deploy / test (push) Successful in 3m32s
Deploy / deploy (push) Successful in 23s
Ollama обрабатывает тексты в пачке последовательно (один "slot" в
llama.cpp, не параллельно) — 64 реальных документа в одном HTTP-запросе
регулярно не укладывались в 120с, весь батч падал с ReadTimeout и
терялся без ретрая (embed_documents была без bind/max_retries).
Теперь: пачка 16, таймаут 300с, задача ретраится до 3 раз при сбое.
2026-08-26 18:37:49 +05:00
jze9
e05e658d81 feat(proxy): sing-box для обхода блокировки OpenRouter из РФ
All checks were successful
Deploy / test (push) Successful in 3m48s
Deploy / deploy (push) Successful in 17m25s
OpenRouter отдаёт 403 с российских IP (подтверждено вживую). Добавлен
sing-box как отдельный сервис (VLESS+WS+TLS до ноды вне РФ) — только для
исходящих запросов к OpenRouter из ollama_client.py, остальной трафик
(Postgres/RabbitMQ/MinIO/Ollama) идёт напрямую. Реальный конфиг с UUID —
только на проде (см. config.json.example), в git не попадает.
2026-08-26 17:37:26 +05:00
jze9
c156fb2b78 feat(embeddings): добавить облачный бэкенд (DeepInfra, та же bge-m3)
All checks were successful
Deploy / test (push) Successful in 4m29s
Deploy / deploy (push) Successful in 59s
EMBED_BACKEND=cloud — эмбеддинги через DeepInfra (OpenAI-совместимый API),
модель та же BAAI/bge-m3, что уже льётся на VM109 — переключение не рвёт
совместимость с уже пересчитанным FAISS-индексом (тот же вектор 1024).
Не активно по умолчанию (EMBED_BACKEND всё ещё "ollama").
2026-08-26 17:18:11 +05:00
jze9
96a4530a93 feat(llm): переключаемый бэкенд для L4 — Ollama или OpenRouter
All checks were successful
Deploy / test (push) Successful in 3m1s
Deploy / deploy (push) Successful in 19s
LLM_BACKEND=ollama (дефолт, поведение не меняется) | openrouter (облачный
API, дешёвая модель — для да/нет+уверенность "ум" не критичен, зато
не нужен локальный GPU-хост вообще). Общая _complete() прячет разницу
форматов запроса/ответа за check_paraphrase/summarize.
2026-08-26 17:11:46 +05:00
jze9
344f78b795 fix(openalex): ограничить retry на 429 и добавить экспоненциальный backoff
All checks were successful
Deploy / test (push) Successful in 2m58s
Deploy / deploy (push) Successful in 2s
Было: на 429 плоское ожидание 60с и retry без счётчика — при нескольких
параллельных источниках (4 воркера) каждый продлевал общий rate limit
сам, получался самоподдерживающийся затык без единого успешного запроса
по 20+ минут. Теперь: до 5 попыток с экспоненциальным backoff
(60/120/240/480/960с), после — источник сдаётся с тем, что успел
собрать, вместо вечного retry.
2026-08-26 16:32:02 +05:00
jze9
237e1767a7 feat(embeddings): переключить эмбеддинги на Ollama/bge-m3 (GPU через Vulkan)
All checks were successful
Deploy / test (push) Successful in 4m43s
Deploy / deploy (push) Successful in 1m53s
Раньше эмбеддинг-модель (уровень 3) гоняла на CPU внутри worker-gpu —
GPU CT108 использовался только под LLM-парафраз (уровень 4). Теперь
эмбеддинги идут через Ollama /api/embed на отдельной VM с RX 580
(Vulkan-бэкенд, без возни с ROCm/HIP для этой карты). EMBED_BACKEND
переключаемый ("ollama" | "sentence_transformers"), дефолт — ollama.

Модель сменилась на bge-m3 (1024-мерный вектор вместо 768 у
paraphrase-multilingual-mpnet-base-v2) — несовместимо с уже посчитанным
FAISS-индексом, нужна полная переиндексация корпуса после деплоя.

Заодно докстринг delete_task в tasks.py — снятое раньше ограничение
"нельзя удалить processing" оставляло враньё в докстринге.
2026-08-26 15:08:30 +05:00
jze9
74cbf1b8f4 fix(plagiarism): не матчить против чужих/своих же загруженных работ
All checks were successful
Deploy / deploy (push) Successful in 14m38s
Deploy / test (push) Successful in 2m47s
Критично: L1/L2/L3 сравнивали фрагменты со ВСЕЙ базой documents, включая
source=user_submission — работы, которые auto_approve_submission сам же
затаскивал в корпус после проверки. Итог: студент, перепроверивший тот
же файл дважды, получал 100% "точное совпадение" по всем фрагментам —
против собственной же более ранней загрузки.

Фикс: все три уровня исключают source=user_submission из кандидатов на
совпадение. AUTO_APPROVE_SUBMISSIONS выключен по умолчанию — раньше рос
корпус для будущего сравнения, но без защиты от self/cross-match это
опаснее, чем полезно.
2026-08-25 17:42:23 +05:00
jze9
43034eda33 fix(indexer): не дублировать полный текст, если он уже есть у парсера
All checks were successful
Deploy / test (push) Successful in 3m6s
Deploy / deploy (push) Successful in 11s
add_document слал index.enrich_full_text для КАЖДОГО документа с url,
даже когда full_text уже пришёл от парсера (у КиберЛенинки — OCR-фрагмент
прямо в ответе поиска). Страница статьи там HTML, не PDF — enrich
гарантированно ничего не находит, только тратит запрос впустую. При
массовой заливке (десятки источников параллельно) это давало шквал
запросов к чужому сайту и 503 в ответ — саму заливку не ломало, но
съедало время и нагружало источник без всякой пользы.
2026-08-25 16:27:21 +05:00
jze9
729b41bbfb feat(corpus): добавить прикладные/техникумовские темы в русский сидинг
All checks were successful
Deploy / test (push) Successful in 3m5s
Deploy / deploy (push) Successful in 2s
Исходный список из 29 дисциплин был чисто вузовски-научным (экономика,
право, психология и т.д.) — ни одной темы уровня СПО/колледжей
(полиграфия, строительство, сварка, туризм и т.п.), хотя реальные
дипломы студентов именно на них. +29 новых дисциплин, идемпотентно
(старые 30 не трогает).
2026-08-25 15:52:12 +05:00
jze9
29301c3a4c feat(cabinet): дать пользователю самому удалять задачи из кабинета
All checks were successful
Deploy / test (push) Successful in 3m3s
Deploy / deploy (push) Successful in 20s
Раньше удалить задачу мог только админ через свою панель — обычный
пользователь с зависшей (например, навечно застрявшей в processing)
задачей не мог убрать её из кабинета вообще. Кнопка удаления на
карточке + снято искусственное ограничение "нельзя удалить, пока
processing" (все воркеры уже корректно проверяют task is None перед
записью, удаление во время реальной обработки безопасно).
2026-08-25 15:24:46 +05:00
jze9
5eac465917 fix(frontend): не прятать блок с текстом работы по умолчанию
All checks were successful
Deploy / test (push) Successful in 3m11s
Deploy / deploy (push) Successful in 13s
Раскрыт по умолчанию — свёрнутое состояние читалось как «ничего нет».
Плюс явное сообщение, когда подсвечивать действительно нечего (у задачи
нет ни совпадений, ни похожих по теме источников), вместо молчаливой
голой стены текста.
2026-08-25 15:02:46 +05:00
jze9
d02596cedb feat(plagiarism): показывать текст работы с подсветкой совпадений
All checks were successful
Deploy / test (push) Successful in 3m49s
Deploy / deploy (push) Successful in 22s
Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
2026-08-25 14:32:16 +05:00
jze9
53d0de3d71 feat(plagiarism): показывать тематически близкие источники, а не только нарушения
All checks were successful
Deploy / test (push) Successful in 4m15s
Deploy / deploy (push) Successful in 37s
Кандидаты уровня 3 (FAISS), которые прошли порог семантической схожести,
но LLM не подтвердила заимствование, раньше молча отбрасывались. Теперь
это отдельный блок "recommendations" в отчёте — не плагиат, но источники,
полезные для раскрытия темы.
2026-08-25 14:16:09 +05:00
jze9
12eb954838 fix(indexer): возвращать месячную квоту при провале извлечения файла
All checks were successful
Deploy / test (push) Successful in 2m51s
Deploy / deploy (push) Successful in 15s
Живой репорт: юзер получил "Превышен месячный лимит проверок (тариф 'free'):
1/1" сразу после ЕДИНСТВЕННОЙ попытки — а та попытка провалилась ещё на
"файл не является PDF" (см. предыдущий коммит df2dfcc). Причина: API списывает
месячную квоту plagiarism синхронно при ЗАГРУЗКЕ файла (check_and_increment_
limit в documents.py), ДО того как воркер вообще попытается его распарсить —
реальной проверки не было, а квота уже списана навсегда (сброс только в
следующем месяце). У free-тарифа лимит 1/мес — то есть один неверный формат
файла сжигал единственную попытку целиком.

Плюс сопутствующая неэффективность: extract_and_check ретраил (3 попытки,
60с задержка) даже детерминированные ошибки формата — на 2-й и 3-й попытке
результат будет тем же, ретрай только откладывает финальный фидбек юзеру
на пару минут без всякого смысла.

Фикс:
- ValueError (битый файл/пустой текст) теперь ловится ДО общего Exception:
  без ретрая (не поможет), с возвратом квоты (реальной проверки не было).
- db.refund_plagiarism_quota(): декремент того же Redis-ключа
  rl:{user_id}:plagiarism:{YYYY-MM}, что инкрементит api/rate_limiter.py —
  тот же формат ключа, декремент виден мгновенно и там, и там (общий Redis).
- Транзиентные ошибки (сеть/MinIO/БД) — поведение прежнее (ретрай, без
  возврата квоты, т.к. задача может ещё успешно завершиться).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 21:32:02 +05:00
jze9
df2dfcc456 fix(indexer): понятная ошибка, если загруженный "PDF" на самом деле не PDF
All checks were successful
Deploy / test (push) Successful in 2m41s
Deploy / deploy (push) Successful in 11s
Живой репорт: проверка плагиата упала с "Не удалось открыть PDF: code=7: no
objects found" — сырая внутренняя ошибка MuPDF. Разобрал файл из MinIO: это
HTML-страница (веб-интерфейс роутера D-Link DAP-400P), сохранённая с
расширением .pdf — 2049 байт, не PDF вообще. Система корректно отказалась
парсить мусор (это не баг пайплайна — реальный битый/не-PDF файл юзера), но
сообщение об ошибке было нечитаемым.

extract_text_from_pdf теперь проверяет magic-байты (%PDF-) ДО попытки
fitz.open() и даёт понятное "Файл повреждён или не является PDF-документом"
для этого частого случая; для настоящих PDF с внутренней порчей — прежнее
поведение (сырая ошибка MuPDF как detail, для диагностики).

4 юнит-теста (валидный PDF, HTML под видом PDF, пустые байты, битый заголовок
PDF). Тестов всего: 122 (было 118).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 18:55:24 +05:00
jze9
712dc383ea fix(api): лимит одновременных задач считать по БД, не по Redis-счётчику
All checks were successful
Deploy / test (push) Successful in 2m54s
Deploy / deploy (push) Successful in 11s
Живая проверка: юзер сделал один поиск (давно завершился, status='done'),
второй поиск сразу упёрся в "Превышен лимит одновременных задач" — на
free-тарифе лимит 1.

Причина: acquire_concurrent_slot() инкрементирует Redis-счётчик
concurrent:{user_id} при создании КАЖДОЙ задачи (search.py, documents.py),
а release_concurrent_slot() — которая должна его декрементировать по
завершении — НЕ ВЫЗЫВАЛАСЬ НИГДЕ В КОДЕ (grep подтвердил: только
определение, ни одного вызова). Счётчик только рос, лимит превышался
навсегда для практически любого юзера после первой же задачи — до
часового TTL-автосброса.

Фикс — не "доставить забытый release()" (это лечит симптом, но оставляет
класс бага: счётчик и реальность могут разойтись любым другим путём), а
убрать сам отдельный счётчик. check_concurrent_limit() считает активные
задачи (status IN queued/processing) напрямую в Postgres — Task.status уже
корректно обновляется во всех воркерах (проверено многократно в этой
сессии), рассинхронизация невозможна по конструкции. Redis-лимиты
(дневные/месячные, Lua-скрипт) не тронуты — там свой, рабочий, механизм.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 18:42:29 +05:00
jze9
61c903ae0e fix(api): 500 на GET /tasks/{id} при обращении к закэшированному юзеру
All checks were successful
Deploy / test (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m41s
Живая проверка после первого реального OAuth-логина: юзер вошёл, поиск
отработал, но статус задачи не показывался — GET /api/tasks/{id} падал 500.
В логе: AttributeError: 'NoneType' object has no attribute
'supports_population' на current_user.id.

Причина: _load_user() при попадании в Redis-кэш делал
User.__new__(User); u.__dict__.update(data) — выглядело как лёгкий объект
без лишнего SELECT, но замапленные атрибуты User (id, email, ...) —
дескрипторы данных SQLAlchemy: их __get__ обращается к InstanceState,
которого у объекта в обход __init__/ORM-машинерии нет. Падало на КАЖДОМ
запросе, где юзер брался из кэша (5 мин TTL) — то есть почти всегда,
кроме первого запроса после логина/протухания кэша.

Фикс: CachedUser — обычный dataclass с теми же полями, без дескрипторов,
падать нечему. Заодно нашёл тем же грепом идентичный баг в
resend_verification (трогал current_user.verification_token — не входит
в кэшируемый набор полей, к тому же current_user из кэша не привязан к
сессии — db.commit()/refresh() на нём тоже не сработали бы) — почистил по
образцу update_profile/change_password: подгружает свежего юзера из БД.

Подтверждено вручную на проде: сгенерировал JWT для реального юзера,
GET /api/tasks/{id} возвращал 500 до фикса.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 18:31:20 +05:00
jze9
c76f60df69 fix(auth): логировать тело ответа при ошибке OAuth-обмена кода
All checks were successful
Deploy / test (push) Successful in 3m16s
Deploy / deploy (push) Successful in 17s
Живая проверка Google-входа упала с "401 Unauthorized" на /token, но лог
показывал только код статуса — тело ответа (там у Google/Яндекс error/
error_description с точной причиной: invalid_client и т.п.) терялось.

_raise_for_status_verbose() оборачивает raise_for_status(), добавляя resp.text
в сообщение исключения — на все 4 вызова (token+userinfo × google+yandex).
Чисто диагностическое изменение, поведение не меняет. Тесты/линт/mypy — ок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 18:21:23 +05:00
jze9
9f35ae8de1 feat(auth): вход через Google и Яндекс (OAuth2 authorization code flow)
All checks were successful
Deploy / test (push) Successful in 2m48s
Deploy / deploy (push) Successful in 23s
Реализовано без authlib, на голом httpx (AsyncClient — синхронный httpx
блокировал бы event loop API на время внешнего запроса), по образцу двух
провайдеров:

- Миграция 004: hashed_password → nullable (OAuth-юзеры без пароля),
  oauth_provider/oauth_id + уникальный индекс на пару.
- app/core/security.py: verify_password защищён от hashed=None (иначе TypeError
  при попытке OAuth-юзера войти по паролю — нашёл при ревью, не баг-репорт).
- app/core/oauth.py: get_authorize_url()/exchange_code() — единый интерфейс для
  google/yandex. Redirect URI: <APP_URL>/api/auth/<provider>/callback.
- app/api/auth.py: GET /auth/{provider}/login (редирект на согласие, state в
  httponly-cookie от CSRF) и /callback (обмен code, find-or-create юзера по
  oauth_id → по email для привязки существующего аккаунта → новый без пароля,
  is_verified=email_verified от провайдера). Токен фронту — через URL-фрагмент
  #token=..., не query (не уходит в логи/Referer).
- Фронтенд: OAuthButtons (Login/Register), страница /oauth/callback (читает
  фрагмент → GET /auth/me → setAuth → редирект в кабинет).
- 6 юнит-тестов чистой логики сборки ссылок (app/core/oauth.py) — первый тест-
  контур для api/ в этой сессии (pytest.ini/conftest/requirements-test по
  образцу остальных сервисов), добавлен в общий run_tests.sh + mypy-гейт.

GOOGLE_CLIENT_ID/SECRET уже в .env (юзер создал OAuth-клиент), YANDEX_* пусты —
эндпоинты в этом случае отвечают 503, не падают. .env.example документирует обе
пары. Тестов всего: 118 (было 112).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 17:59:10 +05:00
jze9
251be1d77b fix(ci): не валить джобу на неудачной уборке временного чекаута
All checks were successful
Deploy / test (push) Successful in 2m33s
Deploy / deploy (push) Successful in 15m1s
Найден реальный root cause, почему ни один деплой не проходил с 11 августа:
джоба test честно проходила линт (ruff+mypy) и все 112 тестов ("All checks
passed!", "Все юнит-тесты прошли"), но затем падала на шаге уборки — rm -rf
временной директории не мог удалить mypy/pytest-кэш, созданный ВНУТРИ Docker-
контейнеров от root (docker run без --user). Обычный пользователь раннера не
может удалить root-owned файлы → rm возвращает ненулевой код → джоба падает →
deploy пропускается (needs: test) — при том что код был полностью исправен.

Починено: rm -rf ... || true в обоих шагах уборки (test и deploy). Чистка
временной папки — best-effort, не критерий готовности кода.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 17:07:46 +05:00
96 changed files with 6665 additions and 530 deletions

View File

@@ -28,6 +28,15 @@ OLLAMA_URL=http://ollama:11434
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRE_MINUTES=10080
# OAuth — вход через Google/Яндекс (опционально; пусто = кнопка провайдера скрыта).
# Google: console.cloud.google.com → APIs & Services → Credentials → OAuth Client ID
# (Web application), redirect URI: https://<домен>/api/auth/google/callback
# Yandex: oauth.yandex.ru → создать приложение, redirect URI: .../api/auth/yandex/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
# SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS)
SMTP_HOST=mail.jze9mail.ru
SMTP_PORT=587

View File

@@ -38,7 +38,10 @@ jobs:
- name: Убрать временный чекаут
if: always()
run: rm -rf "${SRC:-/tmp/none}"
# Часть файлов (mypy/pytest кэш) создана внутри Docker-контейнеров от
# root — обычный пользователь раннера не может их удалить. Это не
# повод валить джобу: код уже проверен, чистка — best-effort.
run: rm -rf "${SRC:-/tmp/none}" || true
deploy:
needs: test # деплой только если юнит-тесты прошли
@@ -55,6 +58,8 @@ jobs:
- name: Деплой (бэкенд 1.32 + фронтенд CT 102)
env:
PVE_PASSWORD: ${{ secrets.PVE_PASSWORD }}
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
run: |
set -euo pipefail
cd "$SRC"
@@ -62,4 +67,7 @@ jobs:
- name: Убрать временный чекаут
if: always()
run: rm -rf "${SRC:-/tmp/none}"
# Часть файлов (mypy/pytest кэш) создана внутри Docker-контейнеров от
# root — обычный пользователь раннера не может их удалить. Это не
# повод валить джобу: код уже проверен, чистка — best-effort.
run: rm -rf "${SRC:-/tmp/none}" || true

4
.gitignore vendored
View File

@@ -105,3 +105,7 @@ services/**/app/models/**/*.py[cod]
# Temp
tmp/
temp/
CREDENTIALS.md
# Прокси-конфиг с реальными credentials (UUID) — только на проде, не в git
infra/singbox/config.json

View File

@@ -23,7 +23,7 @@
│ RabbitMQ│ Celery задачи
┌────────────▼──┐ ┌──▼──────────────┐
│ worker-gpu │ │ worker-indexer │
│ (CUDA/FAISS) │ │ (PDF/DOCX parse) │
│ (FAISS, CPU) │ │ (PDF/DOCX parse) │
│ Sem. search │ │ Winnowing/MinHash │
│ LLM paraphrase│ └──────────────────┘
└────────────────┘
@@ -35,7 +35,8 @@
Инфраструктура:
PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8
MinIO (S3) · Ollama (qwen2.5:7b) · отдельный GPU-сервер
MinIO (S3) · Ollama с bge-m3 на отдельном сервере эмбеддингов (192.168.1.40)
LLM-парафраз (L4) — Ollama или OpenRouter, переключается LLM_BACKEND
```
## Быстрый старт
@@ -87,7 +88,7 @@ make clean # Удалить контейнеры и volumes
| Компонент | Технологии |
|-----------|-----------|
| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic |
| GPU Worker | sentence-transformers, FAISS-CPU (IndexIDMap2 · IndexFlatIP), Ollama (qwen2.5:7b) |
| GPU Worker | FAISS-CPU (IndexIDMap2 · IndexFlatIP), эмбеддинги bge-m3/1024d (Ollama/sentence-transformers/облако — `EMBED_BACKEND`), LLM-парафраз через Ollama или OpenRouter (`LLM_BACKEND`) |
| Indexer | PyMuPDF, python-docx, Winnowing, MinHash LSH |
| Очереди | RabbitMQ (брокер) + Celery 5 (воркеры) + Redis (результаты) |
| База данных | PostgreSQL 16 |
@@ -99,8 +100,13 @@ make clean # Удалить контейнеры и volumes
1. **Winnowing** — точные/частичные совпадения по fingerprint'ам (xxHash + скользящее окно)
2. **MinHash LSH** — нечёткие совпадения (шинглы + Jaccard, индекс в общем Redis)
3. **FAISS cosine** — семантическое сходство (порог 0.75; IndexFlatIP на нормированных эмбеддингах)
4. **Ollama qwen2.5:7b** — LLM-анализ парафраза (порог confidence 0.7)
3. **FAISS cosine** — семантическое сходство (порог 0.75; эмбеддинги bge-m3/1024d, IndexFlatIP на нормированных векторах)
4. **LLM-анализ парафраза** (порог confidence 0.7) — бэкенд переключается `LLM_BACKEND`: локальная Ollama или облачный OpenRouter/DeepSeek (геоблокировка РФ обходится через SOCKS5-прокси `singbox-proxy`, см. `infra/singbox/`)
Проверенные работы сами пополняют корпус (`source=user_submission`) — это даёт эффект
как у коммерческих систем (база растёт от каждой проверки), но такие документы
**исключены** из сравнения на всех уровнях L1-L3 (`AUTO_APPROVE_SUBMISSIONS`,
по умолчанию выключено), иначе работа матчилась бы сама с собой на 100%.
## Тарифные планы
@@ -130,8 +136,17 @@ python scripts/run_parser.py arxiv \
--query "deep learning" \
--categories cs.AI cs.LG \
--limit 5000
# PubMed Central (PMC) — англоязычные научные статьи открытого доступа
python scripts/run_parser.py pmc \
--query "public health" \
--limit 1000
```
Массовое расширение корпуса по дисциплинам (не единичный запрос) — `scripts/seed_broad_corpus.py`
и `scripts/seed_ru_sources.py` заводят десятки `parse_sources` записей сразу, дальше их
разбирает `index.run_parser` через очередь. Подробности и текущий охват — [docs/INGESTION.md](docs/INGESTION.md).
## Переменные окружения
Смотри `.env.example` для полного списка переменных.
@@ -141,26 +156,34 @@ python scripts/run_parser.py arxiv \
| Файл | Назначение |
|------|-----------|
| `docker-compose.prod.yml` | **Реальный прод.** app-сервисы + nginx (собранный фронтенд + TLS) + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в `.env`. |
| `docker-compose.prod.yml` | **Реальный прод.** app-сервисы + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в `.env`. Файл описывает и сервис `nginx` (собранный фронтенд + TLS), но в текущей топологии он **не запускается** — фронтенд и TLS реально раздаёт хостовой nginx на отдельном CT 102 (см. «Продакшн деплой» ниже), а `api` публикует `8000:8000` наружу именно под него. |
| `docker-compose.test.yml` | Повседневная разработка — hot reload против той же общей инфры, что и прод (`make test-up`). |
| `docker-compose.selfhosted.yml.example` | Не используется. Полностью автономный вариант (свои Postgres/Redis/RabbitMQ/ES/MinIO/Ollama + GPU passthrough) — на случай отдельного выделенного сервера в будущем. |
## Продакшн деплой
```bash
# На сервере
cp .env.example .env
nano .env # Настроить все пароли и ключи, указать реальные адреса общих Postgres/Redis/RabbitMQ/MinIO/Ollama
Автоматический: push в `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) → гейт
`test` (lint + юнит-тесты) → `deploy` → `scripts/deploy.sh` на app-хосте. Умная
пересборка — образ пересобирается только у сервисов, чей код изменился с прошлого
деплоя (маркер SHA в `.last_deploy_sha`); правка `docker-compose.prod.yml` триггерит
полную пересборку всех пяти бэкенд-сервисов.
# Сертификат ДО первого запуска nginx-контейнера (standalone, порт 80 должен быть свободен)
certbot certonly --standalone -d academic.jze9.ru
**`.env` на проде НЕ редактируется руками.** Первым шагом `deploy.sh` логинится в
self-hosted Infisical (Machine Identity, Universal Auth) и генерирует `.env` заново
из окружения `prod` на каждом запуске — ручные правки файла на сервере переживут
максимум до следующего деплоя. Менять секреты/конфиг — через Infisical
(`https://infisical.jze9.ru`, проект `academ`), не через `.env` напрямую. Если
Infisical недоступен или вернул подозрительно мало ключей, деплой падает раньше
синка кода и не трогает рабочий `.env`.
make build
make up
make migrate
```
Фронтенд собирается отдельно (`docker run node:20-slim` → `npm run build`) и
заливается по `scp`/`pct push` на CT 102, где раздаётся **хостовым** (не
докеризованным) nginx с TLS через certbot — см. `/etc/nginx/sites-available/academic`
на CT 102. Это отдельная машина от app-хоста, деплоится только когда меняется
`services/frontend/`.
Nginx работает внутри контейнера (`docker-compose.prod.yml`, сервис `nginx`, Dockerfile в `infra/nginx/Dockerfile`) — собирает `services/frontend` в статику и отдаёт её вместе с проксированием `/api/`, `/ws/` на `api:8000` по конфигу `infra/nginx/nginx.conf`. Контейнер монтирует `/etc/letsencrypt` с хоста как read-only — сертификат обновляется на хосте (`certbot renew`), контейнер просто читает его.
Ручной запуск деплоя (например, после смены секрета в Infisical без изменений кода) —
`workflow_dispatch` в Gitea Actions, или локально: `PVE_PASSWORD=... INFISICAL_CLIENT_ID=... INFISICAL_CLIENT_SECRET=... bash scripts/deploy.sh` из полного чекаута репозитория.
## Векторный бэкенд (FAISS / Qdrant)
@@ -199,7 +222,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
- Prometheus (`infra/prometheus/prometheus.yml`) скрейпит API и **Flower** — из
Flower приходят метрики Celery (задачи, время выполнения, воркеры) без доп. кода.
- Grafana с автоподключённым источником Prometheus (`infra/grafana/provisioning/`);
пароль admin — через `GRAFANA_ADMIN_PASSWORD` в `.env` (по умолчанию `admin`).
пароль admin — `GRAFANA_ADMIN_PASSWORD` в Infisical (prod). Если не задан — падает
на дефолт `admin`, поэтому перед включением профиля `observability` в проде
убедиться, что значение в Infisical реально установлено (не пустое).
## Тестирование и качество кода
@@ -208,9 +233,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
2. **Юнит-тесты** — `pytest` по сервисам: 112 тестов на ядро детекции, скоринга,
парсеров и форматирования, без внешней инфры (БД/Redis/GPU/Ollama замоканы
либо не нужны).
2. **Юнит-тесты** — `pytest` по сервисам: 150 тестов на ядро детекции, скоринга,
парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
(БД/Redis/GPU/Ollama замоканы либо не нужны).
```bash
make lint # ruff + mypy в изолированном контейнере
@@ -231,13 +256,17 @@ make test-one SVC=worker-gost # тесты одного сервиса
| L2 — нечёткие (MinHash LSH) | `worker-indexer/app/algorithms/minhash.py` | 6 |
| Разбиение на фрагменты | `worker-indexer/app/fragments.py` | 5 |
| Автопополнение корпуса | `worker-indexer/app/staging.py` | 6 |
| Извлечение текста из PDF | `worker-indexer/app/extractors/pdf.py` | 4 |
| L3 — семантический индекс (FAISS) | `worker-gpu/app/faiss_manager.py` | 5 |
| L3 — векторный бэкенд (Qdrant + выбор) | `worker-gpu/app/qdrant_manager.py`, `vector_store.py` | 9 |
| L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 |
| Итоговый % плагиата + цитаты | `worker-gpu/app/scoring.py` | 18 |
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
| Список литературы | `worker-gost/app/bibliography.py` | 7 |
| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 |
| Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | `scripts/parsers/` | 19 |
| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 |
| Прогресс заливки (счётчики, бюджет) | `worker-indexer/app/progress.py` | 7 |
| Шкала загрузки и тайминги прогонов | `api/app/core/progress.py`, `schemas/admin.py` | 11 |
## Лицензия

View File

@@ -105,10 +105,28 @@ services:
dockerfile: Dockerfile
container_name: antiplagiator-api
<<: *app-env
# CT102 (общий реверс-прокси, /etc/nginx/sites-available/academic) проксирует
# /health, /api/, /ws/ напрямую на 192.168.1.32:8000 — без этого маппинга
# порт нигде не публикуется и прод отвечает 502 на все API-запросы.
ports:
- "8000:8000"
depends_on:
elasticsearch:
condition: service_healthy
# SOCKS5-прокси (sing-box → VLESS-нода вне РФ) — OpenRouter недоступен
# напрямую из России, только для этого и нужен. Остальной трафик
# worker-gpu (Postgres/RabbitMQ/MinIO/Ollama) идёт напрямую, не через него.
singbox-proxy:
image: ghcr.io/sagernet/sing-box:v1.10.0
container_name: antiplagiator-singbox-proxy
command: run -c /etc/sing-box/config.json
volumes:
- ./infra/singbox/config.json:/etc/sing-box/config.json:ro
networks:
- antiplagiator
restart: unless-stopped
worker-gpu:
build:
context: ./services/worker-gpu
@@ -120,6 +138,8 @@ services:
depends_on:
elasticsearch:
condition: service_healthy
singbox-proxy:
condition: service_started
worker-indexer:
build:
@@ -132,6 +152,10 @@ services:
depends_on:
elasticsearch:
condition: service_healthy
# CORE не отвечает на ключ с российских адресов — парсер ходит через
# sing-box (см. scripts/parsers/core.py)
singbox-proxy:
condition: service_started
worker-notifier:
build:

View File

@@ -46,7 +46,8 @@
│ worker- │ │ PostgreSQL 1.38 · Redis 1.35 │
│ notifier │ │ RabbitMQ .82 · MinIO 1.21 │
│ SMTP email │ │ Elasticsearch (local 1.32) │
└────────────┘ │ Ollama .163 (qwen2.5:7b) │
└────────────┘ │ Ollama 1.40 (bge-m3, эмбед.) │
│ OpenRouter/DeepSeek — LLM (опц.)│
│ [opt] Qdrant · Prometheus/Graf.│
└──────────────────────────────┘
```
@@ -57,7 +58,7 @@
|--------|-----------|-----------------|
| **api** | FastAPI, SQLAlchemy async (asyncpg), Redis, Celery-producer | HTTP/WS API, auth (JWT), rate-limits, диспетч задач в очереди, админ-панель |
| **worker-indexer** | Celery, PyMuPDF, python-docx, xxhash, datasketch | Извлечение текста (PDF/DOCX/TXT), фрагментация, **L1 Winnowing**, **L2 MinHash LSH**, парсинг источников, обогащение full-text |
| **worker-gpu** | Celery, sentence-transformers, FAISS/Qdrant, httpx→Ollama | Эмбеддинги, **L3** семантический поиск, **L4** LLM-анализ парафраза, семантический поиск источников |
| **worker-gpu** | Celery, FAISS/Qdrant, httpx→Ollama/OpenRouter | Эмбеддинги (`EMBED_BACKEND`: ollama/sentence_transformers/cloud), **L3** семантический поиск, **L4** LLM-анализ парафраза (`LLM_BACKEND`: ollama/openrouter), семантический поиск источников |
| **worker-gost** | Celery | Список литературы по **ГОСТ 7.1-2003 / Р 7.0.5-2008** |
| **worker-notifier** | Celery, smtplib | Email: письма-результаты и верификация (jze9mail.ru) |
| **frontend** | React 18, Vite, TS, Tailwind, Zustand, React Query | SPA: 11 публичных страниц + админ-панель. Собирается в статику, отдаётся nginx |
@@ -76,7 +77,16 @@
`faiss_id`.
- **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1.
- **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу.
- **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус.
- **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус,
`last_run_id` — ссылка на последний прогон, `resume_token` — позиция
продолжения для массовых источников (номер статьи в дампе, токен страницы
бакета).
- **parse_runs** — прогоны заливки: стадия, счётчики (`target/fetched/processed/
added/duplicates/skipped/failed`), `cancel_requested`, `heartbeat_at`, журнал
событий (JSON). Тайминги раздельные: `started_at` — постановка в очередь,
`run_started_at` — реальный старт работы воркером (при массовом запуске между
ними часы ожидания), `finished_at` — конец. Из них админка рисует шкалу
загрузки, см. §12.
- **staged_works** — пользовательские загрузки на модерацию перед добавлением в корпус.
- **admin_sessions** — одноразовые коды входа в админку.
@@ -86,8 +96,8 @@
| Очередь | Задачи | Воркер |
|---------|--------|--------|
| `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text` | worker-indexer |
| `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic` | worker-gpu |
| `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text`, `index.ingest_upload` | worker-indexer |
| `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic`, `gpu.index_stats` | worker-gpu |
| `queue.gost` | `gost.format_bibliography` | worker-gost |
| `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier |
@@ -98,8 +108,11 @@
**Проверка плагиата.** upload (api, файл→MinIO, Task) → `index.extract_and_check`
(извлечь текст → фрагментация → **L1 Winnowing** по fingerprints → **L2 MinHash LSH**
в Redis) → передаёт частичные совпадения в `gpu.check_plagiarism` (**L3** FAISS/Qdrant
семантика по фрагментам → для подозрительных **L4** Ollama-парафраз) →
семантика по фрагментам → для подозрительных **L4** LLM-парафраз) →
`app.scoring.aggregate_results` (итоговый %) → результат в Task → `notify.send_task_done`.
Фронтенд (`HighlightedDocument.tsx`) показывает исходный текст работы с подсветкой
совпадающих/процитированных фрагментов поверх обычного списка нарушений
(`PlagiarismReport.tsx`).
**Поиск источников.** api → `gpu.search_semantic`: эмбеддинг запроса → векторный поиск
(FAISS/Qdrant) + Elasticsearch BM25 → объединение → результат.
@@ -108,9 +121,24 @@
`app.bibliography.build_bibliography` (сортировка кириллица→латиница, нумерация,
формат 7.1/7.0.5) → результат.
**Наполнение корпуса.** админка/CLI → `index.run_parser` (OpenAlex/arXiv/КиберЛенинка,
фильтр `is_oa`) → `index.add_document` (дедуп по `ext_id`, fingerprints, MinHash,
эмбеддинг) → `index.enrich_full_text` (скачать OA-PDF → MinIO → переиндексация).
**Наполнение корпуса.** админка → `index.run_parser` — один конвейер для всех
источников, со шкалой, журналом и кнопкой остановки. Внутри два пути записи:
- *обычные источники* (OpenAlex/arXiv/PMC/КиберЛенинка, фильтр `is_oa`) —
`index.add_document` на каждый документ: дедуп по `ext_id`, fingerprints,
MinHash, Elasticsearch, эмбеддинг, затем `index.enrich_full_text` (скачать
OA-PDF → MinIO → переиндексация);
- *массовые источники* (`wikipedia_ru`, `pmc_bulk`) — парсер отдаёт генератор,
запись идёт пачками через `COPY` (`app/bulk_writer.py`), позиция продолжения
хранится в `parse_sources.resume_token`. Эмбеддинги там не считаются: они
медленнее заливки на порядок и стали бы её узким местом, вектора
досчитываются отдельно (§12).
Ход заливки в обоих случаях пишется в `parse_runs` (§12).
Второй путь наполнения — ручная загрузка файлов админом: api сохраняет их в
MinIO (`corpus-upload/`) → `index.ingest_upload` (извлечь текст → `add_document`,
`source=manual_upload`). Это не проверка на плагиат: файл сразу становится
источником для сравнения, минуя отстойник.
## 7. Детекция плагиата — 4 уровня
@@ -119,13 +147,22 @@
2. **L2 MinHash LSH** (`.../minhash.py`) — нечёткие совпадения: шинглы → MinHash (128
перм.) → LSH-индекс в **общем Redis** (префикс `antiplag_lsh`, upsert, graceful-фолбэк
в память).
3. **L3 семантика** (`worker-gpu`) — эмбеддинги `paraphrase-multilingual-mpnet-base-v2`
(768d, нормированы) → cosine в FAISS `IndexIDMap2(IndexFlatIP)` **или** Qdrant
(`VECTOR_BACKEND`); порог 0.75.
4. **L4 LLM-парафраз** — Ollama `qwen2.5:7b` оценивает пары «источник↔фрагмент» для
подозрительных из L3; порог confidence 0.7.
3. **L3 семантика** (`worker-gpu`) — эмбеддинги `bge-m3` (1024d, нормированы),
бэкенд переключается `EMBED_BACKEND` (`ollama` — дефолт, GGUF через Vulkan на
AMD GPU; `sentence_transformers` — локальная загрузка CUDA/CPU; `cloud` —
облачный инференс той же модели, без локального GPU) → cosine в FAISS
`IndexIDMap2(IndexFlatIP)` **или** Qdrant (`VECTOR_BACKEND`); порог 0.75.
4. **L4 LLM-парафраз** — оценивает пары «источник↔фрагмент» для подозрительных из
L3; порог confidence 0.7. Бэкенд переключается `LLM_BACKEND`: `ollama`
(локальная модель, нужен GPU-хост) или `openrouter` (облачный DeepSeek —
геоблокировку РФ обходит SOCKS5-прокси `singbox-proxy`, докер-сервис на базе
sing-box/Trojan). Ни один документ с `source=user_submission` (прошлые
проверки пользователей) не участвует в сравнении ни на одном уровне —
иначе работа матчилась бы сама с собой.
Итог: `scoring.aggregate_results` — доля уникальных помеченных позиций (не выше 100%).
Кандидаты, похожие семантически, но не подтверждённые LLM как парафраз, не теряются —
попадают в отдельный список «похожие по теме источники» (рекомендации, не нарушения).
## 8. Векторный бэкенд
@@ -143,22 +180,59 @@
| Redis 7 | 1.35 (выделенный LXC) | кэш, rate-limits, LSH |
| RabbitMQ | 192.168.20.82 | брокер Celery |
| MinIO (S3) | 1.21 | документы, full-text, бэкапы |
| Ollama (qwen2.5:7b) | 192.168.20.163 | GPU-сервер |
| Ollama (bge-m3, эмбеддинги) | 192.168.1.40 «embedding-cpu» (VM 210 на хосте pve2 .1.37) | Xeon 4314, AVX-512+VNNI, 8 vCPU; модель всегда в RAM (`OLLAMA_KEEP_ALIVE=-1`), сторожевой таймер раз в 2 мин перезапускает зависшую Ollama. Замер на данных корпуса: 3.6 док/с против 2.2 у прежнего сервера |
| ~~Ollama на GPU~~ (выведен 31.08.2026) | 192.168.20.109 «embedding-gpu» | RX580; отказал по amdgpu ring timeout (Vulkan-контекст умирал при живом systemd-юните). Оставлен как есть — откат сводится к возврату `OLLAMA_URL` в Infisical |
| OpenRouter (DeepSeek, L4 LLM) | облако | опционально вместо локальной Ollama (`LLM_BACKEND=openrouter`); из РФ доступен только через `singbox-proxy` |
| Infisical (секреты) | 192.168.20.111 «VM111» | self-hosted, `infisical.jze9.ru`; единственный источник правды для `.env` на проде |
| Qdrant / Prometheus / Grafana | 1.32 | опционально, под compose-профилями |
**Деплой** — Gitea Actions по push в `main`: гейт `test` (ruff+mypy → 82 юнит-теста),
CT 108 (192.168.20.163, GTX1070, qwen2.5:7b) — старая LLM-нода, роль полностью
заменена OpenRouter, контейнер остановлен, но не удалён.
**Деплой** — Gitea Actions по push в `main`: гейт `test` (ruff+mypy → 113 юнит-тестов),
затем `deploy` (`needs: test`) через `scripts/deploy.sh` (умная пересборка изменённых
сервисов). PG/Redis не в compose — общая инфра берётся из `.env`.
сервисов). PG/Redis/RabbitMQ/MinIO не в compose — общая инфра берётся из `.env`,
который на каждом деплое генерируется заново из Infisical (см. §10).
## 10. Конфигурация
## 10. Конфигурация и секреты
Всё через `.env` (пример — `.env.example`). Обязательно менять: `SECRET_KEY`,
`POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Опции: `VECTOR_BACKEND`, `QDRANT_URL`,
`GRAFANA_ADMIN_PASSWORD`. `.env` не в git и исключён из деплой-rsync (не откатывается).
Приложение читает `.env` (пример структуры — `.env.example`), но на проде этот файл
**не редактируется руками** и не персистентен как источник правды — на каждом
деплое `scripts/deploy.sh` логинится в self-hosted **Infisical**
(`https://infisical.jze9.ru`, проект `academ`, окружение `prod`) через Machine
Identity (Universal Auth) и генерирует `.env` заново (`infisical export`) до синка
кода. Правки секретов/конфига — через Infisical, а не через `.env` на сервере
(следующий деплой затрёт локальные правки). Если Infisical недоступен или вернул
подозрительно мало ключей, деплой прерывается раньше, не трогая рабочий `.env`.
Обязательно менять (значения уже в Infisical, не дефолты из `.env.example`):
`SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Опции: `VECTOR_BACKEND`,
`QDRANT_URL`, `EMBED_BACKEND`, `LLM_BACKEND`, `GRAFANA_ADMIN_PASSWORD`.
Локальная разработка (`docker-compose.test.yml`) продолжает использовать обычный
`.env`, отдельный от прод-потока через Infisical.
## 11. Качество и тесты
- **82 юнит-теста** (pytest, per-service) на чистую логику L1-L4/скоринг/фрагменты/
**Замер качества детекции** — [`scripts/ops/detection_benchmark.py`](../scripts/ops/detection_benchmark.py).
Делает из документов корпуса «студенческие работы» (дословная копия, лёгкий и
сильный рерайт, плюс заведомо оригинальный текст) и прогоняет через настоящий
путь L1. Первый замер, 05.09.2026:
| Случай | Доля найденных |
|--------|---------------:|
| дословно | 100% |
| лёгкий рерайт (выброшено каждое 10-е слово) | 100% |
| сильный рерайт (каждое 3-е слово) | 0% — задача L3/L4, не L1 |
| оригинальный текст | 0% ложных обвинений |
Запускать после изменения порогов (`EXACT_FRAGMENT_THRESHOLD`), параметров
winnowing и плотности отпечатков: без этих чисел непонятно, улучшение сделано
или ухудшение. Ограничение замера: в выборку попадают только документы с
реальной глубиной (>800 отпечатков) — по документам с одной аннотацией мерить
нечего, и это само по себе показатель состояния корпуса.
- **113 юнит-тестов** (pytest, per-service) на чистую логику L1-L4/скоринг/фрагменты/
ГОСТ/библиография; инфра замокана или не нужна. Запуск: `make test`.
- **Гейты CI**: ruff (весь Python) + mypy (доменная логика) + тесты — блокируют деплой.
`make lint`. Хермет-раннеры в `python:3.11-slim`.
@@ -167,10 +241,45 @@
## 12. Наблюдаемость и эксплуатация
- **Мониторинг**: `antiplag_monitor.py` (cron 5 мин, 7 сервисов, email-алерт при смене
статуса). Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower).
- **Мониторинг**: [`scripts/ops/antiplag_monitor.py`](../scripts/ops/antiplag_monitor.py)
(cron 5 мин на 1.32, email при смене статуса). Адреса берутся из прод-`.env`, а не
зашиты в код: прежняя версия месяц проверяла остановленный CT 108 и не видела
реального сервера эмбеддингов — постоянный ложный DOWN заглушал настоящие аварии.
Проверяются в том числе **воркеры Celery**: живой брокер ещё не значит работающую
систему (05.09 воркеры час простаивали при «зелёном» брокере).
Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower).
- **Бэкапы**: `pg_dump→gzip→MinIO`, cron 03:00, ротация 14. Проверка восстановления —
`scripts/ops/pg_restore_verify.sh`. HA/DR — [DR-HA.md](DR-HA.md).
- **Шкала загрузки источников** (админка → «Источники»): каждый запуск создаёт строку
`parse_runs`, воркер пишет туда прогресс раз в ~2с. Шкала считается по формуле
`api/app/core/progress.py`: 0→50% — выборка из источника, 50→100% — индексация в
базу. Раскрытая строка показывает журнал прогона (по шагам, с таймингами).
Кнопки «Запустить всё» / «Остановить всё» — массовый старт и кооперативная отмена
(флаг `cancel_requested`, воркер останавливается сам на ближайшем тике; уже
начатый прогон не рвём посреди записи в базу).
- **Панель отладки** (админка → «Отладка», `GET /api/admin/debug` + `/health`):
один срез — доступность инфраструктуры (PostgreSQL/Redis/MinIO/ES/Ollama/
брокер: этот блок показывается даже когда сам срез не собирается, потому что
при аварии первый вопрос — что именно отвалилось),
живые воркеры Celery и что именно они крутят, глубина очередей RabbitMQ
(в т.ч. `unacked` и число потребителей), покрытие корпуса эмбеддингами,
активные и проблемные прогоны, зависшие прогоны (нет heartbeat >10 мин),
упавшие проверки за сутки и текущие бэкенды (`EMBED_BACKEND`/`LLM_BACKEND`/
`VECTOR_BACKEND`).
- **Бюджет времени прогона** (`PARSER_TIME_BUDGET_S`, по умолчанию 1500с) —
защита от краш-лупа по `consumer_timeout` RabbitMQ, см. [DR-HA.md](DR-HA.md) §6.
- **Покрытие L3 меряется по индексу, а не по БД.** Колонка `documents.faiss_id`
для этого непригодна: отметка остаётся после пересоздания индекса (смена
модели/размерности) и после сбоев worker-gpu. Панель отладки спрашивает
реальное число векторов задачей `gpu.index_stats` и отдельно предупреждает о
ложных отметках; чинит их
[`scripts/ops/faiss_reconcile.py`](../scripts/ops/faiss_reconcile.py).
- **Добор эмбеддингов** — [`scripts/ops/reembed_missing.py`](../scripts/ops/reembed_missing.py):
документ попадает в корпус сразу, а вектор для L3 считает отдельная задача
`gpu.embed_documents`; если worker-gpu или Ollama были недоступны, эти задачи
теряются и документ остаётся невидимым для семантического поиска. Скрипт
находит `faiss_id IS NULL` и переотправляет задачи пачками (dry-run по
умолчанию). Покрытие видно в панели отладки.
## 13. Безопасность
@@ -182,9 +291,9 @@ origins. Пользователь видит только свои задачи
```
services/ api, worker-{gpu,indexer,notifier,gost}, frontend
scripts/ parsers/ (OpenAlex/arXiv/КиберЛенинка), ops/, deploy.sh,
run_tests.sh, run_lint.sh
infra/ nginx/, prometheus/, grafana/
scripts/ parsers/ (OpenAlex/arXiv/PMC/КиберЛенинка), seed_*.py, ops/,
deploy.sh, run_tests.sh, run_lint.sh
infra/ nginx/, prometheus/, grafana/, singbox/ (VPN-прокси для OpenRouter)
docs/ ARCHITECTURE.md (этот файл), DR-HA.md
.gitea/workflows/ deploy.yml (гейт test → deploy)
ruff.toml · mypy.ini · Makefile · docker-compose.prod.yml

View File

@@ -31,7 +31,9 @@ flowchart TB
PG[("PostgreSQL 1.38<br/>users/tasks/documents/<br/>fingerprints/...")]
REDIS[("Redis 1.35<br/>кэш · rate-limit · LSH-индекс")]
MINIO[("MinIO 1.21<br/>документы · full-text · бэкапы")]
OLLAMA["Ollama .163<br/>qwen2.5:7b"]
OLLAMA["Ollama 1.40 embedding-cpu<br/>bge-m3, эмбеддинги (L3)"]
OPENROUTER["OpenRouter/DeepSeek<br/>LLM L4 (опц., через singbox-proxy)"]
INFISICAL["Infisical .111<br/>секреты → .env на каждом деплое"]
end
subgraph obs["Опционально (профили compose)"]
@@ -56,10 +58,12 @@ flowchart TB
IDX -->|"gpu.embed_documents"| MQ
GPU <-->|"documents"| PG
GPU -->|"L3 эмбеддинги"| OLLAMA
GPU --> VEC
VEC --> FAISS
VEC -.->|"переключение флагом"| QDR
GPU -->|"L4 парафраз"| OLLAMA
GPU -.->|"L4 парафраз (LLM_BACKEND)"| OLLAMA
GPU -.->|"L4 парафраз, опц."| OPENROUTER
GPU --> ES
GOST <-->|"documents"| PG
@@ -81,7 +85,7 @@ sequenceDiagram
participant API as API
participant IDX as worker-indexer
participant GPU as worker-gpu
participant LLM as Ollama
participant LLM as Ollama/OpenRouter<br/>(LLM_BACKEND)
U->>API: upload файла
API->>API: сохранить в MinIO, создать Task, commit
@@ -103,6 +107,7 @@ sequenceDiagram
## Легенда
- Сплошные стрелки — прямые вызовы/запросы; пунктир у `VEC` — переключение бэкенда
по настройке `VECTOR_BACKEND`, не одновременная работа обоих.
по настройке `VECTOR_BACKEND`, не одновременная работа обоих. Пунктир у L4
(`OLLAMA`/`OPENROUTER`) — то же самое для `LLM_BACKEND`.
- `obs` (Prometheus/Grafana) и `Qdrant` — опциональны, поднимаются под
`docker compose --profile qdrant|observability`, по умолчанию выключены.

View File

@@ -30,7 +30,9 @@ bash scripts/ops/pg_restore_verify.sh # креды из .env
2. **Replica**: `pg_basebackup -h 1.38 -U replicator -D $PGDATA -R` (создаёт
`standby.signal` + `primary_conninfo`), затем старт — догоняет primary по WAL.
3. **Failover**: ручной `pg_ctl promote` на реплике + переключение `POSTGRES_HOST`
в `.env` (или автоматизация через Patroni + etcd — если нужен авто-failover).
в Infisical (окружение `prod` — не в `.env` на сервере напрямую, его перезапишет
следующий деплой, см. ARCHITECTURE.md §10) и передеплой/рестарт сервисов
(или автоматизация через Patroni + etcd — если нужен авто-failover).
4. Проверка лага: `SELECT * FROM pg_stat_replication` на primary.
## 4. HA Redis — реплика + Sentinel (требует новый LXC)
@@ -53,3 +55,47 @@ Redis у нас — кэш/rate-limits/LSH-индекс (префикс `antipla
| Векторный индекс | было | `VECTOR_BACKEND=qdrant` снимает (см. README) ✅ |
| RabbitMQ .82 | да | мониторинг ловит падение ✅; кластер — по потребности |
| app/ES 1.32 | да | воркеры горизонтальны; ES single-node (для BM25 не критично) |
| Хост Proxmox .254 | **да, широкий** | эмбеддинги вынесены на pve2 ✅; брокер/прокси/секреты — нет, см. ниже |
**Гипервизор — самая широкая единая точка отказа.** На хосте `192.168.20.254`
одновременно живут RabbitMQ (.82), Infisical (.111) и CT 102 (.253) — фронтенд
и реверс-прокси. С 31.08.2026 эмбеддинги отсюда вынесены: сервер `embedding-cpu`
(192.168.1.40) стоит на другом физическом хосте (pve2, .1.37), так что падение
.254 больше не уносит с собой L3. Его падение по-прежнему снимает сразу: приём
и обработку задач (нет брокера), деплой (нет Infisical и Gitea) и весь публичный
доступ (нет прокси) — при том что API, PostgreSQL, MinIO и сервер эмбеддингов
продолжают работать. Проверено на практике 2026-08-28: хост перестал
отвечать даже на ARP, всё перечисленное отвалилось разом, данные не пострадали.
Разнести хотя бы прокси/брокер по разным физическим хостам — самая дешёвая
мера; пока её нет, восстановление требует физического доступа к железу.
## 6. Известный операционный риск — RabbitMQ `consumer_timeout` vs долгие таски
Обнаружено на практике (2026-08-26): RabbitMQ по умолчанию рвёт канал, если consumer
не сделал ack за 1800с. Любой Celery-таск с retry-логикой (rate-limit backoff у
парсеров, ожидание зависшего внешнего сервиса вроде Ollama), суммарно занимающий
дольше — брокер обрывает соединение раньше, чем таск успевает сдаться и
подтвердиться, весь Celery-процесс падает (`exitCode=1`), Docker
(`restart: unless-stopped`) поднимает его заново, недоставленное сообщение
передоставляется — и цикл повторяется бесконечно, монополизируя весь пул воркера
(было и на `worker-indexer` из-за backoff `scripts/parsers/openalex.py`, и на
`worker-gpu` из-за зависшей Ollama на прежнем embedding-gpu; на новом сервере
эмбеддингов зависание закрыто сторожевым таймером на самой машине).
Митигации (2026-08-27, сделано для `worker-indexer`):
1. **Бюджет времени прогона** — `PARSER_TIME_BUDGET_S` (1500с) в
`worker-indexer/app/config.py`: `index.run_parser` сам останавливается раньше
дедлайна и помечает прогон `partial` вместо того, чтобы довести воркер до
падения. Остаток дозаливается повторным запуском источника.
2. **`worker_prefetch_multiplier=1`** (`worker-indexer/app/celery_app.py`) —
ключевое при массовой заливке. Таймаут отсчитывается от **доставки**
сообщения, а не от начала выполнения: с дефолтным префетчем (4×concurrency)
сотни поставленных в очередь долгих `run_parser` висят unacked и убивают
канал на задачах, которые ещё даже не начинались.
3. Retry-бэкоффы держим заведомо короче 1800с (`MAX_RATE_LIMIT_RETRIES` в
`openalex.py`).
`worker-gpu` этих защит пока не имеет (там нет своих долгих retry-циклов, но
есть зависание внешней Ollama — см. выше). Более глубокий общий фикс — поднять
`consumer_timeout` на самом RabbitMQ (доступа для этого пока не заводили).

View File

@@ -1,11 +1,106 @@
# Наполнение корпуса — runbook
## Текущее состояние (на 2026-08-12)
## Текущее состояние (на 2026-08-31)
- ~**42K документов**, из них **99.4% английские, 0 русских** (см. `documents`).
- Заливка **встала 2026-08-07**. Корпус — seed из ~30 английских тем OpenAlex/arXiv.
- Для сервиса под русских студентов это главный дефект: русские работы проверять
не с чем.
- **177 247 документов**, русский большинство: `ru` 99 884, `en` 77 036,
остальные языки — единицы/десятки. Проблема «не с чем сравнивать русские
работы» из более ранней версии этого документа закрыта.
- По источникам: CyberLeninka 99 883, OpenAlex 49 276, PMC 15 010, arXiv 13 077,
`user_submission` (проверенные пользователями работы, не источник для сравнения
сами с собой — см. ARCHITECTURE.md §7) 1.
- Повторный прогон по уже залитым источникам даёт почти одни дубли (типично
1490 из 1500 на источник): прирост дают только новые публикации. Реальный
рост корпуса — поднятый `limit` или новые темы, а не повторный запуск.
- OpenAlex при массовом запуске упирается в лимит вежливого пула (10 req/s
на mailto, общий для всех воркеров) — пауза между страницами поднята до 1с.
- Добавлен 4-й парсер — **PMC** (PubMed Central, `scripts/parsers/pmc.py`),
англоязычные научные статьи открытого доступа.
- Массовое расширение по дисциплинам теперь двумя сидерами: `seed_ru_sources.py`
(русскоязычные, CyberLeninka/OpenAlex `lang=ru`) и **`scripts/seed_broad_corpus.py`**
(англоязычные — OpenAlex/arXiv/PMC по широкому списку дисциплин, добавлен позже
первой волны).
- Операционный риск массовой докачки (`consumer_timeout` RabbitMQ vs долгие таски)
закрыт бюджетом времени прогона и `worker_prefetch_multiplier=1` — подробности
и почему префетч тут главный, см. [DR-HA.md](DR-HA.md) §6.
### Глубина индексации — чем реально располагает детекция (замер 2026-08-28)
Мерить глубину по `minio_key` (сохранён ли текст в MinIO) **нельзя**: PMC,
например, кладёт тело статьи в отпечатки прямо при заливке, не сохраняя файл.
Честный показатель — **число отпечатков на документ**: аннотация даёт десятки,
полный текст — тысячи.
| Источник | Документов | Глубоко (≥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 отпечатков).
**Но массовый прогон упирается в защиту сайта.** Замер 2026-08-28: первые ~130
статей скачались штатно, дальше КиберЛенинка перестала отдавать PDF и начала
возвращать HTML-заглушку ~5.7 КБ — счётчик успехов замер на 122, скрипт работал
вхолостую. Расчёт «48 часов на 100 тысяч при 1 req/s» этим опровергнут. Чтобы
углубить русскую часть корпуса, нужен другой подход: заметно большие паузы,
разные исходящие адреса или договорённость с источником.
Покрытие L3: 60 993 документа числились векторизованными ошибочно — отметки
сброшены `faiss_reconcile.py`, после чего 31.08 запущен пересчёт всех 84 094
документов без вектора. Считает новый сервер эмбеддингов (192.168.1.40,
3.6 док/с против 2.2 у прежнего), полный проход занимает около 6.5 часов.
### Источники русского текста — что проверено (31.08.2026)
| Источник | Объём | Годен для массовой заливки |
|----------|-------|----------------------------|
| **Википедия ru** | дамп 5.6 ГБ, ~2 млн статей | **да** — тип источника `wikipedia_ru`, ~919 отпечатков на статью. Дамп качается заранее (Wikimedia отдаёт 403 без осмысленного User-Agent и рвёт долгие потоковые соединения) |
| КиберЛенинка | ~3 млн статей | нет — блокирует выкачку PDF после ~130 запросов |
| OpenAlex `language:ru` + OA | заявлено 395 410 | практически нет — по прямым `pdf_url` скачалось 2 из 10 (остальное 403 издателей), а метка языка ненадёжна: в выдаче попадаются англоязычные журналы |
| eLIBRARY.RU (РИНЦ) | ~40 млн | только по договору, публичной выгрузки нет |
| Math-Net.Ru | российские матжурналы | архив отдаёт 403 |
| НЭБ, BASE, вузовские DSpace | — | открыты, механизм выгрузки не проверялся |
Википедия формально не научный источник, но студенты копируют из неё чаще всего,
а по объёму связного русского текста ей нет альтернативы среди доступного.
### Массовые источники — тот же конвейер, что и обычные
Постраничные API дают 1-2 статьи в секунду и упираются в rate limit — миллионы
так не залить. Для объёма есть два типа источника, которые читают дамп или
бакет потоком:
| Тип | Откуда | Особенность |
|-----|--------|-------------|
| `wikipedia_ru` | локальный дамп `scripts/parsers/ruwiki.xml.bz2` | позиция продолжения — номер статьи в дампе |
| `pmc_bulk` | бакет `pmc-oa-opendata` (открыт, ключ не нужен) | позиция — токен страницы бакета; у каждой статьи готовый извлечённый текст |
Заводятся и запускаются они как любой другой источник — через админку, и точно
так же показывают шкалу, журнал и кнопку остановки. Отличия внутри:
- парсер отдаёт **генератор**, а не список: миллионы статей в память не влезут;
- запись идёт пачками через `COPY` (`worker-indexer/app/bulk_writer.py`) —
построчная вставка даёт 6.7 тыс. строк/с против 21 тыс. у COPY, а миллион
статей это ~2 млрд отпечатков;
- **эмбеддинги при заливке не считаются**: они медленнее заливки и сделали бы её
узким местом. L1 и L2 работают сразу, векторы досчитываются потом
(`scripts/ops/reembed_missing.py`);
- позиция продолжения хранится в `parse_sources.resume_token`, поэтому источник
запускается повторно до исчерпания — каждый прогон берёт следующую порцию и
укладывается в бюджет времени таска.
Планировать объём: 1 млн статей ≈ 2 млрд отпечатков ≈ 200 ГБ в базе с индексами.
При таком росте индексы перестают помещаться в память сервера БД — проверено на
практике: поиск L1 деградировал с 31 мс до 484 мс, пока не увеличили RAM и
`shared_buffers` (см. DR-HA.md).
## Что подготовлено
@@ -20,17 +115,52 @@
## Запуск русской заливки
```bash
# 1. (на app-хосте / в контейнере worker-indexer, где есть psycopg2 и прод-.env)
# 1. (на app-хосте / в контейнере worker-indexer, где есть psycopg2 и прод-.env
# — .env на проде теперь генерируется из Infisical на каждом деплое, см.
# ARCHITECTURE.md §10, руками его не редактировать)
# Посмотреть план:
python scripts/seed_ru_sources.py
# Создать источники в parse_sources (лимит на дисциплину):
python scripts/seed_ru_sources.py --apply --limit 500
# 2. Проверить пару источников на темпе/качестве, затем запустить заливку:
# • Админ-панель → «Источники» → «Запустить», ЛИБО
# • Админ-панель → «Источники» → «Запустить» (или «Запустить всё»), ЛИБО
# • Celery: index.run_parser.delay(source_id) по каждому id
```
## Управление заливкой из админки
Всё, что ниже, доступно на странице «Источники» — CLI для этого больше не нужен:
- **Шкала загрузки** у каждого источника: стадия (выборка → индексация), сколько
получено из скольки, сколько добавлено/дублей/ошибок. Раскрытая строка —
журнал прогона по шагам с таймингами (таблица `parse_runs`).
- **«Запустить всё»** — прогон по всем включённым источникам; уже идущие
пропускаются. **«Остановить всё»** и остановка по одному — кооперативная
отмена: воркер останавливается сам на ближайшем тике, не обрывая запись в базу.
- **Пакетное добавление** — один тип источника + список тем (по строке),
опционально с немедленным запуском. Заменяет `seed_*.py` для разовых расширений.
- **Загрузка работ в базу** — PDF/DOCX/TXT прямо в корпус сравнения
(`index.ingest_upload`, `source=manual_upload`), минуя проверку и отстойник.
- **Страница «Отладка»** — очереди, воркеры, покрытие эмбеддингами, зависшие и
упавшие прогоны (см. ARCHITECTURE.md §12).
После большой заливки — свериться с покрытием L3 (панель отладки, «Векторов в
индексе»). Порядок именно такой, из двух шагов:
1. `scripts/ops/faiss_reconcile.py` (в контейнере worker-gpu) — сверяет отметки
`faiss_id` с реальным содержимым индекса и обнуляет ложные. Без этого шага
документы, потерявшие вектор при пересоздании индекса, не попадут на
пересчёт: они всё ещё «отмечены».
2. `scripts/ops/reembed_missing.py` (в контейнере worker-indexer) — отправляет
`gpu.embed_documents` для всех `faiss_id IS NULL`.
Оба по умолчанию dry-run, отправляют/меняют только с `--apply`.
Прогон со статусом `partial` — это не ошибка: сработал бюджет времени
(`PARSER_TIME_BUDGET_S`, 1500с), заливка остановилась раньше `consumer_timeout`
RabbitMQ. Остаток добирается повторным запуском источника.
Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`,
fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем ×
500 ≈ 15K русских документов на первый заход.
@@ -46,7 +176,11 @@ SELECT source, count(*) FROM documents WHERE source='cyberleninka'; -- > 0
- Больше тем + выше `--limit`; добавить OpenAlex `lang=ru` (качество ниже — англ.
заголовки с меткой ru).
- Для миллионов — **bulk** (снапшот OpenAlex на S3), а не постраничный API.
- Для миллионов — **bulk**, а не постраничный API. Снапшот OpenAlex для этого не
годится: там только метаданные, а миллионы аннотаций детекции не дают (см.
провал КиберЛенинки выше). Рабочий источник полных текстов — бакет
`pmc-oa-opendata`, см. «Массовая заливка» выше.
- На масштабе обязателен `VECTOR_BACKEND=qdrant` (FAISS flat не тянет), а таблица
`fingerprints` (уже ~29M строк на 42K доков) потребует партиционирования. См.
`fingerprints` (уже ~113M строк на 177K доков — партиционирование стоит планировать
заранее, не постфактум) потребует партиционирования. См.
[ARCHITECTURE.md](ARCHITECTURE.md) и [DR-HA.md](DR-HA.md).

View File

@@ -0,0 +1,39 @@
{
"log": {
"level": "warn"
},
"inbounds": [
{
"type": "socks",
"tag": "socks-in",
"listen": "0.0.0.0",
"listen_port": 1080
}
],
"outbounds": [
{
"type": "vless",
"tag": "proxy-out",
"server": "CHANGE_ME.example.com",
"server_port": 443,
"uuid": "CHANGE_ME-uuid",
"packet_encoding": "xudp",
"tls": {
"enabled": true,
"server_name": "CHANGE_ME.example.com",
"alpn": ["http/1.1"],
"utls": {
"enabled": true,
"fingerprint": "chrome"
}
},
"transport": {
"type": "ws",
"path": "/api/stream"
}
}
],
"route": {
"final": "proxy-out"
}
}

View File

@@ -5,7 +5,10 @@
# Запускается Gitea Actions runner'ом в host-режиме прямо на app-хосте 1.32
# (см. .gitea/workflows/deploy.yml). Ожидает:
# - cwd = ПОЛНЫЙ чекаут репозитория (нужен git-лог для diff);
# - переменную PVE_PASSWORD (секрет) для доступа к Proxmox-хосту → CT 102.
# - переменную PVE_PASSWORD (секрет) для доступа к Proxmox-хосту → CT 102;
# - INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET (Machine Identity
# "claude-ai", Universal Auth) — .env на проде теперь генерируется из
# Infisical (prod) на каждом деплое, а не правится руками на сервере.
#
# Пересобирается только то, чей код изменился с прошлого деплоя (маркер SHA в
# $APP/.last_deploy_sha). Это критично: worker-gpu тянет torch/faiss (~2 ГБ),
@@ -40,6 +43,7 @@ fi
REBUILD=""
FRONTEND_CHANGED=0
PARSERS_CHANGED=0
if [ "$FULL" = 1 ]; then
REBUILD="$ALL_BACKEND"
FRONTEND_CHANGED=1
@@ -48,10 +52,50 @@ else
echo "$CHANGED" | grep -qE "^services/$svc/" && REBUILD="$REBUILD $svc"
done
echo "$CHANGED" | grep -qE '^services/frontend/' && FRONTEND_CHANGED=1
# Парсеры не вшиты в образ, а смонтированы в worker-indexer (см. compose),
# поэтому пересобирать нечего. Но процесс воркера держит модуль парсера в
# памяти с прошлого прогона: без перезапуска правка молча не применяется —
# заливка продолжает ходить по старому коду, и это не видно ниоткуда, кроме
# тела запросов в логах. Ловилось на CORE 16.09.2026.
echo "$CHANGED" | grep -qE '^scripts/parsers/' && PARSERS_CHANGED=1
fi
REBUILD="$(echo "$REBUILD" | xargs || true)"
echo "==> [1/5] Синхронизация кода в $APP (сохраняя .env и compose)"
echo "==> [1/6] Секреты из Infisical (prod) → .env"
INFISICAL_BIN="$APP/.infisical-cli"
if [ ! -x "$INFISICAL_BIN" ]; then
echo " ставлю infisical CLI (разово)"
curl -sSL -o /tmp/infisical-cli.tar.gz \
"https://github.com/Infisical/infisical/releases/download/infisical-cli%2Fv0.31.1/infisical_0.31.1_linux_amd64.tar.gz"
tar xzf /tmp/infisical-cli.tar.gz -C /tmp infisical
mv /tmp/infisical "$INFISICAL_BIN"
rm -f /tmp/infisical-cli.tar.gz
fi
INFISICAL_DOMAIN=https://infisical.jze9.ru/api
INFISICAL_PROJECT_ID=a2e7505e-573f-43e9-872b-4ed7a6bed7b8
INFISICAL_TOKEN="$("$INFISICAL_BIN" login --method=universal-auth \
--client-id="${INFISICAL_CLIENT_ID:?INFISICAL_CLIENT_ID не задан}" \
--client-secret="${INFISICAL_CLIENT_SECRET:?INFISICAL_CLIENT_SECRET не задан}" \
--domain="$INFISICAL_DOMAIN" --plain --silent)"
TMP_ENV="$(mktemp)"
"$INFISICAL_BIN" export --token="$INFISICAL_TOKEN" --domain="$INFISICAL_DOMAIN" \
--projectId="$INFISICAL_PROJECT_ID" --env=prod --format=dotenv --expand=false \
--silent > "$TMP_ENV"
# Не затирать рабочий .env, если Infisical вернул мало строк (недоступен/пуст) —
# иначе следующий шаг раскатит прод с пустым конфигом.
ENV_LINES="$(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$TMP_ENV" || true)"
if [ "$ENV_LINES" -lt 20 ]; then
echo "ОШИБКА: Infisical export вернул только $ENV_LINES строк (ожидалось ~38) — сервис недоступен или пуст. Деплой прерван, .env на проде не тронут."
rm -f "$TMP_ENV"
exit 1
fi
mv "$TMP_ENV" "$APP/.env"
echo " OK: $ENV_LINES секретов"
echo "==> [2/6] Синхронизация кода в $APP (сохраняя .env и compose)"
rsync -a \
--exclude='__pycache__/' --exclude='*.pyc' --exclude='.pytest_cache/' \
--exclude='node_modules/' --exclude='dist/' \
@@ -59,25 +103,40 @@ rsync -a \
cd "$APP"
if [ -n "$REBUILD" ]; then
echo "==> [2/5] Пересборка и перезапуск: $REBUILD"
echo "==> [3/6] Пересборка и перезапуск: $REBUILD"
# shellcheck disable=SC2086
$COMPOSE up -d --build $REBUILD
else
echo "==> [2/5] Бэкенд-сервисы не менялись — пропуск сборки"
echo "==> [3/6] Бэкенд-сервисы не менялись — пропуск сборки"
fi
echo "==> [3/5] Миграции БД (с ретраем)"
# Секреты в .env могли поменяться без изменения кода (правка в Infisical) —
# docker compose сам не пересоздаст контейнер, чей env_file изменился, если
# просто повторно не позвать up -d. Дёшево и идемпотентно: compose пересоздаёт
# только контейнеры с реально изменившимся конфигом, остальные не трогает.
echo "==> [4/6] Применение секретов к неизменившимся сервисам (если менялись)"
# shellcheck disable=SC2086
$COMPOSE up -d $ALL_BACKEND
# Правка парсера доезжает монтированием, но модуль уже загружен в память
# воркера — перезапускаем, если образ и так не пересобирался выше.
if [ "$PARSERS_CHANGED" = 1 ] && ! echo " $REBUILD " | grep -q " worker-indexer "; then
echo "==> [4.5/6] Парсеры менялись — перезапуск worker-indexer"
$COMPOSE restart worker-indexer
fi
echo "==> [5/6] Миграции БД (с ретраем)"
for i in $(seq 1 10); do
$COMPOSE exec -T api alembic upgrade head && break
echo " api ещё не готов, повтор $i/10..."; sleep 5
done
if [ "$FRONTEND_CHANGED" = 1 ]; then
echo "==> [4/5] Сборка фронтенда"
echo "==> [6/6] Сборка фронтенда"
docker run --rm -v "$APP/services/frontend":/app -w /app node:20-slim \
sh -c "npm install --no-audit --no-fund --loglevel=error && npm run build"
echo "==> [5/5] Выкладка статики на CT 102 (через $PVE_HOST)"
echo "==> [6/6] Выкладка статики на CT 102 (через $PVE_HOST)"
cd "$APP/services/frontend/dist"
tar czf /tmp/academic-dist.tgz .
export SSHPASS="${PVE_PASSWORD:?PVE_PASSWORD не задан}"
@@ -102,7 +161,7 @@ rm -f /tmp/academic-dist.tgz
REMOTE
rm -f /tmp/academic-dist.tgz
else
echo "==> [4-5/5] Фронтенд не менялся — пропуск сборки и выкладки"
echo "==> [6/6] Фронтенд не менялся — пропуск сборки и выкладки"
fi
echo "$CUR_SHA" > "$MARKER"

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Health-монитор anti-plagiarism: письмо при СМЕНЕ статуса сервиса, без спама.
Запускается по cron на app-хосте (1.32) каждые 5 минут:
*/5 * * * * /usr/bin/python3 /home/user/antiplag_monitor.py >> ~/.antiplag-monitor/run.log 2>&1
Живёт в репозитории намеренно: прошлая версия лежала только на сервере, ничем
не версионировалась и месяц проверяла топологию, которой уже нет — вечный
ложный DOWN на остановленном CT 108 и полная слепота к реальному серверу
эмбеддингов. Постоянный ложный сигнал хуже отсутствия сигнала: на его фоне
теряется настоящая авария.
Что проверяется и почему именно это:
- API, PostgreSQL, Redis, RabbitMQ, MinIO, Elasticsearch — без них сервис не
принимает и не обрабатывает работы;
- Ollama на 1.40 — эмбеддинги для L3 (адрес брать из .env, а не хардкодить:
он уже дважды переезжал);
- воркеры Celery — самое важное дополнение: 05.09 брокер лежал час, воркеры
молчали, а прежний монитор рапортовал, что всё хорошо, потому что смотрел
только на TCP-порты.
OpenRouter (L4) намеренно не проверяется: это внешний сервис за VPN-прокси, его
недоступность не ломает основную проверку — L4 лишь уточняет вердикт.
"""
import json
import os
import smtplib
import socket
import ssl
import subprocess
import urllib.request
from datetime import datetime
from email.mime.text import MIMEText
APP_DIR = os.environ.get("APP_DIR", "/home/user/anti-plagiarism")
STATE_FILE = os.path.expanduser("~/.antiplag-monitor/state.json")
SMTP_HOST, SMTP_PORT = "mail.jze9mail.ru", 587
SMTP_USER = os.environ.get("MONITOR_SMTP_USER", "noreply")
SMTP_PASS = os.environ.get("MONITOR_SMTP_PASS", "kGy3vgt6EuiUBMNDmV01Ng")
SMTP_FROM = "noreply@jze9mail.ru"
ALERT_TO = os.environ.get("MONITOR_ALERT_TO", "jze9programer@gmail.com")
def env_value(key: str, default: str = "") -> str:
"""Значение из прод-.env (он генерируется из Infisical на каждом деплое).
Адреса инфраструктуры читаем оттуда, а не хардкодим: сервер эмбеддингов уже
переезжал дважды, и монитор каждый раз оставался со старым адресом.
"""
path = os.path.join(APP_DIR, ".env")
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip("'\"")
except Exception:
pass
return default
def host_port_from_url(url: str, default_port: int) -> tuple[str, int]:
"""Разобрать URL в пару (host, port), отбросив логин с паролем.
В REDIS_URL и RABBITMQ_URL креды идут перед адресом
(`redis://:пароль@host:port/0`), и без их отсечения разбор падает.
"""
rest = url.split("//", 1)[-1].split("/", 1)[0]
rest = rest.rpartition("@")[2] or rest
if ":" in rest:
host, _, port = rest.partition(":")
return host, int(port or default_port)
return rest, default_port
def check_tcp(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=5):
return True
except Exception:
return False
def check_http(url: str) -> bool:
try:
with urllib.request.urlopen(url, timeout=8) as r:
return r.status == 200
except Exception:
return False
def check_in_api_container(command: str) -> bool:
"""Проверка изнутри контейнера — так же, как это видит приложение."""
try:
out = subprocess.run(
["docker", "exec", "antiplagiator-api", "sh", "-c", command],
capture_output=True, text=True, timeout=20,
)
return out.stdout.strip() == "200"
except Exception:
return False
def check_celery_workers() -> bool:
"""Отвечают ли воркеры на ping.
Живой брокер ещё не значит работающую систему: воркер может не суметь к
нему подключиться и молча простаивать — именно так и было 05.09.
"""
try:
out = subprocess.run(
["docker", "exec", "antiplagiator-api", "python", "-c",
"from app.core.celery_app import celery_app;"
"print(len(celery_app.control.inspect(timeout=5).ping() or {}))"],
capture_output=True, text=True, timeout=30,
)
return int(out.stdout.strip() or 0) >= 4 # api ждёт 4 воркера
except Exception:
return False
def build_checks() -> list[tuple[str, callable]]:
"""Список проверок; адреса — из прод-.env, чтобы не разъезжаться с реальностью."""
pg_host = env_value("POSTGRES_HOST", "192.168.1.38")
redis_host, redis_port = host_port_from_url(
env_value("REDIS_URL", "redis://192.168.1.35:6379/0"), 6379)
minio_host, minio_port = host_port_from_url(
"http://" + env_value("MINIO_ENDPOINT", "192.168.1.21:9000"), 9000)
ollama_url = env_value("OLLAMA_URL", "http://192.168.1.40:11434")
rabbit_host, rabbit_port = host_port_from_url(
env_value("RABBITMQ_URL", "amqp://guest:guest@192.168.20.82:5672/"), 5672)
return [
("API", lambda: check_http("http://localhost:8000/health")),
("PostgreSQL", lambda: check_tcp(pg_host, 5432)),
("Redis", lambda: check_tcp(redis_host, redis_port)),
("RabbitMQ", lambda: check_tcp(rabbit_host, rabbit_port)),
("MinIO", lambda: check_tcp(minio_host, minio_port)),
("Elasticsearch", lambda: check_in_api_container(
"curl -s -o /dev/null -w '%{http_code}' http://elasticsearch:9200")),
("Embeddings", lambda: check_http(f"{ollama_url}/api/version")),
("CeleryWorkers", check_celery_workers),
]
def send_alert(subject: str, body: str) -> None:
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = ALERT_TO
ctx = ssl.create_default_context()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=25) as s:
s.ehlo()
s.starttls(context=ctx)
s.ehlo()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
def main() -> None:
results = {name: fn() for name, fn in build_checks()}
prev: dict = {}
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE) as fh:
prev = json.load(fh)
except Exception:
prev = {}
changes = []
for name, ok in results.items():
was = prev.get(name, True) # первый запуск считаем нормой
if was and not ok:
changes.append(f"УПАЛ: {name}")
elif not was and ok:
changes.append(f"восстановился: {name}")
if changes:
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
status = "\n".join(f" {'OK ' if v else 'DOWN'} {k}" for k, v in results.items())
body = (f"Изменения статуса сервисов anti-plagiarism ({ts}):\n\n"
+ "\n".join(changes) + "\n\nТекущий статус:\n" + status)
subject = f"[anti-plagiarism] {changes[0]}"
if len(changes) > 1:
subject += f" (+{len(changes) - 1})"
try:
send_alert(subject, body)
except Exception as e:
print(f"не удалось отправить алерт: {e}")
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as fh:
json.dump(results, fh)
line = " ".join(f"{k}={'OK' if v else 'DOWN'}" for k, v in results.items())
print(datetime.now().strftime("%H:%M"), "|", line)
if __name__ == "__main__":
main()

View File

@@ -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()

View File

@@ -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()

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Замер качества детекции на реальном корпусе: что находим и что выдумываем.
Зачем: до сих пор о качестве проверки мы знали только «механизм жив» — находит
подброшенный фрагмент. Этого мало. Продукт продаёт процент заимствований, и
нужно знать две вещи: сколько списываний система пропускает (полнота) и как
часто обвиняет невиновных (точность). Без этих чисел непонятно, что улучшать —
пороги, глубину корпуса или алгоритм.
Как устроен замер. Берём документы из самого корпуса и делаем из них
«студенческие работы» четырёх видов:
дословно — фрагмент скопирован как есть (обязан находиться);
лёгкий рерайт — выброшено каждое 10-е слово (обязан находиться);
сильный рерайт— выброшено каждое 3-е слово (L1 не обязан; это работа L3/L4);
оригинал — текст, которого в корпусе нет (обязан НЕ находиться).
Считается путь L1 — тот же, что в index.extract_and_check: фрагментация,
winnowing по каждому фрагменту, поиск по отпечаткам с порогом
EXACT_FRAGMENT_THRESHOLD. Уровни L3/L4 сюда не входят намеренно: они требуют
GPU-воркера и на порядок медленнее, а мерить нужно в первую очередь основу.
Запуск (в контейнере worker-indexer):
cd /home/user/anti-plagiarism
docker compose -f docker-compose.prod.yml exec -T worker-indexer python - \\
< scripts/ops/detection_benchmark.py
... python - --docs 40 < scripts/ops/detection_benchmark.py
Ничего не пишет в базу — только читает.
"""
import argparse
import random
def make_cases(text: str, words_taken: int) -> dict[str, str]:
"""Сделать из текста источника четыре «студенческие работы»."""
words = text.split()
start = len(words) // 3
chunk = words[start : start + words_taken]
return {
"дословно": " ".join(chunk),
"лёгкий рерайт": " ".join(w for i, w in enumerate(chunk) if i % 10),
"сильный рерайт": " ".join(w for i, w in enumerate(chunk) if i % 3),
}
def original_text(rnd: random.Random, words_taken: int) -> str:
"""Текст, которого заведомо нет в корпусе — проверка на ложные обвинения."""
topics = [
"вчера вечером я варил гречку с грибами и вспоминал поездку на озеро",
"мой сосед купил подержанный велосипед и красит его в оранжевый цвет",
"бабушка печёт пироги с капустой по субботам и зовёт всех соседей",
"кот запрыгнул на подоконник и уронил горшок с геранью на пол",
"в субботу мы чинили забор и обсуждали цены на доски и гвозди",
]
out: list[str] = []
while len(out) < words_taken:
out.extend(rnd.choice(topics).split())
return " ".join(out[:words_taken])
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--docs", type=int, default=25, help="сколько документов взять для замера")
ap.add_argument("--words", type=int, default=400, help="сколько слов «списывать»")
ap.add_argument("--seed", type=int, default=20260905, help="зерно случайности (для повторяемости)")
args = ap.parse_args()
from app.algorithms.winnowing import winnow
from app.config import settings
from app.db import db_session, get_minio
from app.fragments import split_into_fragments
from app.models import Document, Fingerprint
from sqlalchemy import func, select
from sqlalchemy import text as sql_text
rnd = random.Random(args.seed)
# Берём документы с реальной глубиной: по аннотации в 30 отпечатков мерить
# нечего, и такой замер сказал бы больше о корпусе, чем об алгоритме
with db_session() as s:
rows = s.execute(sql_text("""
SELECT d.id, d.source, d.minio_key, d.abstract
FROM documents d
JOIN (SELECT doc_id, count(*) c FROM fingerprints
GROUP BY doc_id HAVING count(*) > 800) f ON f.doc_id = d.id
WHERE d.source <> 'user_submission'
ORDER BY random() LIMIT :n
"""), {"n": args.docs}).all()
print(f"документов в замере: {len(rows)}, «списываем» по {args.words} слов")
print(f"порог срабатывания фрагмента: {settings.EXACT_FRAGMENT_THRESHOLD}%\n")
def find_source(work_text: str) -> set[int]:
"""Прогнать «работу» через L1 и вернуть найденные документы-источники."""
found: set[int] = set()
frags = split_into_fragments(
work_text,
window=settings.FRAGMENT_WINDOW_WORDS,
overlap=settings.FRAGMENT_OVERLAP_WORDS,
)
with db_session() as s:
for frag in frags:
fp = winnow(frag["text"])
if not fp:
continue
hit = s.execute(
select(Fingerprint.doc_id, func.count(Fingerprint.id).label("c"))
.join(Document, Document.id == Fingerprint.doc_id)
.where(
Fingerprint.hash_value.in_(list(fp)),
Document.source != "user_submission",
)
.group_by(Fingerprint.doc_id)
.order_by(func.count(Fingerprint.id).desc())
.limit(1)
).first()
if hit and hit[1] / len(fp) * 100 >= settings.EXACT_FRAGMENT_THRESHOLD:
found.add(hit[0])
return found
stats: dict[str, dict[str, int]] = {}
minio = get_minio()
for doc_id, _source, minio_key, abstract in rows:
# Полный текст, если он сохранён; иначе то, что есть в базе
body = abstract or ""
if minio_key:
try:
obj = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
body = obj.read().decode("utf-8", errors="replace")
obj.close()
obj.release_conn()
except Exception:
pass
if len(body.split()) < args.words * 2:
continue
for case_name, work in make_cases(body, args.words).items():
found = find_source(work)
bucket = stats.setdefault(case_name, {"всего": 0, "нашли верно": 0, "не нашли": 0})
bucket["всего"] += 1
if doc_id in found:
bucket["нашли верно"] += 1
else:
bucket["не нашли"] += 1
# Контроль на ложные обвинения
found = find_source(original_text(rnd, args.words))
bucket = stats.setdefault("оригинал", {"всего": 0, "ложно обвинён": 0, "чисто": 0})
bucket["всего"] += 1
if found:
bucket["ложно обвинён"] += 1
else:
bucket["чисто"] += 1
print(f"{'случай':16} {'всего':>6} {'найден':>8} {'пропущен':>10} доля найденных")
for case in ("дословно", "лёгкий рерайт", "сильный рерайт"):
b = stats.get(case)
if not b:
continue
share = b["нашли верно"] / b["всего"] * 100 if b["всего"] else 0
print(f"{case:16} {b['всего']:>6} {b['нашли верно']:>8} {b['не нашли']:>10} {share:5.1f}%")
b = stats.get("оригинал")
if b:
fp_rate = b["ложно обвинён"] / b["всего"] * 100 if b["всего"] else 0
print(f"\nоригинальные тексты: {b['всего']}, ложных обвинений {b['ложно обвинён']} "
f"({fp_rate:.1f}%)")
print("\nЧитать так: «дословно» и «лёгкий рерайт» — обязанность L1, доля должна быть "
"близка к 100%. «Сильный рерайт» L1 брать не обязан, это работа L3/L4. "
"Ложные обвинения должны быть нулевыми.")
if __name__ == "__main__":
main()

101
scripts/ops/faiss_reconcile.py Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Сверить отметки `documents.faiss_id` с реальным содержимым векторного индекса.
Проблема, которую он решает: отметка в базе НЕ означает, что вектор есть в
индексе. Она остаётся после пересоздания индекса (например, при смене модели
эмбеддингов и размерности) и после сбоев worker-gpu. Из-за этого документ
навсегда выпадает из L3: в индексе его нет, а на пересчёт он не попадёт —
`reembed_missing.py` ищет только `faiss_id IS NULL`.
Замер на проде 2026-08-28: в индексе 93 053 вектора, отмечено в базе 154 046 —
60 993 документа считались векторизованными, не будучи ими.
Скрипт обнуляет отметки у документов, которых в индексе нет. После него
`reembed_missing.py` отправит их на пересчёт.
Запуск (нужен доступ к самому индексу → контейнер worker-gpu):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-gpu python -"
$C < scripts/ops/faiss_reconcile.py # dry-run, только показывает
$C --apply < scripts/ops/faiss_reconcile.py # обнулить ложные отметки
Работает только с FAISS (`VECTOR_BACKEND=faiss`): у Qdrant идентификаторы
хранит сам сервис, и сверка там делается иначе.
"""
import argparse
import sys
def stale_marks(marked_ids: set[int], indexed_ids: set[int]) -> set[int]:
"""Документы, отмеченные как векторизованные, но отсутствующие в индексе."""
return marked_ids - indexed_ids
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="реально обнулить faiss_id (иначе dry-run)")
args = ap.parse_args()
from app.config import settings
if settings.VECTOR_BACKEND != "faiss":
sys.exit(f"VECTOR_BACKEND={settings.VECTOR_BACKEND}: скрипт рассчитан на faiss")
import faiss # noqa: F401 (нужен для vector_to_array)
from app.faiss_manager import FAISSManager
FAISSManager.load_or_create()
index = FAISSManager._index
if index is None or not hasattr(index, "id_map"):
sys.exit("индекс не загрузился или не хранит id (нет id_map)")
indexed = {int(i) for i in faiss.vector_to_array(index.id_map)}
print(f"векторов в индексе: {index.ntotal} (уникальных id: {len(indexed)})")
import psycopg2
conn = psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
with conn.cursor() as cur:
cur.execute("SELECT id FROM documents WHERE faiss_id IS NOT NULL")
marked = {r[0] for r in cur.fetchall()}
cur.execute("SELECT count(*) FROM documents")
total = cur.fetchone()[0]
stale = stale_marks(marked, indexed)
print(f"документов всего: {total}")
print(f"отмечено как векторизованные: {len(marked)}")
print(f"ложных отметок (нет в индексе): {len(stale)}")
print(f"реальное покрытие L3: {len(indexed & marked) * 100.0 / total:.1f}%")
if not stale:
print("Сверка чистая, делать нечего.")
conn.close()
return
if not args.apply:
print(f"\n[dry-run] первые id: {sorted(stale)[:10]}")
print("Запустите с --apply, чтобы обнулить faiss_id у этих документов,")
print("затем reembed_missing.py отправит их на пересчёт эмбеддингов.")
conn.close()
return
ids = list(stale)
with conn.cursor() as cur:
# Пачками: один UPDATE с десятками тысяч id упирается в лимиты параметров
for i in range(0, len(ids), 5000):
chunk = ids[i : i + 5000]
cur.execute("UPDATE documents SET faiss_id = NULL WHERE id = ANY(%s)", (chunk,))
conn.commit()
conn.close()
print(f"\nОбнулено отметок: {len(ids)}")
print("Дальше: reembed_missing.py --apply (отправит их на пересчёт).")
if __name__ == "__main__":
main()

160
scripts/ops/keep_ingesting.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Держать массовую заливку идущей: перезапускать источники, когда они закончили.
Массовый источник за один прогон берёт столько статей, сколько влезает в бюджет
времени таска, сохраняет позицию и останавливается. Без внешнего толчка заливка
идёт рывками — ровно столько, сколько раз кто-то нажмёт «Запустить». Этот скрипт
и есть толчок: раз в несколько минут проверяет, не простаивает ли источник, и
запускает следующую порцию.
Ставится в cron на app-хосте:
*/5 * * * * cd /home/user/anti-plagiarism && /usr/bin/docker compose \
-f docker-compose.prod.yml exec -T worker-indexer python - \
< scripts/ops/keep_ingesting.py >> ~/.antiplag-monitor/ingest.log 2>&1
Останавливается сам, когда источник исчерпан: парсер перестаёт отдавать статьи,
прогон закрывается с нулём добавленных, и скрипт больше его не трогает
(--stop-when-empty, по умолчанию включено).
Перед подсчётом «занято ли» снимает зависшие прогоны: если worker-indexer упал
или перезапустился, прогон навсегда остаётся в статусе running с замолчавшим
heartbeat — без этой чистки сторож видит «занято» бесконечно и не запускает
вообще ничего, для всех источников сразу (порог тот же, что и в панели
отладки — services/api/app/api/admin.py, stale_cutoff).
Молчащий heartbeat сам по себе ещё не значит «мёртв»: пачка COPY на большой
базе идёт десятки минут без единого тика. Поэтому прогон снимается, только
если его таска нет среди выполняющихся на воркерах queue.index; не ответил
хоть один такой воркер — не снимается ничего. Иначе сторож снимал живой
медленный прогон и тут же запускал его дубль по тем же статьям.
"""
import argparse
BULK_TYPES = ("wikipedia_ru", "pmc_bulk", "core")
STALE_MINUTES = 10
def live_task_ids(celery_app) -> set[str] | None:
"""ID тасков, выполняющихся на воркерах queue.index; None — кто-то из них не ответил."""
queues = celery_app.control.inspect(timeout=10).active_queues() or {}
workers = [w for w, qs in queues.items() if any(q["name"] == "queue.index" for q in qs)]
if not workers:
return None
active = celery_app.control.inspect(destination=workers, timeout=10).active()
if not active or set(active) != set(workers):
return None
return {t["id"] for tasks in active.values() for t in tasks}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--types", default=",".join(BULK_TYPES),
help="типы источников через запятую")
ap.add_argument("--keep-going-empty", action="store_true",
help="запускать даже если прошлый прогон ничего не добавил")
args = ap.parse_args()
from app.celery_app import celery_app
from app.db import db_session
from app.models import ParseRun, ParseSource
from sqlalchemy import text
types = tuple(t.strip() for t in args.types.split(",") if t.strip())
live = live_task_ids(celery_app)
with db_session() as s:
if live is None:
print("воркеры queue.index не ответили — живость прогонов не проверить, не снимаю")
reaped = []
else:
reaped = s.execute(text(f"""
UPDATE parse_runs SET status='error', stage='finished',
error='зависший прогон снят автоматически: таск не выполняется, heartbeat молчал дольше {STALE_MINUTES} минут',
finished_at=now()
WHERE status='running'
AND (heartbeat_at IS NULL OR heartbeat_at < now() - interval '{STALE_MINUTES} minutes')
AND (celery_task_id IS NULL OR NOT (celery_task_id = ANY(CAST(:live AS text[]))))
RETURNING id, source_id
"""), {"live": sorted(live)}).all()
# queued без heartbeat — тот же зомби другого вида: send_task не дошёл
# или упал между двумя commit при постановке в очередь (celery_task_id
# так и остался пустым), воркер о такой задаче никогда не узнает
reaped += s.execute(text(f"""
UPDATE parse_runs SET status='error', stage='finished',
error='зависший прогон снят автоматически: висел в очереди дольше {STALE_MINUTES} минут',
finished_at=now()
WHERE status='queued'
AND celery_task_id IS NULL
AND started_at < now() - interval '{STALE_MINUTES} minutes'
RETURNING id, source_id
""")).all()
if reaped:
for source_id in {row.source_id for row in reaped}:
s.execute(text("""
UPDATE parse_sources SET last_status='error',
last_error='зависший прогон снят автоматически'
WHERE id=:sid AND last_status='running'
"""), {"sid": source_id})
s.commit()
print(f"снято зависших прогонов: {len(reaped)} ({', '.join(str(r.id) for r in reaped)})")
active = s.execute(text(
"SELECT count(*) FROM parse_runs WHERE status IN ('queued','running')"
)).scalar_one()
if active:
print(f"уже идёт прогонов: {active} — ждём")
return
sources = s.execute(text("""
SELECT id, source_type, last_run_id FROM parse_sources
WHERE enabled AND source_type = ANY(:types) ORDER BY id
"""), {"types": list(types)}).all()
started = []
for source_id, source_type, last_run_id in sources:
# Источник исчерпан, если прогоны перестали добавлять статьи: дальше
# в дампе/бакете/выдаче для нас ничего нет.
#
# Но ОДИН пустой прогон — ещё не приговор: ровно так же выглядят
# обрыв связи с API и прогон на устаревшем коде парсера. На CORE
# 16.09.2026 из-за этого источник был помечен исчерпанным после
# единственной неудачи и больше не запускался — молча, без ошибки.
# Поэтому ждём два пустых прогона подряд: разовый сбой переживём,
# а реально кончившийся источник остановится всего на один прогон
# позже.
if last_run_id and not args.keep_going_empty:
last_two = s.execute(text("""
SELECT status, added, fetched FROM parse_runs
WHERE source_id = :sid ORDER BY id DESC LIMIT 2
"""), {"sid": source_id}).all()
if len(last_two) == 2 and all(
r.status == "done" and r.added == 0 and r.fetched == 0 for r in last_two
):
print(f"{source_type}: источник исчерпан (два пустых прогона), пропускаем")
continue
src = s.get(ParseSource, source_id)
run = ParseRun(source_id=source_id, status="queued", stage="queued",
target=src.limit, log=[])
s.add(run)
s.flush()
src.last_status = "running"
src.last_error = None
src.last_run_id = run.id
s.commit()
result = celery_app.send_task("index.run_parser", args=[source_id, run.id],
queue="queue.index")
run.celery_task_id = result.id
s.commit()
started.append(f"{source_type}(прогон {run.id})")
print("запущено:", ", ".join(started) if started else "нечего запускать")
if __name__ == "__main__":
main()

140
scripts/ops/reembed_missing.py Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Догнать эмбеддинги для документов, у которых их нет (faiss_id IS NULL).
Зачем: документ попадает в корпус сразу (метаданные, fingerprints L1, MinHash L2),
а вектор для L3 считает отдельная задача `gpu.embed_documents`. Если worker-gpu
или Ollama были недоступны в момент заливки, эти задачи теряются — документ
остаётся в базе, но семантический поиск его не видит. Скрипт находит такие
документы и переотправляет задачи пачками.
Идемпотентно: повторный запуск возьмёт только оставшиеся без faiss_id.
ВАЖНО, чего скрипт НЕ делает: документы с непустым `faiss_id`, которого нет в
самом FAISS-индексе (наследие смены модели эмбеддингов — размерность сменилась,
индекс пересобран с нуля, а ссылки в БД остались), сюда не попадут. Их сначала
надо выявить сверкой с индексом и обнулить faiss_id — это отдельная операция.
Запуск (нужны psycopg2 + celery — они есть в образе воркера; сам репозиторий
внутрь контейнера не смонтирован, поэтому передаём скрипт через stdin):
cd /home/user/anti-plagiarism
C="docker compose -f docker-compose.prod.yml exec -T worker-indexer python -"
$C < scripts/ops/reembed_missing.py # dry-run, только считает
$C --apply < scripts/ops/reembed_missing.py # отправить задачи
$C --apply --limit 1000 < scripts/ops/reembed_missing.py # пробная порция
Креды берутся из окружения (в контейнере они уже есть из env_file), а при
запуске файлом — из .env репозитория: POSTGRES_*, RABBITMQ_URL.
"""
import argparse
import os
import sys
from pathlib import Path
DEFAULT_BATCH = 64 # столько же, сколько EMBED_BATCH_SIZE у индексера
def _load_env() -> None:
"""Подтянуть переменные из .env репозитория, если файл есть.
При запуске через stdin (`docker exec ... python -`) __file__ не определён —
это штатный способ запуска здесь, и переменные в контейнере уже есть.
"""
try:
env = Path(__file__).resolve().parent.parent.parent / ".env"
except NameError:
return
if not env.exists():
return
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
def _pg_dsn() -> dict:
return {
"host": os.environ.get("POSTGRES_HOST", "localhost"),
"port": int(os.environ.get("POSTGRES_PORT", "5432")),
"dbname": os.environ.get("POSTGRES_DB", "antiplagiator"),
"user": os.environ.get("POSTGRES_USER", "antiplagiator"),
"password": os.environ.get("POSTGRES_PASSWORD", ""),
}
def chunked(items: list[int], size: int) -> list[list[int]]:
"""Разбить список id на пачки по size элементов."""
return [items[i : i + size] for i in range(0, len(items), size)]
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("--batch", type=int, default=DEFAULT_BATCH, help=f"документов в пачке (по умолчанию {DEFAULT_BATCH})")
ap.add_argument("--limit", type=int, default=0, help="взять не больше N документов (0 = все)")
args = ap.parse_args()
_load_env()
try:
import psycopg2
except ImportError:
sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте в контейнере.")
dsn = _pg_dsn()
print(f"PostgreSQL {dsn['host']}:{dsn['port']}/{dsn['dbname']}")
conn = psycopg2.connect(**dsn)
with conn.cursor() as cur:
sql = "SELECT id FROM documents WHERE faiss_id IS NULL ORDER BY id"
if args.limit:
sql += f" LIMIT {int(args.limit)}"
cur.execute(sql)
doc_ids = [r[0] for r in cur.fetchall()]
cur.execute("SELECT count(*) FROM documents")
total = cur.fetchone()[0]
conn.close()
batches = chunked(doc_ids, args.batch)
print(f"Документов всего: {total}")
print(f"Без эмбеддинга: {len(doc_ids)} → пачек по {args.batch}: {len(batches)}")
if not doc_ids:
print("Нечего досчитывать.")
return
if not args.apply:
head = ", ".join(str(i) for i in doc_ids[:10])
print(f"\n[dry-run] первые id: {head}{' …' if len(doc_ids) > 10 else ''}")
print("Запустите с --apply, чтобы отправить gpu.embed_documents в queue.gpu.")
return
try:
from celery import Celery
except ImportError:
sys.exit("Нужен celery (есть в образах воркеров). Запускайте в контейнере.")
broker = os.environ.get("RABBITMQ_URL")
if not broker:
sys.exit("Не задан RABBITMQ_URL — нечем отправлять задачи.")
app = Celery("reembed", broker=broker)
sent = 0
try:
for batch in batches:
app.send_task("gpu.embed_documents", args=[batch], queue="queue.gpu")
sent += 1
if sent % 50 == 0:
print(f" отправлено пачек: {sent}/{len(batches)}")
except Exception as e:
sys.exit(f"\nБрокер недоступен после {sent} пачек: {e}")
print(f"\nОтправлено пачек: {sent} ({len(doc_ids)} документов).")
print("Ход выполнения — админка → «Отладка» (очередь queue.gpu и покрытие эмбеддингами).")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Вычислить байтовое смещение в дампе Википедии по уже залитым статьям.
Нужен один раз при переходе на multistream-дамп: позиция продолжения сменила
смысл — раньше это был номер статьи (и прогон перечитывал дамп с начала),
теперь байтовое смещение блока.
Как считается: берём максимальный page_id среди залитых статей и находим в
индексе дампа блок, которому он принадлежит. Страницы в дампе идут по
возрастанию page_id, поэтому всё, что дальше этого блока, ещё не залито.
Запуск (в контейнере worker-indexer):
docker compose -f docker-compose.prod.yml exec -T worker-indexer python - \\
< scripts/ops/wikipedia_resume_offset.py # показать
... python - --apply < scripts/ops/wikipedia_resume_offset.py # записать
"""
import argparse
import bz2
import os
DEFAULT_INDEX = "/parsers/ruwiki-index.txt.bz2"
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="записать смещение в источник")
ap.add_argument("--index", default=DEFAULT_INDEX, help="путь к индексу дампа")
args = ap.parse_args()
from app.db import db_session
from sqlalchemy import text
if not os.path.exists(args.index):
raise SystemExit(f"индекс не найден: {args.index}")
with db_session() as s:
row = s.execute(text("""
SELECT max(split_part(ext_id, ':', 2)::bigint)
FROM documents WHERE source = 'wikipedia_ru'
""")).first()
max_pageid = int(row[0]) if row and row[0] else 0
print(f"максимальный page_id среди залитых: {max_pageid}")
if not max_pageid:
print("залитых статей нет — начинать с нуля")
return
# Индекс отсортирован по смещению, page_id внутри растут: ищем блок,
# в котором лежит наш максимальный, и берём его смещение
offset = 0
found_title = ""
with bz2.open(args.index, "rt", encoding="utf-8") as fh:
for line in fh:
parts = line.split(":", 2)
if len(parts) < 3:
continue
off, pid = int(parts[0]), int(parts[1])
if pid > max_pageid:
break
offset, found_title = off, parts[2].strip()
print(f"блок с этой статьёй начинается на байте {offset} ({found_title[:50]!r})")
if not args.apply:
print("\nзапустите с --apply, чтобы записать смещение в источник")
return
with db_session() as s:
s.execute(text("""
UPDATE parse_sources SET resume_token = :off WHERE source_type = 'wikipedia_ru'
"""), {"off": str(offset)})
s.commit()
print(f"смещение {offset} записано в источник")
if __name__ == "__main__":
main()

View File

@@ -12,7 +12,7 @@ import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
@@ -46,6 +46,7 @@ class ArxivParser(BaseParser):
limit: int = 500,
categories: list[str] | None = None,
year_from: int | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]:
"""
Получить препринты из arXiv.
@@ -55,6 +56,7 @@ class ArxivParser(BaseParser):
limit: Максимальное количество документов
categories: Список категорий arXiv (cs.AI, math.ST и т.д.)
year_from: Год публикации от
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей
@@ -94,6 +96,10 @@ class ArxivParser(BaseParser):
results.extend(entries)
start += len(entries)
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(f"arXiv: выборка остановлена по запросу (получено {len(results)})")
break
if len(entries) < max_results:
break

View File

@@ -6,11 +6,19 @@
import json
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Колбэк прогресса выборки: вызывается после каждой страницы результатов с
# накопленным количеством документов. Возврат False — просьба остановиться
# (кооперативная отмена из админки или исчерпанный бюджет времени таска,
# см. worker-indexer/app/progress.py). Парсер обязан вернуть уже собранное,
# а не бросать исключение: частичная выборка — валидный результат.
ProgressCallback = Callable[[int], bool]
# Унифицированный формат документа
UNIFIED_SCHEMA = {
@@ -45,7 +53,10 @@ class BaseParser(ABC):
Получить сырые документы из источника.
Args:
**kwargs: Специфичные для источника параметры (query, limit и т.д.)
**kwargs: Специфичные для источника параметры (query, limit и т.д.).
Все парсеры принимают ещё и `progress_cb` (ProgressCallback) —
отчёт о прогрессе после каждой страницы и точка кооперативной
остановки.
Returns:
Список сырых словарей из API источника

282
scripts/parsers/core.py Normal file
View File

@@ -0,0 +1,282 @@
"""Парсер CORE (core.ac.uk) — полные тексты открытых репозиториев.
Зачем нужен: полный текст есть у малой доли корпуса, и по-русски дыра самая
большая — CyberLeninka отдаёт только HTML-страницу статьи, тела не даёт. CORE
агрегирует открытые репозитории (в том числе вузовские русско- и
белорусскоязычные) и кладёт готовый текст прямо в выдачу поиска — его не надо
ни скачивать отдельно, ни извлекать из PDF.
Замер на живом API 16.09.2026: страница в 100 записей приходит за ~7с и
содержит 47 статей с текстом длиннее 1500 символов, медиана — 12.7 тыс.
символов. То есть один запрос ≈ 47 документов корпуса.
Чем сузить выборку до русских текстов: в общем потоке CORE кириллицы нет
вовсе (проверено: 0 из 48 полных текстов за 2019). Язык у работ размечен
плохо — `language` сплошь None или "zz", фильтровать по нему нельзя. Зато
работает отбор по архиву-поставщику: запрос вида
`(repositories.id:1298 OR repositories.id:21908 OR ...)` по вузовским
репозиториям России и Беларуси даёт 79 полных текстов из 100 записей, 78 из
них — кириллица. Сам список архивов живёт в поле `query` источника, а не в
коде: его правят из админки, не пересобирая образ.
Массовый парсер (`bulk = True`): отдаёт статьи генератором и пишется пачками
через COPY, как Википедия и PMC (см. bulk_writer.py). Статьи без текста
пропускаются молча — они уже есть у нас как метаданные из OpenAlex, а
обогащать корпус нечем.
Грабли API, все проверены живьём:
- **с российских адресов ключ не работает**: прямой запрос с прода виснет без
ошибки (соединение есть, тело ответа не приходит), через `singbox-proxy` тот
же запрос отвечает за 1.7с. Анонимные короткие запросы проходят и напрямую,
из-за чего поломка выглядит как сетевая. Адрес прокси — `CORE_PROXY_URL`;
- у `/v3/search/works` обязателен слэш на конце: иначе 301, а при редиректе
теряется заголовок Authorization и запрос уходит анонимным;
- `offset` упирается в 100 000 (под капотом Azure Search, глубже — 400),
поэтому выборка режется на части — по одному архиву-поставщику на запрос;
- **условия через `AND` не связываются**: `(repositories.id:...) AND yearPublished:2026`
отдаёт 2.3 млн работ вперемешку по годам, то есть условия объединяются по
«или» и год работает лишь как подсказка ранжированию. По отдельности каждое
условие фильтрует честно (`yearPublished:2026` — ровно 2026, `repositories.id:1298`
— ровно этот архив). Поэтому в запрос идёт РОВНО ОДНО условие: номер архива.
Годы, если заданы, отсекаются уже на нашей стороне;
- `fullText` — не фильтруемое поле, `_exists_:fullText` отвечает 500.
Отбирать статьи с текстом приходится на своей стороне, оплачивая трафиком;
- CORE агрегирует репозитории и отдаёт одну статью несколько раз под разными
id (проверено: 90810208 и 354263199 — один текст). Дедупликация корпуса
идёт только по ext_id, поэтому такие пары легли бы отдельными документами
и потом ловились бы как заимствование друг у друга. Отсеиваем по хешу
текста в пределах прогона: копии приходят в соседних строках выдачи.
"""
import hashlib
import logging
import os
import re
import time
from collections.abc import Iterator
from typing import Any
import httpx
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
API_URL = "https://api.core.ac.uk/v3/search/works/" # слэш обязателен, см. докстринг
PAGE = 100 # максимум записей за запрос
MAX_OFFSET = 100_000 # потолок глубины у Azure Search
MIN_CHARS = 1500 # короче — обрывок или аннотация, а не статья
RETRIES = 3
# CORE отдаёт данные по ключу только за пределами РФ: с прода прямой запрос
# молча виснет (TCP есть, тело ответа не приходит), а тот же запрос через
# sing-box возвращается за 1.7с. Анонимные мелкие запросы проходят и напрямую,
# поэтому со стороны это выглядит как поломка сети, а не блокировка. Тот же
# приём, что для OpenRouter в worker-gpu. Пусто = ходить напрямую.
DEFAULT_PROXY = "socks5://singbox-proxy:1080"
class COREParser(BaseParser):
"""Полные тексты из CORE. Отдаёт статьи потоком, режет выборку по годам."""
source_name = "core"
bulk = True
def __init__(self, api_key: str | None = None, proxy_url: str | None = None) -> None:
super().__init__()
self.api_key = api_key or os.environ.get("CORE_API_KEY", "")
# Вне compose-сети (локальный прогон, тесты) имени singbox-proxy нет —
# там прокси отключают, выставив CORE_PROXY_URL пустым
self.proxy_url = self._proxy(proxy_url)
if not self.api_key:
logger.warning("CORE: ключ не задан (CORE_API_KEY) — API ответит 401")
# Страница весит ~5 МБ, обычный ответ ~7с. 30с — запас на джиттер в
# десять раз, но без риска съесть бюджет прогона: три попытки по 60с
# отъедали 180с из 1500с на одной залипшей странице (поймано 16.09.2026,
# та же грабля, что чинили в pmc_bulk)
self.client = httpx.Client(
timeout=30,
proxy=self.proxy_url or None,
headers={
"Authorization": f"Bearer {self.api_key}",
"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)",
},
)
self.last_token: str | None = None
@staticmethod
def _proxy(proxy_url: str | None) -> str:
"""Адрес прокси: явный аргумент, иначе CORE_PROXY_URL, иначе умолчание."""
if proxy_url is not None:
return proxy_url
return os.environ.get("CORE_PROXY_URL", DEFAULT_PROXY)
def fetch( # type: ignore[override]
self,
limit: int = 1000,
query: str = "",
year_from: int | None = None,
year_to: int | None = None,
resume_token: str | None = None,
progress_cb: ProgressCallback | None = None,
**_ignored: Any,
) -> Iterator[dict[str, Any]]:
"""Статьи с полным текстом, архив за архивом.
Args:
limit: сколько статей с текстом отдать за прогон
query: список архивов — номера через запятую/пробел или выражение
`(repositories.id:N OR ...)`. Пустой — обычный поиск по словам
year_from: отсекать статьи старше этого года (необязательно)
year_to: отсекать статьи новее этого года (необязательно)
resume_token: позиция вида "1298:4200" — архив и смещение в нём
progress_cb: см. base.ProgressCallback
"""
parts = self._repos(query)
start_part, start_offset = self._parse_token(resume_token)
if start_part in parts:
parts = parts[parts.index(start_part):]
else:
start_offset = 0
given = 0
seen: set[str] = set()
for part in parts:
offset = start_offset
start_offset = 0 # смещение относится только к архиву из токена
while given < limit and offset < MAX_OFFSET:
results = self._page(part, query, offset)
if results is None: # API не ответил — прогон закончен
return
if not results:
logger.info("CORE: архив %s исчерпан на смещении %d", part, offset)
break
token = f"{part}:{offset}"
for work in results:
text = work.get("fullText") or ""
if len(text) < MIN_CHARS:
continue
year = work.get("yearPublished")
if year and ((year_from and year < year_from)
or (year_to and year > year_to)):
continue
# Дубли CORE: один текст под разными id (см. докстринг).
# Хеша начала текста хватает — совпадение первых 5 тыс.
# символов у разных статей практически исключено
digest = hashlib.sha1(text[:5000].encode()).hexdigest()
if digest in seen:
continue
seen.add(digest)
given += 1
# Токен указывает на страницу, из которой пришла статья, а
# не на следующую: пачка может прерваться на её середине, и
# тогда следующий прогон перечитает страницу целиком.
# Лишний запрос дешевле потерянных статей, дубли отсекает
# ON CONFLICT на ext_id
work["resume_token"] = token
yield work
if given >= limit:
break
offset += PAGE
self.last_token = f"{part}:{offset}"
if progress_cb and not progress_cb(given):
logger.info("CORE: выборка остановлена по запросу (%d)", given)
return
logger.info("CORE: пройдено архивов: %d, отдано %d", len(parts), given)
@staticmethod
def _repos(query: str) -> list[str]:
"""Номера архивов из настройки источника.
Принимает и выражение `(repositories.id:1298 OR ...)`, и простой список
«1298, 21908». Если номеров нет вовсе — единственная часть с именем
`q`: тогда парсер просто ищет по словам запроса.
"""
ids = re.findall(r"repositories\.id:(\d+)", query or "")
if not ids:
ids = re.findall(r"\b\d{2,7}\b", query or "")
return ids or ["q"]
@staticmethod
def _parse_token(token: str | None) -> tuple[str | None, int]:
"""Разобрать позицию "архив:смещение"; мусор — начать сначала."""
if not token or ":" not in token:
return None, 0
part, _, offset = token.rpartition(":")
try:
return part, int(offset)
except ValueError:
logger.warning("CORE: непонятная позиция %r, начинаем сначала", token)
return None, 0
def _page(self, part: str, query: str, offset: int) -> list[dict[str, Any]] | None:
"""Одна страница выдачи; None — API не отвечает, прогон пора кончать.
В запросе РОВНО одно условие: связка через AND в этом API не работает
(см. докстринг модуля), поэтому годы отсекаются уже после выборки.
"""
q = f"repositories.id:{part}" if part != "q" else (query or "*")
params = {"q": q, "limit": PAGE, "offset": offset}
for attempt in range(RETRIES):
try:
resp = self.client.get(API_URL, params=params)
if resp.status_code == 429:
# Лимит тарифа: подождать и повторить, а не ронять прогон
wait = int(resp.headers.get("retry-after", 30))
logger.warning("CORE: лимит запросов, ждём %dс", wait)
time.sleep(min(wait, 60))
continue
resp.raise_for_status()
return resp.json().get("results") or []
except Exception as e:
logger.warning(
"CORE: страница %s:%d не удалась (%d/%d): %s",
part, offset, attempt + 1, RETRIES, e,
)
time.sleep(2 * (attempt + 1))
logger.error("CORE: страница %s:%d не далась за %d попыток", part, offset, RETRIES)
return None
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Привести статью CORE к унифицированному формату корпуса."""
ext = raw.get("id")
text = raw.get("fullText") or ""
if not ext or len(text) < MIN_CHARS:
return {}
lang = raw.get("language") or {}
code = lang.get("code") if isinstance(lang, dict) else lang
journals = raw.get("journals") or []
journal = journals[0].get("title") if journals and isinstance(journals[0], dict) else None
return {
"source": self.source_name,
"ext_id": f"core:{ext}",
"title": (raw.get("title") or f"CORE {ext}")[:1000],
"authors": self.normalize_authors(self._authors(raw)),
"doi": raw.get("doi"),
"year": raw.get("yearPublished"),
# zz у CORE значит «язык не определён» — лучше пусто, чем мусор
"lang": code if code and code != "zz" else None,
"journal": journal[:300] if journal else None,
"url": raw.get("downloadUrl") or f"https://core.ac.uk/works/{ext}",
"abstract": (raw.get("abstract") or text[:2000])[:2000],
"text": text,
"resume_token": raw.get("resume_token"),
}
@staticmethod
def _authors(raw: dict[str, Any]) -> list[dict[str, str]]:
"""Авторы CORE приходят одной строкой "Фамилия, Имя Отчество"."""
out = []
for author in raw.get("authors") or []:
name = author.get("name") if isinstance(author, dict) else author
if not name:
continue
last, _, first = str(name).partition(",")
out.append({"last_name": last.strip(), "first_name": first.strip()})
return out

View File

@@ -16,7 +16,7 @@ import time
from typing import Any
import httpx
from base import BaseParser
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
@@ -47,6 +47,7 @@ class CyberLeninkaParser(BaseParser):
query: str = "",
limit: int = 500,
subject: str | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]:
"""
Получить статьи из КиберЛенинки.
@@ -55,6 +56,7 @@ class CyberLeninkaParser(BaseParser):
query: Поисковый запрос
limit: Максимальное количество статей
subject: Предметная область (опционально)
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей статей
@@ -85,6 +87,12 @@ class CyberLeninkaParser(BaseParser):
results.extend(items)
page += 1
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(
f"КиберЛенинка: выборка остановлена по запросу (получено {len(results)})"
)
break
if len(items) < 10:
break

View File

@@ -15,12 +15,41 @@ from collections.abc import Generator
from typing import Any
import httpx
from base import BaseParser
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
OPENALEX_API = "https://api.openalex.org"
DEFAULT_EMAIL = "noreply@jze9.ru" # Для вежливого агента
# Пауза между страницами. Лимит вежливого пула — 10 запросов/сек НА ВЕСЬ ключ
# (mailto), а не на процесс: четыре воркера, качающие разные источники, делят
# его между собой. С прежними 0.1с массовая заливка утыкалась в сплошные 429 и
# каждый прогон уходил в 900с бесполезного backoff. 1с × 4 воркера ≈ 4 req/s.
RATE_LIMIT_DELAY = 1.0
# Backoff режем на куски: во время сна парсер обязан отчитываться о жизни,
# иначе прогон выглядит зависшим и его нельзя отменить из админки.
BACKOFF_TICK_S = 5.0
def _sleep_alive(
seconds: float, progress_cb: ProgressCallback | None, fetched: int
) -> bool:
"""Поспать, отчитываясь о жизни; False — попросили остановиться.
Минуты сна в backoff нельзя проводить молча: для админки такой прогон
неотличим от зависшего, а отмена не сработает до конца ожидания.
"""
if progress_cb is None:
time.sleep(seconds)
return True
left = seconds
while left > 0:
time.sleep(min(BACKOFF_TICK_S, left))
left -= BACKOFF_TICK_S
if not progress_cb(fetched):
return False
return True
class OpenAlexParser(BaseParser):
@@ -45,6 +74,7 @@ class OpenAlexParser(BaseParser):
year_to: int | None = None,
type_filter: str = "article",
open_access_only: bool = False,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]:
"""
Получить документы из OpenAlex.
@@ -58,6 +88,7 @@ class OpenAlexParser(BaseParser):
type_filter: Тип документа (journal-article, book, и т.д.)
open_access_only: только open-access работы (is_oa:true) — резко
повышает долю статей с доступным PDF (для наполнения корпуса)
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей из OpenAlex API
@@ -72,8 +103,12 @@ class OpenAlexParser(BaseParser):
year_to=year_to,
type_filter=type_filter,
open_access_only=open_access_only,
progress_cb=progress_cb,
):
results.extend(page)
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info("OpenAlex: выборка остановлена по запросу (получено %d)", len(results))
break
if len(results) >= limit:
break
@@ -88,11 +123,20 @@ class OpenAlexParser(BaseParser):
year_to: int | None,
type_filter: str,
open_access_only: bool = False,
progress_cb: ProgressCallback | None = None,
) -> Generator[list[dict], None, None]:
"""Cursor-based пагинация OpenAlex."""
cursor = "*"
per_page = min(200, limit)
total_fetched = 0
rate_limit_retries = 0
# 5 попыток (60+120+240+480+960=1860с) превышали дефолтный
# consumer_timeout RabbitMQ (1800с) — брокер рвал канал до того,
# как таск успевал сдаться и заacke-иться, воркер падал и Docker
# перезапускал его, задача редоставлялась и весь цикл начинался
# заново с попытки 1 — бесконечный краш-луп. 4 попытки = 900с,
# запас с большим запасом.
MAX_RATE_LIMIT_RETRIES = 4
while total_fetched < limit:
params: dict[str, Any] = {
@@ -135,20 +179,33 @@ class OpenAlexParser(BaseParser):
yield works
total_fetched += len(works)
rate_limit_retries = 0 # успешный запрос — сбросить счётчик
# Следующий курсор
cursor = data.get("meta", {}).get("next_cursor")
if not cursor:
break
# Rate limiting: 10 запросов/сек без ключа
time.sleep(0.1)
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"OpenAlex HTTP ошибка: {e.response.status_code}")
if e.response.status_code == 429:
logger.warning("Rate limit! Ожидаем 60 секунд...")
time.sleep(60)
rate_limit_retries += 1
if rate_limit_retries > MAX_RATE_LIMIT_RETRIES:
logger.error(
f"OpenAlex: {MAX_RATE_LIMIT_RETRIES} подряд 429 — сдаюсь, "
f"забрано {total_fetched}/{limit}"
)
break
# Экспоненциальный backoff: 60/120/240/480/960с — при нескольких
# параллельных воркерах плоское ожидание 60с не давало общему
# лимиту освободиться, каждый воркер продлевал блокировку сам.
wait = 60 * (2 ** (rate_limit_retries - 1))
logger.warning(f"Rate limit! Попытка {rate_limit_retries}/{MAX_RATE_LIMIT_RETRIES}, ждём {wait}с...")
if not _sleep_alive(wait, progress_cb, total_fetched):
logger.info("OpenAlex: ожидание прервано по запросу")
break
continue
break
except Exception as e:

View File

@@ -17,7 +17,7 @@ import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
@@ -52,6 +52,7 @@ class PMCParser(BaseParser):
limit: int = 500,
year_from: int | None = None,
year_to: int | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]:
"""
Получить статьи из PMC Open Access Subset.
@@ -61,6 +62,7 @@ class PMCParser(BaseParser):
limit: Максимальное количество документов
year_from: Год публикации от
year_to: Год публикации до
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей (уже с извлечёнными метаданными + full_text)
@@ -85,6 +87,9 @@ class PMCParser(BaseParser):
)
response.raise_for_status()
results.extend(self._parse_articles(response.text))
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(f"PMC: выборка остановлена по запросу (получено {len(results)})")
break
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC efetch HTTP ошибка: {e.response.status_code}")

164
scripts/parsers/pmc_bulk.py Normal file
View File

@@ -0,0 +1,164 @@
"""Парсер PubMed Central из открытого бакета AWS — массовый путь.
Отличие от `pmc.py`: тот ходит в API E-utilities и годится для точечной
докачки по теме (1-2 статьи в секунду, есть rate limit). Этот берёт статьи из
бакета `pmc-oa-opendata`, где у каждой лежит **уже извлечённый текст**, и
качает их пачками в несколько потоков — путь к сотням тысяч и миллионам.
Ключ для доступа не нужен, бакет открыт. Замер: 12 статей/с в 10 потоков.
Как и парсер Википедии, отдаёт статьи генератором: складывать миллионы
документов в список нельзя (см. bulk_writer.py).
"""
import logging
import re
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from urllib.parse import quote
import httpx
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
BUCKET = "https://pmc-oa-opendata.s3.amazonaws.com"
KEY_RE = re.compile(r"<Key>(PMC\d+\.\d+)/\1\.txt</Key>")
TOKEN_RE = re.compile(r"<NextContinuationToken>([^<]+)</NextContinuationToken>")
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
MIN_CHARS = 1500 # меньше — обрывок, а не статья
class PMCBulkParser(BaseParser):
"""PMC Open Access из бакета AWS. Отдаёт статьи потоком."""
source_name = "pmc" # тот же источник в корпусе, что и у API-парсера
bulk = True
def __init__(self, workers: int = 12) -> None:
super().__init__()
self.workers = workers
# 60с на файл при бюджете задачи в 1500с — один залипший запрос съедал
# почти весь бюджет. Обычный GET сюда укладывается в доли секунды,
# 12с — с большим запасом на джиттер, но без риска съесть весь прогон
self.client = httpx.Client(
timeout=12,
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
)
# Позиция листинга: бакет отдаётся страницами, и продолжать прогон
# нужно с той же страницы, а не с начала
self.last_token: str | None = None
def fetch( # type: ignore[override]
self,
limit: int = 1000,
resume_token: str | None = None,
start_after: str | None = None,
progress_cb: ProgressCallback | None = None,
**_ignored: Any,
) -> Iterator[dict[str, Any]]:
"""Статьи из бакета: листинг страницами, скачивание в потоках.
Args:
limit: сколько статей отдать
resume_token: продолжить листинг с сохранённой страницы бакета
start_after: ключ, после которого начинать листинг. Нужен, когда
токена нет (первый прогон после ручных заливок): без него
листинг пошёл бы с начала бакета и часами перемалывал уже
залитые статьи как дубли
progress_cb: см. base.ProgressCallback
"""
token = resume_token
given = 0
while given < limit:
try:
ids, token = self._list_page(
token, want=min(200, limit - given),
start_after=None if token else start_after,
)
except Exception as e:
logger.error("PMC bulk: листинг не удался: %s", e)
return
if not ids:
logger.info("PMC bulk: бакет закончился")
return
# as_completed вместо map(): map() отдаёт результаты строго по
# порядку отправки, поэтому один залипший запрос блокирует все
# уже готовые — даже если остальные 11 потоков давно отработали
with ThreadPoolExecutor(max_workers=self.workers) as pool:
futures = {pool.submit(self._fetch_article, i): i for i in ids}
for future in as_completed(futures):
raw = future.result()
if raw is None:
continue
given += 1
raw["resume_token"] = token
yield raw
if given >= limit:
break
self.last_token = token
if progress_cb and not progress_cb(given):
logger.info("PMC bulk: выборка остановлена по запросу (%d)", given)
return
if token is None:
return
def _list_page(
self, token: str | None, want: int, start_after: str | None = None
) -> tuple[list[str], str | None]:
"""Одна страница листинга бакета: id статей и токен следующей страницы."""
url = f"{BUCKET}/?list-type=2&max-keys=1000"
if token:
# В токене бывают + и / — без экранирования S3 отвечает 400
url += f"&continuation-token={quote(token, safe='')}"
elif start_after:
url += f"&start-after={quote(start_after, safe='')}"
resp = self.client.get(url)
resp.raise_for_status()
ids = KEY_RE.findall(resp.text)[:want]
m = TOKEN_RE.search(resp.text)
return ids, (m.group(1) if m else None)
def _fetch_article(self, art_id: str) -> dict[str, Any] | None:
"""Текст статьи и метаданные; None — статья недоступна или пустая."""
try:
txt = self.client.get(f"{BUCKET}/{art_id}/{art_id}.txt")
if txt.status_code != 200 or len(txt.text) < MIN_CHARS:
return None
meta_resp = self.client.get(f"{BUCKET}/{art_id}/{art_id}.json")
meta = meta_resp.json() if meta_resp.status_code == 200 else {}
except Exception:
return None
return {"art_id": art_id, "meta": meta, "text": txt.text}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Привести статью к унифицированному формату документов корпуса."""
meta = raw.get("meta") or {}
art_id = raw.get("art_id", "")
pmcid = meta.get("pmcid") or art_id.split(".")[0]
if not pmcid:
return {}
citation = meta.get("citation") or ""
year_match = YEAR_RE.search(citation)
text = raw.get("text", "")
return {
"source": self.source_name,
"ext_id": f"pmc:{pmcid}",
"title": (meta.get("title") or pmcid)[:1000],
"authors": [],
"doi": meta.get("doi"),
"year": int(year_match.group(0)) if year_match else None,
"lang": "en",
"journal": citation[:300] or None,
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/",
"abstract": text[:2000],
"text": text,
"resume_token": raw.get("resume_token"),
}

View File

@@ -0,0 +1,156 @@
"""Юнит-тесты парсера CORE — чистая логика, без сети.
Стерегут то, ради чего парсер написан иначе остальных: отбор статей с полным
текстом, отсев дублей CORE (одна статья под разными id), нарезку выборки по
архивам-поставщикам и позицию продолжения «архив:смещение».
Нарезка по архивам — не украшение: связка условий через `AND` в API CORE не
работает (запрос по архивам вместе с годом отдаёт годы вперемешку), поэтому в
запрос идёт ровно одно условие, а годы отсекаются уже на нашей стороне.
"""
from core import DEFAULT_PROXY, MIN_CHARS, COREParser
LONG = "слово " * 400 # заведомо длиннее MIN_CHARS
SAMPLE = {
"id": 123456,
"title": "Нейронные сети в медицине",
"authors": [{"name": "Кузьмин, Ярослав Вадимович"}, {"name": "Савенок А."}],
"doi": "10.1234/x",
"yearPublished": 2019,
"language": {"code": "ru"},
"journals": [{"title": "Вестник"}],
"downloadUrl": "https://core.ac.uk/download/1.pdf",
"abstract": "Аннотация",
"fullText": LONG,
"resume_token": "1298:100",
}
def test_repos_from_or_expression():
q = "(repositories.id:1298 OR repositories.id:21908 OR repositories.id:949)"
assert COREParser._repos(q) == ["1298", "21908", "949"]
def test_repos_from_plain_list():
assert COREParser._repos("1298, 21908 949") == ["1298", "21908", "949"]
def test_repos_without_numbers_falls_back_to_word_search():
# Номеров нет — единственная часть «q»: обычный поиск по словам
assert COREParser._repos("нейронные сети") == ["q"]
assert COREParser._repos("") == ["q"]
def test_parse_token():
assert COREParser._parse_token("1298:4200") == ("1298", 4200)
assert COREParser._parse_token("q:300") == ("q", 300)
assert COREParser._parse_token(None) == (None, 0)
assert COREParser._parse_token("мусор") == (None, 0)
assert COREParser._parse_token("архив:смещение") == (None, 0)
def test_transform_maps_fields():
t = COREParser(proxy_url="").transform(SAMPLE)
assert t["ext_id"] == "core:123456"
assert t["source"] == "core"
assert t["year"] == 2019 and t["lang"] == "ru"
assert t["journal"] == "Вестник"
assert t["text"] == LONG
assert t["resume_token"] == "1298:100"
# Автор приходит одной строкой «Фамилия, Имя Отчество»
assert t["authors"][0]["last_name"] == "Кузьмин"
assert t["authors"][0]["initials"] == "Я.В."
def test_transform_skips_short_text():
assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText="коротко")) == {}
assert COREParser(proxy_url="").transform(dict(SAMPLE, fullText=None)) == {}
assert COREParser(proxy_url="").transform(dict(SAMPLE, id=None)) == {}
def test_transform_drops_undefined_language():
# zz у CORE значит «язык не определён» — в корпус такое класть незачем
assert COREParser(proxy_url="").transform(dict(SAMPLE, language={"code": "zz"}))["lang"] is None
assert COREParser(proxy_url="").transform(dict(SAMPLE, language=None))["lang"] is None
def _parser_with_pages(pages):
"""Парсер, у которого выдача подменена заранее заготовленными страницами."""
p = COREParser(api_key="test", proxy_url="")
calls = []
def fake_page(part, query, offset):
calls.append((part, offset))
return pages.pop(0) if pages else []
p._page = fake_page # type: ignore[method-assign]
p.calls = calls # type: ignore[attr-defined]
return p
def test_fetch_drops_core_duplicates():
# Одна и та же статья под разными id — CORE так отдаёт всегда
page = [
{"id": 1, "fullText": LONG},
{"id": 2, "fullText": LONG},
{"id": 3, "fullText": LONG + "иное"},
{"id": 4, "fullText": "коротко"},
]
p = _parser_with_pages([page])
got = list(p.fetch(limit=10, query="repositories.id:1298"))
assert [w["id"] for w in got] == [1, 3]
def test_fetch_position_points_at_own_page():
# Токен обязан указывать на страницу, откуда пришла статья: пачка может
# прерваться на середине, и следующий прогон перечитает её целиком
pages = [[{"id": 1, "fullText": LONG}], [{"id": 2, "fullText": LONG + "два"}]]
p = _parser_with_pages(pages)
got = list(p.fetch(limit=10, query="repositories.id:1298"))
assert [w["resume_token"] for w in got] == ["1298:0", "1298:100"]
def test_fetch_walks_archives_and_resumes():
p = _parser_with_pages([[], []])
list(p.fetch(limit=10, query="(repositories.id:1298 OR repositories.id:949)",
resume_token="1298:300"))
# Начали с архива из токена и его смещения, пустой архив — переход к следующему
assert p.calls == [("1298", 300), ("949", 0)]
def test_fetch_ignores_token_of_unknown_archive():
p = _parser_with_pages([[]])
list(p.fetch(limit=10, query="repositories.id:1298", resume_token="99999:500"))
assert p.calls == [("1298", 0)]
def test_fetch_filters_years_on_our_side():
# Годы API не фильтрует, поэтому отсекаем сами — и только если заданы
page = [
{"id": 1, "fullText": LONG, "yearPublished": 2004},
{"id": 2, "fullText": LONG + "два", "yearPublished": 2015},
{"id": 3, "fullText": LONG + "три", "yearPublished": 2030},
]
p = _parser_with_pages([list(page)])
got = list(p.fetch(limit=10, query="repositories.id:1298", year_from=2010, year_to=2026))
assert [w["id"] for w in got] == [2]
p2 = _parser_with_pages([list(page)])
got2 = list(p2.fetch(limit=10, query="repositories.id:1298"))
assert [w["id"] for w in got2] == [1, 2, 3]
def test_min_chars_threshold_is_meaningful():
assert MIN_CHARS >= 1000
def test_proxy_choice(monkeypatch):
# CORE не отвечает на ключ с российских адресов — по умолчанию идём через
# sing-box, но вне compose-сети прокси отключают пустым значением
monkeypatch.delenv("CORE_PROXY_URL", raising=False)
assert COREParser._proxy(None) == DEFAULT_PROXY
monkeypatch.setenv("CORE_PROXY_URL", "socks5://иной:1080")
assert COREParser._proxy(None) == "socks5://иной:1080"
assert COREParser._proxy("") == "" # явное «без прокси» сильнее окружения

View File

@@ -0,0 +1,93 @@
"""Контракт progress_cb у парсеров: отчёт по страницам и остановка по запросу.
Сеть не трогаем — подменяем HTTP-клиент парсера заглушкой. Проверяем то, на что
опирается заливка: воркер видит рост выборки и может остановить парсер, не
дожидаясь конца (отмена из админки, исчерпанный бюджет времени таска).
"""
from typing import Any
import openalex
from cyberleninka import CyberLeninkaParser
from openalex import _sleep_alive
class FakeResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return self._payload
class FakeClient:
"""Отдаёт бесконечные полные страницы по 10 статей — как большая выдача."""
def __init__(self) -> None:
self.calls = 0
def post(self, url: str, json: dict[str, Any]) -> FakeResponse:
self.calls += 1
start = json["from"]
return FakeResponse({
"articles": [{"id": start + i, "name": f"статья {start + i}"} for i in range(10)]
})
def _parser(monkeypatch) -> CyberLeninkaParser:
p = CyberLeninkaParser()
p.client = FakeClient() # type: ignore[assignment]
monkeypatch.setattr("cyberleninka.RATE_LIMIT_DELAY", 0)
return p
def test_progress_cb_reports_growing_count(monkeypatch):
p = _parser(monkeypatch)
seen: list[int] = []
docs = p.fetch(query="x", limit=30, progress_cb=lambda n: seen.append(n) or True)
assert len(docs) == 30
assert seen == [10, 20, 30]
def test_progress_cb_false_stops_fetch_early(monkeypatch):
p = _parser(monkeypatch)
# Останавливаем после второй страницы — как отмена прогона из админки
docs = p.fetch(query="x", limit=1000, progress_cb=lambda n: n < 20)
assert len(docs) == 20
assert p.client.calls == 2 # type: ignore[attr-defined]
def test_fetch_works_without_callback(monkeypatch):
"""Скрипты заливки зовут парсеры без progress_cb — поведение прежнее."""
p = _parser(monkeypatch)
assert len(p.fetch(query="x", limit=20)) == 20
def test_backoff_sleep_reports_life_and_can_be_interrupted(monkeypatch):
"""Минуты ожидания в backoff OpenAlex — не молчание: тик и шанс остановиться."""
slept: list[float] = []
monkeypatch.setattr(openalex.time, "sleep", lambda s: slept.append(s))
monkeypatch.setattr(openalex, "BACKOFF_TICK_S", 5.0)
ticks: list[int] = []
assert _sleep_alive(20, lambda n: ticks.append(n) or True, fetched=7) is True
assert sum(slept) == 20 and ticks == [7, 7, 7, 7]
slept.clear()
# Останавливаемся на первом же тике — не досыпая оставшиеся 900с
assert _sleep_alive(900, lambda n: False, fetched=7) is False
assert sum(slept) == 5
def test_backoff_sleep_without_callback_just_sleeps(monkeypatch):
slept: list[float] = []
monkeypatch.setattr(openalex.time, "sleep", lambda s: slept.append(s))
assert _sleep_alive(60, None, fetched=0) is True
assert slept == [60]

View File

@@ -0,0 +1,199 @@
"""Парсер русской Википедии из дампа Wikimedia.
Зачем в корпусе: студенты копируют из Википедии чаще, чем из научных статей, а
по объёму связного русского текста ей нет альтернативы среди доступного —
КиберЛенинка блокирует выкачку, eLIBRARY требует договора.
Отличие от остальных парсеров: `fetch` возвращает **генератор**, а не список.
Дамп — 5.6 ГБ и около 2 млн статей, держать их в памяти нельзя, поэтому
`run_parser` читает результат лениво и пишет пачками (см. bulk_writer.py).
Используется **multistream**-вариант дампа: он состоит из независимых bz2-блоков
по 100 статей, и к нему прилагается индекс со смещениями. Это принципиально —
обычный дамп читается только с начала, поэтому каждый следующий прогон
перечитывал всё уже залитое: на 20 тысячах статей это стоило 6 минут из 25
доступных, а на 100 тысячах съело бы весь бюджет и заливка встала бы совсем.
С multistream позиция продолжения — байтовое смещение, и прогон стартует
мгновенно.
Файлы качаются заранее и кладутся туда, где их видит воркер:
B=https://dumps.wikimedia.org/ruwiki/latest
UA="AcademicHelper/1.0 (https://academic.jze9.ru; noreply@jze9.ru)"
curl -L -C - --retry 100 -A "$UA" -o scripts/parsers/ruwiki-multistream.xml.bz2 \\
$B/ruwiki-latest-pages-articles-multistream.xml.bz2
Читать дамп прямо из сети не выйдет: Wikimedia обрывает долгие соединения
(проверено — обрыв на 32 МБ из 5.9 ГБ).
"""
import bz2
import logging
import os
import re
from collections.abc import Iterator
from typing import Any
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
DEFAULT_DUMP = "/parsers/ruwiki-multistream.xml.bz2"
PAGE_RE = re.compile(r"<page>(.*?)</page>", re.DOTALL)
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
ID_RE = re.compile(r"<id>(\d+)</id>")
NS_RE = re.compile(r"<ns>(\d+)</ns>")
TEXT_RE = re.compile(r"<text[^>]*>(.*?)</text>", re.DOTALL)
REDIRECT_RE = re.compile(r"<redirect ")
# Разметку чистим регулярками: для отпечатков нужен связный текст, а не точное
# восстановление вёрстки — полноценный парсер вики-разметки тут неоправдан
TEMPLATE_RE = re.compile(r"\{\{[^{}]*\}\}")
CLEAN_RULES = [
(re.compile(r"\[\[[^\]|]*\|"), ""), # [[ссылка|текст → текст
(re.compile(r"\[\[|\]\]"), ""),
(re.compile(r"<ref[^>]*>.*?</ref>", re.DOTALL), " "),
(re.compile(r"<[^>]+>"), " "),
(re.compile(r"^[*#:;|!].*$", re.MULTILINE), " "), # списки и таблицы
(re.compile(r"^=+.*?=+$", re.MULTILINE), " "), # == заголовки разделов ==
(re.compile(r"'{2,}"), ""),
(re.compile(r"&[a-z]+;"), " "),
(re.compile(r"[ \t]+"), " "),
(re.compile(r"\n{2,}"), "\n"),
]
def clean_wikitext(raw: str) -> str:
"""Убрать вики-разметку, оставив читаемый текст статьи."""
text = raw
for _ in range(3): # шаблоны бывают вложенными
text = TEMPLATE_RE.sub(" ", text)
for rx, repl in CLEAN_RULES:
text = rx.sub(repl, text)
return text.strip()
class WikipediaRuParser(BaseParser):
"""Русская Википедия из локального дампа. Отдаёт статьи потоком."""
source_name = "wikipedia_ru"
# Признак для run_parser: результат читается лениво и пишется пачками,
# а не собирается в список и не идёт через add_document по одному
bulk = True
def fetch( # type: ignore[override]
self,
limit: int = 1000,
min_chars: int = 2000,
dump_path: str | None = None,
start_offset: int = 0,
progress_cb: ProgressCallback | None = None,
**_ignored: Any,
) -> Iterator[dict[str, Any]]:
"""Статьи основного пространства имён из multistream-дампа.
Args:
limit: сколько статей отдать
min_chars: минимальная длина текста — заготовки в корпусе бесполезны
dump_path: путь к дампу (по умолчанию /parsers/ruwiki-multistream.xml.bz2)
start_offset: байтовое смещение в файле, с которого продолжать.
Именно смещение, а не номер статьи: перечитывание дампа с начала
росло линейно и на сотне тысяч статей съедало весь бюджет прогона
progress_cb: см. base.ProgressCallback
"""
path = dump_path or os.environ.get("WIKIPEDIA_DUMP_PATH") or DEFAULT_DUMP
if not os.path.exists(path):
raise FileNotFoundError(
f"дамп Википедии не найден: {path} — скачайте его (см. модуль) "
f"или укажите WIKIPEDIA_DUMP_PATH"
)
given = 0
buf = ""
with open(path, "rb") as fh:
fh.seek(start_offset)
# Смещение блока, из которого пришли уже отданные статьи: его и
# сохраняем как позицию продолжения, чтобы ничего не потерять
block_offset = start_offset
decomp = bz2.BZ2Decompressor()
while given < limit:
part = fh.read(4 * 1024 * 1024)
if not part:
break
# В multistream-дампе потоки идут подряд: закончился один —
# начинаем следующий с того места, где предыдущий остановился
while part:
try:
raw = decomp.decompress(part)
except (OSError, EOFError):
return
if raw:
buf += raw.decode("utf-8", errors="replace")
if not decomp.eof:
break
part = decomp.unused_data
decomp = bz2.BZ2Decompressor()
while given < limit:
m = PAGE_RE.search(buf)
if not m:
break
page, buf = m.group(1), buf[m.end():]
doc = self._page_to_doc(page, min_chars)
if doc is None:
continue
given += 1
doc["dump_offset"] = block_offset
yield doc
if progress_cb and not progress_cb(given):
logger.info("Википедия: выборка остановлена по запросу (%d)", given)
return
# Всё разобранное отдано — следующая позиция продолжения здесь
block_offset = fh.tell() - len(buf.encode("utf-8", errors="ignore")) // 4
if len(buf) > 20 * 1024 * 1024: # страховка от разбухания
buf = buf[-1024 * 1024:]
def _page_to_doc(self, page: str, min_chars: int) -> dict[str, Any] | None:
"""Разобрать <page> в документ; None — страница нам не подходит."""
if REDIRECT_RE.search(page):
return None
ns = NS_RE.search(page)
if not ns or ns.group(1) != "0": # только статьи
return None
tm, im, xm = TITLE_RE.search(page), ID_RE.search(page), TEXT_RE.search(page)
if not (tm and im and xm):
return None
text = clean_wikitext(xm.group(1))
if len(text) < min_chars:
return None
return {"pageid": im.group(1), "title": tm.group(1), "text": text}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Привести статью к унифицированному формату документов корпуса."""
pid = raw.get("pageid")
if not pid:
return {}
return {
"source": self.source_name,
"ext_id": f"wikipedia_ru:{pid}",
"title": raw.get("title", ""),
"authors": [],
"year": None,
"lang": "ru",
"url": f"https://ru.wikipedia.org/?curid={pid}",
"abstract": (raw.get("text") or "")[:2000],
"text": raw.get("text", ""),
"dump_offset": raw.get("dump_offset"),
}

View File

@@ -24,9 +24,10 @@ docker run --rm \
echo '▶ ruff (services/ scripts/)'
ruff check services/ scripts/
echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)'
( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py )
echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг + OAuth + прогресс заливки)'
( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py app/progress.py )
( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py )
( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py )
( cd services/api && mypy --config-file /repo/mypy.ini app/core/oauth.py app/core/progress.py )
"
echo "✅ Линт (ruff + mypy) пройден"

View File

@@ -19,6 +19,7 @@ IMAGE="python:3.11-slim"
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
SERVICES=(
"services/api:"
"services/worker-indexer:"
"services/worker-gost:"
"services/worker-gpu:libgomp1"

View File

@@ -35,6 +35,17 @@ DISCIPLINES = [
"математический анализ", "физика", "химия", "биология", "медицина",
"экология", "лингвистика", "литературоведение", "государственное управление",
"международные отношения", "журналистика", "культурология",
# Прикладные/техникумовские темы — оригинальный список был вузовски-научным,
# тут реальные дипломы СПО/колледжей, которых там не было вовсе.
"полиграфия и печать", "издательское дело", "дизайн", "строительство",
"архитектура", "машиностроение", "электротехника", "логистика", "туризм",
"гостиничное дело", "сестринское дело", "ветеринария", "агрономия",
"пищевая промышленность", "транспорт", "дорожное строительство",
"сварочное производство", "метрология и стандартизация", "реклама и PR",
"физическая культура и спорт", "дошкольное образование", "дефектология",
"банковское дело", "таможенное дело", "документоведение",
"телекоммуникации", "нефтегазовое дело", "энергетика",
"пожарная безопасность", "социальная работа",
]

View File

@@ -0,0 +1,33 @@
"""OAuth-вход (Google/Яндекс): oauth_provider/oauth_id, hashed_password nullable.
Revision ID: 004
Revises: 003
Create Date: 2026-08-24
"""
import sqlalchemy as sa
from alembic import op
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade() -> None:
# OAuth-пользователи не заводят пароль
op.alter_column("users", "hashed_password", nullable=True)
op.add_column("users", sa.Column("oauth_provider", sa.String(20), nullable=True))
op.add_column("users", sa.Column("oauth_id", sa.String(255), nullable=True))
op.create_index(
"ix_users_oauth_provider_id", "users", ["oauth_provider", "oauth_id"], unique=True
)
def downgrade() -> None:
op.drop_index("ix_users_oauth_provider_id", table_name="users")
op.drop_column("users", "oauth_id")
op.drop_column("users", "oauth_provider")
op.alter_column("users", "hashed_password", nullable=False)

View File

@@ -0,0 +1,68 @@
"""Журнал прогонов парсинга (parse_runs) + ссылка на последний прогон источника.
Revision ID: 005
Revises: 004
Create Date: 2026-08-27
Прогресс заливки раньше был не виден: у источника был только last_status
(idle/running/done/error) без «сколько из скольки». Таблица parse_runs хранит
счётчики и структурный журнал каждого прогона — по ним админка рисует шкалу
загрузки и показывает, на чём именно споткнулся источник.
"""
import sqlalchemy as sa
from alembic import op
revision = "005"
down_revision = "004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"parse_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"source_id",
sa.Integer(),
sa.ForeignKey("parse_sources.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("celery_task_id", sa.String(64), nullable=True),
# queued / running / done / partial / error / cancelled
sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
# queued / fetch / index / finished
sa.Column("stage", sa.String(20), nullable=False, server_default="queued"),
sa.Column("target", sa.Integer(), nullable=False, server_default="0"),
sa.Column("fetched", sa.Integer(), nullable=False, server_default="0"),
sa.Column("processed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("added", sa.Integer(), nullable=False, server_default="0"),
sa.Column("duplicates", sa.Integer(), nullable=False, server_default="0"),
# Мусор от источника (нет title/ext_id) — считаем отдельно от ошибок
sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
# Флаг кооперативной отмены: воркер проверяет его на каждом тике прогресса
sa.Column("cancel_requested", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("log", sa.JSON(), nullable=True),
sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_parse_runs_source_id", "parse_runs", ["source_id"])
op.create_index("ix_parse_runs_status", "parse_runs", ["status"])
op.create_index("ix_parse_runs_started_at", "parse_runs", ["started_at"])
# Последний (он же текущий, пока идёт) прогон источника — чтобы список
# источников отдавался одним джойном, без подзапроса «максимальный id».
op.add_column("parse_sources", sa.Column("last_run_id", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("parse_sources", "last_run_id")
op.drop_index("ix_parse_runs_started_at", table_name="parse_runs")
op.drop_index("ix_parse_runs_status", table_name="parse_runs")
op.drop_index("ix_parse_runs_source_id", table_name="parse_runs")
op.drop_table("parse_runs")

View File

@@ -0,0 +1,31 @@
"""Отдельная отметка старта выполнения прогона парсинга.
Revision ID: 006
Revises: 005
Create Date: 2026-08-28
`started_at` пишется в момент СОЗДАНИЯ строки, то есть постановки в очередь.
При массовом запуске очередь разбирается часами, и «длительность» прогона по
двум таймстампам показывала 2.8 часа там, где сам парсинг занял 50 секунд —
для отладки это дезинформация. Момент, когда воркер реально взял задачу,
пишем отдельно; разница со `started_at` — это ожидание в очереди.
"""
import sqlalchemy as sa
from alembic import op
revision = "006"
down_revision = "005"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Для уже прошедших прогонов остаётся NULL: подставлять им started_at
# значило бы выдать время ожидания за время работы.
op.add_column("parse_runs", sa.Column("run_started_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("parse_runs", "run_started_at")

View File

@@ -0,0 +1,29 @@
"""Позиция продолжения массовой заливки в источнике.
Revision ID: 007
Revises: 006
Create Date: 2026-09-05
Массовые источники (дамп Википедии, бакет PMC) заливаются частями: за один
прогон берётся столько статей, сколько влезает в бюджет времени таска. Чтобы
следующий прогон продолжал с того же места, а не перечитывал залитое заново,
позиция сохраняется прямо в источнике: для Википедии это номер статьи в дампе,
для PMC — токен страницы бакета.
"""
import sqlalchemy as sa
from alembic import op
revision = "007"
down_revision = "006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("parse_sources", sa.Column("resume_token", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("parse_sources", "resume_token")

View File

@@ -5,12 +5,17 @@
"""
import contextlib
import io
import logging
from datetime import datetime
import os
import uuid
from datetime import datetime, timedelta
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, select
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -19,7 +24,7 @@ from app.core.celery_app import celery_app
from app.core.minio_client import get_minio
from app.core.redis_client import get_redis
from app.database import get_db
from app.models.admin import ParseSource, StagedWork
from app.models.admin import ParseRun, ParseSource, StagedWork
from app.models.document import Document, Fingerprint
from app.models.task import Task
from app.models.user import User
@@ -30,6 +35,9 @@ from app.schemas.admin import (
AdminTaskResponse,
AdminUserResponse,
AdminUserUpdate,
ParseRunDetail,
ParseRunResponse,
ParseSourceBulkCreate,
ParseSourceCreate,
ParseSourceResponse,
ParseSourceUpdate,
@@ -42,6 +50,13 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)])
# Типы источников, для которых в worker-indexer есть парсер (index.run_parser).
# wikipedia_ru и pmc_bulk — массовые: читают дамп/бакет потоком и пишут пачками,
# продолжая с сохранённой позиции (parse_sources.resume_token)
SOURCE_TYPES = ("openalex", "cyberleninka", "arxiv", "pmc", "wikipedia_ru", "pmc_bulk")
# Прогон в этих статусах ещё может двигаться — повторно запускать источник нельзя
RUN_ACTIVE_STATUSES = ("queued", "running")
# ═══════════════════════════════════════════════════════════════════════════════
# СЕССИЯ ДОСТУПА
@@ -86,6 +101,28 @@ async def verify_session(
# ═══════════════════════════════════════════════════════════════════════════════
# ДАШБОРД / СЕРВИСЫ
# ═══════════════════════════════════════════════════════════════════════════════
async def _rabbitmq_queues() -> dict:
"""Очереди queue.* из management API RabbitMQ: {имя: сырой словарь очереди}.
При недоступности брокера/плагина management возвращает {"error": ...} —
это не повод валить весь дашборд.
"""
try:
# amqp://user:pass@host:port/
creds, _, hostpart = settings.RABBITMQ_URL.split("//", 1)[1].rpartition("@")
rmq_user, _, rmq_pass = creds.partition(":")
rmq_host = hostpart.split(":")[0].split("/")[0]
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(f"http://{rmq_host}:15672/api/queues", auth=(rmq_user, rmq_pass))
if resp.status_code != 200:
return {"error": f"management API вернул {resp.status_code}"}
return {
q["name"]: q for q in resp.json() if str(q.get("name", "")).startswith("queue.")
}
except Exception as e:
return {"error": str(e)[:120]}
@router.get("/health", response_model=list[ServiceHealth])
async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]:
"""Статус всех сервисов инфраструктуры."""
@@ -183,23 +220,12 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
storage = {"error": str(e)[:200]}
# Длины очередей через RabbitMQ management API (если доступно)
queues: dict = {}
try:
rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0]
rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0]
rmq_host = settings.RABBITMQ_URL.split("@")[1].split(":")[0]
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(
f"http://{rmq_host}:15672/api/queues",
auth=(rmq_user, rmq_pass),
rmq = await _rabbitmq_queues()
queues: dict = (
{"error": rmq["error"]}
if "error" in rmq
else {name: q.get("messages", 0) for name, q in rmq.items()}
)
if resp.status_code == 200:
for q in resp.json():
name = q.get("name", "")
if name.startswith("queue."):
queues[name] = q.get("messages", 0)
except Exception as e:
queues = {"error": str(e)[:120]}
return AdminStats(
users_total=users_total,
@@ -423,15 +449,63 @@ async def storage_info() -> dict:
# ═══════════════════════════════════════════════════════════════════════════════
# ИСТОЧНИКИ ПАРСИНГА
# ═══════════════════════════════════════════════════════════════════════════════
async def _attach_runs(
sources: list[ParseSource], db: AsyncSession
) -> list[ParseSourceResponse]:
"""Подтянуть последние прогоны одним запросом (иначе N+1 на 170+ источников)."""
run_ids = [s.last_run_id for s in sources if s.last_run_id]
runs: dict[int, ParseRun] = {}
if run_ids:
rows = (await db.execute(select(ParseRun).where(ParseRun.id.in_(run_ids)))).scalars().all()
runs = {r.id: r for r in rows}
result = []
for s in sources:
item = ParseSourceResponse.model_validate(s)
run = runs.get(s.last_run_id) if s.last_run_id else None
item.last_run = ParseRunResponse.model_validate(run) if run else None
result.append(item)
return result
async def _start_run(src: ParseSource, db: AsyncSession) -> ParseRun:
"""Создать строку прогона и отправить таск парсера.
Строку создаёт API, а не воркер: при массовом запуске 170+ источников
очередь разбирается минутами, и без неё в админке не было бы видно, что
источник уже поставлен в очередь (шкала висела бы на прошлом прогоне).
"""
run = ParseRun(source_id=src.id, status="queued", stage="queued", target=src.limit, log=[])
db.add(run)
await db.flush()
src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.utcnow()
src.last_run_id = run.id
await db.commit()
await db.refresh(run)
celery_result = celery_app.send_task(
"index.run_parser", args=[src.id, run.id], queue="queue.index"
)
run.celery_task_id = celery_result.id
await db.commit()
await db.refresh(run)
return run
@router.get("/sources", response_model=list[ParseSourceResponse])
async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]:
rows = (await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))).scalars().all()
return [ParseSourceResponse.model_validate(s) for s in rows]
rows = (
await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))
).scalars().all()
return await _attach_runs(list(rows), db)
@router.post("/sources", response_model=ParseSourceResponse, status_code=201)
async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse:
if data.source_type not in ("openalex", "cyberleninka", "arxiv"):
if data.source_type not in SOURCE_TYPES:
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
src = ParseSource(**data.model_dump())
db.add(src)
@@ -440,6 +514,44 @@ async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_
return ParseSourceResponse.model_validate(src)
@router.post("/sources/bulk", status_code=201)
async def create_sources_bulk(
data: ParseSourceBulkCreate, db: AsyncSession = Depends(get_db)
) -> dict:
"""Добавить пачку источников одного типа — по одному на каждый запрос-тему."""
if data.source_type not in SOURCE_TYPES:
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
queries = [q.strip() for q in data.queries if q.strip()]
if not queries:
raise HTTPException(status_code=400, detail="Пустой список запросов")
prefix = data.name_prefix or data.source_type
created: list[ParseSource] = []
for q in queries:
src = ParseSource(
source_type=data.source_type,
name=f"{prefix}:{q}"[:255],
query=q,
lang=data.lang,
year_from=data.year_from,
year_to=data.year_to,
limit=data.limit,
)
db.add(src)
created.append(src)
await db.commit()
started = 0
if data.run_now:
for src in created:
await db.refresh(src)
await _start_run(src, db)
started += 1
return {"created": len(created), "started": started}
@router.patch("/sources/{source_id}", response_model=ParseSourceResponse)
async def update_source(
source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db)
@@ -463,19 +575,436 @@ async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)) -> N
await db.commit()
@router.post("/sources/{source_id}/run")
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
@router.post("/sources/run-all")
async def run_all_sources(
source_type: str | None = None,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Запустить заливку по всем включённым источникам (кроме уже идущих)."""
stmt = select(ParseSource).where(ParseSource.enabled.is_(True))
if source_type:
stmt = stmt.where(ParseSource.source_type == source_type)
sources = (await db.execute(stmt.order_by(ParseSource.id))).scalars().all()
active_ids = set(
(
await db.execute(
select(ParseRun.source_id).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
)
).scalars().all()
)
started, skipped = 0, 0
for src in sources:
if src.id in active_ids:
skipped += 1
continue
await _start_run(src, db)
started += 1
logger.info("Массовый запуск источников: старт %d, пропущено %d", started, skipped)
return {"started": started, "skipped_active": skipped, "total": len(sources)}
@router.post("/sources/stop-all")
async def stop_all_sources(db: AsyncSession = Depends(get_db)) -> dict:
"""Отменить все активные прогоны (кооперативно — воркер увидит на своём тике)."""
runs = (
await db.execute(select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)))
).scalars().all()
for run in runs:
await _cancel_run(run, db)
await db.commit()
return {"cancelled": len(runs)}
@router.post("/sources/{source_id}/run", response_model=ParseRunResponse)
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunResponse:
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
if src is None:
raise HTTPException(status_code=404, detail="Источник не найден")
if src.last_status == "running":
active = (
await db.execute(
select(ParseRun).where(
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
)
)
).scalars().first()
if active is not None:
raise HTTPException(status_code=409, detail="Источник уже парсится")
src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.utcnow()
run = await _start_run(src, db)
return ParseRunResponse.model_validate(run)
async def _cancel_run(run: ParseRun, db: AsyncSession) -> None:
"""Отменить прогон: флаг для воркера + revoke, если таск ещё не начали.
Пока таск в очереди — revoke снимает его, и никто уже не переведёт прогон
из queued, поэтому закрываем строку прямо здесь. Начатый прогон трогать
жёстко нельзя (оборвётся посреди записи в базу) — ставим флаг, воркер
остановится сам на ближайшем тике прогресса.
"""
run.cancel_requested = True
if run.status == "queued":
if run.celery_task_id:
with contextlib.suppress(Exception):
celery_app.control.revoke(run.celery_task_id)
run.status = "cancelled"
run.stage = "finished"
run.finished_at = datetime.utcnow()
src = await db.get(ParseSource, run.source_id)
if src and src.last_run_id == run.id:
src.last_status = "cancelled"
@router.post("/sources/{source_id}/cancel")
async def cancel_source_run(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
runs = (
await db.execute(
select(ParseRun).where(
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
)
)
).scalars().all()
if not runs:
raise HTTPException(status_code=404, detail="Активных прогонов нет")
for run in runs:
await _cancel_run(run, db)
await db.commit()
celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index")
return {"status": "started", "source_id": source_id}
return {"cancelled": len(runs), "source_id": source_id}
@router.get("/sources/{source_id}/runs", response_model=list[ParseRunResponse])
async def list_source_runs(
source_id: int,
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
) -> list[ParseRunResponse]:
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.source_id == source_id)
.order_by(ParseRun.started_at.desc())
.limit(limit)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/active", response_model=list[ParseRunResponse])
async def list_active_runs(db: AsyncSession = Depends(get_db)) -> list[ParseRunResponse]:
"""Все прогоны в работе — для панели отладки и общей шкалы заливки."""
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
.order_by(ParseRun.started_at)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/{run_id}", response_model=ParseRunDetail)
async def get_run(run_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunDetail:
run = await db.get(ParseRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Прогон не найден")
detail = ParseRunDetail.model_validate(run)
detail.log = run.log or []
return detail
# ═══════════════════════════════════════════════════════════════════════════════
# ЗАГРУЗКА РАБОТ В КОРПУС
# ═══════════════════════════════════════════════════════════════════════════════
UPLOAD_EXTENSIONS = {".pdf", ".docx", ".txt"}
UPLOAD_MAX_BYTES = 100 * 1024 * 1024
UPLOAD_CONTENT_TYPES = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
}
@router.post("/documents/upload", status_code=202)
async def upload_documents(files: list[UploadFile]) -> dict:
"""Залить готовые работы прямо в базу сравнения (минуя проверку и отстойник).
Отличие от пользовательской загрузки (/documents/check): там файл проверяют
на плагиат, здесь он сам становится источником, с которым будут сравнивать.
"""
if not files:
raise HTTPException(status_code=400, detail="Файлы не переданы")
accepted: list[dict] = []
rejected: list[dict] = []
minio = get_minio()
for file in files:
name = file.filename or "без имени"
ext = Path(name).suffix.lower()
if ext not in UPLOAD_EXTENSIONS:
rejected.append({"filename": name, "reason": f"формат {ext or '—'} не поддержан"})
continue
data = await file.read()
if not data:
rejected.append({"filename": name, "reason": "пустой файл"})
continue
if len(data) > UPLOAD_MAX_BYTES:
rejected.append({"filename": name, "reason": "больше 100 МБ"})
continue
minio_key = f"corpus-upload/{uuid.uuid4()}{ext}"
try:
minio.put_object(
bucket_name=settings.MINIO_BUCKET_DOCS,
object_name=minio_key,
data=io.BytesIO(data),
length=len(data),
content_type=UPLOAD_CONTENT_TYPES.get(ext, "application/octet-stream"),
)
except Exception as e:
logger.error("Загрузка %s в MinIO не удалась: %s", name, e)
rejected.append({"filename": name, "reason": "ошибка сохранения в хранилище"})
continue
celery_app.send_task(
"index.ingest_upload",
args=[minio_key, name],
kwargs={"meta": {"title": Path(name).stem}},
queue="queue.index",
)
accepted.append({"filename": name, "minio_key": minio_key, "bytes": len(data)})
logger.info("Админ залил в корпус: принято %d, отклонено %d", len(accepted), len(rejected))
return {"accepted": accepted, "rejected": rejected}
# ═══════════════════════════════════════════════════════════════════════════════
# ОТЛАДКА
# ═══════════════════════════════════════════════════════════════════════════════
def _vector_index_stats() -> dict:
"""Реальное наполнение векторного индекса — спросить у worker-gpu.
Колонка `documents.faiss_id` для этого не годится: пометка остаётся и когда
вектор в индекс не попал, и когда индекс пересоздали после смены модели
эмбеддингов. Отладка, показывающая покрытие L3 по ней, завышает его в разы.
"""
try:
result = celery_app.send_task("gpu.index_stats", queue="queue.gpu")
return result.get(timeout=10) or {}
except Exception as e:
return {"error": str(e)[:200] or type(e).__name__}
def _celery_snapshot() -> dict:
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
try:
insp = celery_app.control.inspect(timeout=4)
ping = insp.ping() or {}
active = insp.active() or {}
reserved = insp.reserved() or {}
stats = insp.stats() or {}
except Exception as e:
return {"error": str(e)[:200], "workers": []}
workers = []
for name in sorted(set(ping) | set(stats)):
wstats = stats.get(name, {})
workers.append({
"name": name,
"concurrency": (wstats.get("pool") or {}).get("max-concurrency"),
"reserved": len(reserved.get(name, [])),
"active": [
{
"name": t.get("name"),
"id": t.get("id"),
# args целиком не отдаём: в них бывает текст работы целиком
"args": str(t.get("args"))[:120],
"started_ago_s": (
round(datetime.utcnow().timestamp() - t["time_start"])
if t.get("time_start") else None
),
}
for t in active.get(name, [])
],
})
return {"workers": workers}
@router.get("/debug")
async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
"""Полный срез состояния системы для отладки заливки и проверок.
Один запрос вместо похода по пяти вкладкам: кто из воркеров жив и что
именно сейчас крутит, что копится в очередях, как наполняется корпус,
какие прогоны идут и на чём падали последние.
"""
# Воркеры и статистика индекса — блокирующие вызовы Celery, в пул потоков,
# чтобы не вешать событийный цикл
celery_state = await run_in_threadpool(_celery_snapshot)
vector_stats = await run_in_threadpool(_vector_index_stats)
rmq = await _rabbitmq_queues()
queues = (
{"error": rmq["error"]}
if "error" in rmq
else {
name: {
"messages": q.get("messages", 0),
"unacked": q.get("messages_unacknowledged", 0),
"consumers": q.get("consumers", 0),
}
for name, q in sorted(rmq.items())
}
)
# ── Корпус ────────────────────────────────────────────────────────────────
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
docs_embedded = (
await db.execute(
select(func.count()).select_from(Document).where(Document.faiss_id.isnot(None))
)
).scalar_one()
# count(*) по fingerprints — это десятки миллионов строк и секунды ожидания;
# для панели отладки достаточно оценки планировщика
fingerprints_est = (
await db.execute(text("SELECT reltuples::bigint FROM pg_class WHERE relname='fingerprints'"))
).scalar() or 0
es_docs = None
try:
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(f"{settings.ELASTICSEARCH_URL}/documents/_count")
if resp.status_code == 200:
es_docs = resp.json().get("count")
except Exception:
es_docs = None
# ── Источники и прогоны ───────────────────────────────────────────────────
src_rows = (
await db.execute(select(ParseSource.last_status, func.count()).group_by(ParseSource.last_status))
).all()
active_runs = (
await db.execute(
select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)).order_by(ParseRun.id)
)
).scalars().all()
recent_failed_runs = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(("error", "partial")))
.order_by(ParseRun.started_at.desc())
.limit(10)
)
).scalars().all()
# Зависший прогон: числится в работе, но воркер давно не отчитывался
stale_cutoff = datetime.utcnow() - timedelta(minutes=10)
stale_runs = [
r.id for r in active_runs
if r.status == "running" and (r.heartbeat_at is None or r.heartbeat_at < stale_cutoff)
]
# ── Задачи пользователей ──────────────────────────────────────────────────
day_ago = datetime.utcnow() - timedelta(hours=24)
tasks_24h = dict(
(
await db.execute(
select(Task.status, func.count()).where(Task.created_at >= day_ago).group_by(Task.status)
)
).all()
)
failed_tasks = (
await db.execute(
select(Task)
.where(Task.status == "failed")
.order_by(Task.created_at.desc())
.limit(10)
)
).scalars().all()
# Зависшая проверка: числится в работе, но воркер давно её не трогал.
# Пользователь видит вечное «обрабатывается» — молча и без следов в логах,
# поэтому такие задачи должны быть видны в отладке (нашлись экземпляры,
# висевшие с мая).
#
# Возраст считает сама база: колонки в PostgreSQL — timestamptz, а модель
# объявляет их без зоны, и передача сюда Python-времени ломается на
# несовпадении (asyncpg отвергает aware-параметр, naive даёт неверный сдвиг).
stale_tasks = (
await db.execute(
text("""
SELECT public_id, type, created_at,
round(extract(epoch FROM now() - coalesce(updated_at, created_at))
/ 3600.0, 1) AS stuck_hours
FROM tasks
WHERE status = 'processing'
AND coalesce(updated_at, created_at) < now() - interval '2 hours'
ORDER BY created_at DESC
LIMIT 20
""")
)
).all()
return {
"generated_at": datetime.utcnow().isoformat(timespec="seconds"),
"celery": celery_state,
"queues": queues,
"corpus": {
"documents": docs_total,
# Пометка в БД и реальное содержимое индекса расходятся — показываем
# обе величины, иначе покрытие L3 выглядит лучше, чем оно есть
"documents_marked_embedded": docs_embedded,
"vector_index": vector_stats,
"fingerprints_estimate": int(fingerprints_est),
"elasticsearch_documents": es_docs,
},
"sources": {
"by_status": dict(src_rows),
"active_runs": [ParseRunResponse.model_validate(r).model_dump() for r in active_runs],
"stale_run_ids": stale_runs,
"recent_problem_runs": [
ParseRunResponse.model_validate(r).model_dump() for r in recent_failed_runs
],
},
"tasks_24h": tasks_24h,
"recent_failed_tasks": [
{
"public_id": t.public_id,
"type": t.type,
"error": (t.error or "")[:200],
"created_at": t.created_at,
}
for t in failed_tasks
],
"stale_tasks": [
{
"public_id": t.public_id,
"type": t.type,
"created_at": t.created_at,
"stuck_hours": float(t.stuck_hours),
}
for t in stale_tasks
],
# Ключи бэкендов задаются в .env (генерируется из Infisical) и влияют на
# поведение воркеров — при разборе «почему не так считает» нужны первыми
"config": {
"environment": settings.ENVIRONMENT,
"embed_backend": os.environ.get("EMBED_BACKEND", "ollama"),
"llm_backend": os.environ.get("LLM_BACKEND", "ollama"),
"vector_backend": os.environ.get("VECTOR_BACKEND", "faiss"),
"embed_model": os.environ.get("EMBED_MODEL", "—"),
"fetch_full_text": os.environ.get("FETCH_FULL_TEXT", "false"),
"auto_approve_submissions": os.environ.get("AUTO_APPROVE_SUBMISSIONS", "false"),
"parser_time_budget_s": os.environ.get("PARSER_TIME_BUDGET_S", "1500"),
"ollama_url": settings.OLLAMA_URL,
"elasticsearch_url": settings.ELASTICSEARCH_URL,
},
}
# ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -1,13 +1,16 @@
"""Роутер аутентификации: регистрация, вход, верификация email."""
"""Роутер аутентификации: регистрация, вход, верификация email, OAuth."""
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.celery_app import celery_app
from app.core.oauth import OAuthNotConfigured, OAuthUserInfo, exchange_code, get_authorize_url
from app.core.security import (
create_access_token,
get_current_user,
@@ -30,6 +33,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
_OAUTH_PROVIDERS = {"google", "yandex"}
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
@@ -208,15 +213,22 @@ async def resend_verification(
detail="Email уже подтверждён",
)
if not current_user.verification_token:
current_user.verification_token = secrets.token_urlsafe(32)
# current_user из dependency может быть из Redis-кэша (без verification_token
# и не привязан к сессии — commit/refresh на нём не сработают) — грузим "живого"
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
if not user.verification_token:
user.verification_token = secrets.token_urlsafe(32)
await db.commit()
await db.refresh(current_user)
await db.refresh(user)
try:
celery_app.send_task(
"notify.send_verification",
args=[current_user.email, current_user.name, current_user.verification_token],
args=[user.email, user.name, user.verification_token],
queue="queue.notify",
)
except Exception as e:
@@ -247,3 +259,109 @@ async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
await invalidate_user_cache(user.id)
return {"message": "Email успешно подтверждён"}
async def _get_or_create_oauth_user(
db: AsyncSession, provider: str, info: OAuthUserInfo
) -> User:
"""Найти пользователя по (provider, oauth_id), иначе по email (привязка
существующего аккаунта), иначе создать нового без пароля."""
result = await db.execute(
select(User).where(User.oauth_provider == provider, User.oauth_id == info.provider_id)
)
user = result.scalar_one_or_none()
if user:
return user
email = info.email.lower()
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
if not user.oauth_provider:
user.oauth_provider = provider
user.oauth_id = info.provider_id
if info.email_verified:
user.is_verified = True
await db.commit()
await db.refresh(user)
return user
user = User(
email=email,
hashed_password=None,
name=info.name,
is_verified=info.email_verified,
plan="free",
oauth_provider=provider,
oauth_id=info.provider_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.get("/{provider}/login", include_in_schema=False)
async def oauth_login(provider: str) -> RedirectResponse:
"""Редирект на экран согласия Google/Яндекс."""
if provider not in _OAUTH_PROVIDERS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Неизвестный провайдер")
state = secrets.token_urlsafe(24)
try:
url = get_authorize_url(provider, state)
except OAuthNotConfigured as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Вход через {provider} временно недоступен",
) from e
response = RedirectResponse(url)
response.set_cookie(
f"oauth_state_{provider}", state,
httponly=True, secure=True, samesite="lax", max_age=600,
)
return response
@router.get("/{provider}/callback", include_in_schema=False)
async def oauth_callback(
provider: str,
request: Request,
code: str | None = None,
state: str | None = None,
db: AsyncSession = Depends(get_db),
) -> RedirectResponse:
"""Колбэк провайдера: обменять code на профиль, найти/создать юзера, выдать JWT.
Токен передаётся фронту через URL-фрагмент (#token=...) — он не уходит на
сервер при последующих запросах и не попадает в логи/Referer, в отличие от
query-параметра. Фронт (страница /oauth/callback) читает его и вызывает
setAuth, как после обычного /login.
"""
if provider not in _OAUTH_PROVIDERS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Неизвестный провайдер")
expected_state = request.cookies.get(f"oauth_state_{provider}")
if not code or not state or not expected_state or state != expected_state:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Невалидный OAuth-колбэк")
try:
info = await exchange_code(provider, code)
except OAuthNotConfigured as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Вход через {provider} временно недоступен",
) from e
except Exception as e:
logger.warning(f"OAuth {provider}: обмен кода не удался: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Не удалось войти через провайдера"
) from e
user = await _get_or_create_oauth_user(db, provider, info)
access_token = create_access_token({"sub": str(user.id)})
response = RedirectResponse(f"{settings.APP_URL}/oauth/callback#token={access_token}")
response.delete_cookie(f"oauth_state_{provider}")
return response

View File

@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.celery_app import celery_app
from app.core.minio_client import get_minio
from app.core.rate_limiter import acquire_concurrent_slot, check_and_increment_limit
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_verified_user
from app.database import get_db
from app.models.task import Task
@@ -80,8 +80,7 @@ async def upload_for_plagiarism_check(
headers={"Retry-After": "86400"},
)
slot_acquired = await acquire_concurrent_slot(current_user.id, current_user.plan)
if not slot_acquired:
if not await check_concurrent_limit(db, current_user.id, current_user.plan):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач.",

View File

@@ -1,76 +0,0 @@
"""Роутер для получения отчётов о выполненных задачах."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("/{task_id}")
async def get_report(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""
Получить готовый отчёт по задаче.
Возвращает task.result в зависимости от типа задачи:
- search: список источников с ГОСТ-цитатами
- plagiarism: детальный отчёт с совпадениями
- gost: отформатированная библиография
- summarize: краткое изложение
"""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
if task.status == "queued":
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail="Задача ещё в очереди",
)
if task.status == "processing":
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail="Задача ещё выполняется",
)
if task.status == "failed":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Задача завершилась с ошибкой: {task.error}",
)
if task.result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Результат недоступен",
)
return {
"task_id": task.id,
"type": task.type,
"status": task.status,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
"result": task.result,
}

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.celery_app import celery_app
from app.core.rate_limiter import acquire_concurrent_slot, check_and_increment_limit
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_verified_user
from app.database import get_db
from app.models.task import Task
@@ -46,9 +46,8 @@ async def create_search_task(
headers={"Retry-After": "86400"},
)
# Проверяем лимит одновременных задач (атомарно)
slot_acquired = await acquire_concurrent_slot(current_user.id, current_user.plan)
if not slot_acquired:
# Проверяем лимит одновременных задач (по факту в БД)
if not await check_concurrent_limit(db, current_user.id, current_user.plan):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач. Дождитесь завершения текущих.",

View File

@@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.minio_client import get_minio
from app.core.security import get_current_user
from app.database import get_db
from app.models.admin import StagedWork
from app.models.task import Task
from app.models.user import User
from app.schemas.tasks import TaskResponse
@@ -63,13 +65,49 @@ async def get_task(
return TaskResponse.model_validate(task)
@router.get("/{public_id}/text")
async def get_task_text(
public_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Полный текст проверенного документа — для подсветки совпадений в интерфейсе.
Тот же текст, по которому worker-gpu считал position_start/position_end в
отчёте о плагиате (см. StagedWork.text_key — пишется в _stage_work при
извлечении, worker-indexer/app/tasks/index.py). Запись туда — best-effort
(сбой не валит саму проверку), поэтому текст доступен не для всех задач.
"""
result = await db.execute(
select(Task).where(Task.public_id == public_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
staged = (
await db.execute(select(StagedWork).where(StagedWork.task_id == task.id))
).scalar_one_or_none()
if staged is None or not staged.text_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Текст работы недоступен")
try:
obj = get_minio().get_object("staging", staged.text_key)
text = obj.read().decode("utf-8", errors="replace")
except Exception as e:
logger.warning(f"Не удалось прочитать текст задачи {public_id!r}: {e}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Текст работы недоступен") from e
return {"text": text}
@router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
public_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Удалить задачу. Нельзя удалить задачу в статусе 'processing'."""
"""Удалить задачу."""
result = await db.execute(
select(Task).where(
Task.public_id == public_id,
@@ -81,11 +119,5 @@ async def delete_task(
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена")
if task.status == "processing":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Нельзя удалить задачу в процессе выполнения",
)
await db.delete(task)
await db.commit()

View File

@@ -44,6 +44,13 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
# OAuth — вход через Google/Яндекс. Пусто = провайдер выключен (эндпоинты
# отвечают 503, кнопка на фронте не показывается).
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
YANDEX_CLIENT_ID: str = ""
YANDEX_CLIENT_SECRET: str = ""
# SMTP
SMTP_HOST: str = "mail.jze9mail.ru"
SMTP_PORT: int = 587

View File

@@ -0,0 +1,148 @@
"""OAuth2 authorization-code flow для Google и Яндекс — без authlib, только httpx.
Единый интерфейс для обоих провайдеров: get_authorize_url() формирует ссылку на
экран согласия, exchange_code() меняет code на профиль пользователя. Асинхронно
(httpx.AsyncClient) — синхронные вызовы блокировали бы event loop всего API на
время внешнего запроса к Google/Яндекс.
"""
from dataclasses import dataclass
from urllib.parse import urlencode
import httpx
from app.config import settings
_TIMEOUT = 10.0
class OAuthNotConfigured(Exception):
"""Провайдер не настроен (пустой client_id/secret в конфиге)."""
def _raise_for_status_verbose(resp: httpx.Response) -> None:
"""Как raise_for_status(), но с телом ответа в сообщении — у Google/Яндекс
там error/error_description с точной причиной (invalid_client и т.п.),
без этого в логе только код статуса, причина не видна."""
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
raise httpx.HTTPStatusError(
f"{e}: {resp.text[:500]}", request=e.request, response=e.response
) from e
@dataclass
class OAuthUserInfo:
provider_id: str
email: str
name: str
email_verified: bool
def _redirect_uri(provider: str) -> str:
return f"{settings.APP_URL}/api/auth/{provider}/callback"
def _credentials(provider: str) -> tuple[str, str]:
if provider == "google":
client_id, secret = settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET
elif provider == "yandex":
client_id, secret = settings.YANDEX_CLIENT_ID, settings.YANDEX_CLIENT_SECRET
else:
raise ValueError(f"неизвестный OAuth-провайдер: {provider!r}")
if not client_id or not secret:
raise OAuthNotConfigured(provider)
return client_id, secret
def get_authorize_url(provider: str, state: str) -> str:
"""Собрать ссылку на экран согласия провайдера."""
client_id, _ = _credentials(provider)
redirect_uri = _redirect_uri(provider)
if provider == "google":
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"prompt": "select_account",
}
return f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state,
}
return f"https://oauth.yandex.ru/authorize?{urlencode(params)}"
async def exchange_code(provider: str, code: str) -> OAuthUserInfo:
"""Обменять authorization code на профиль пользователя у провайдера."""
client_id, client_secret = _credentials(provider)
redirect_uri = _redirect_uri(provider)
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
if provider == "google":
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
_raise_for_status_verbose(token_resp)
access_token = token_resp.json()["access_token"]
info_resp = await client.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
_raise_for_status_verbose(info_resp)
info = info_resp.json()
email = info["email"]
return OAuthUserInfo(
provider_id=info["sub"],
email=email,
name=info.get("name") or email.split("@")[0],
email_verified=bool(info.get("email_verified", False)),
)
# yandex
token_resp = await client.post(
"https://oauth.yandex.ru/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
},
)
_raise_for_status_verbose(token_resp)
access_token = token_resp.json()["access_token"]
info_resp = await client.get(
"https://login.yandex.ru/info",
params={"format": "json"},
headers={"Authorization": f"OAuth {access_token}"},
)
_raise_for_status_verbose(info_resp)
info = info_resp.json()
email = info.get("default_email") or next(iter(info.get("emails") or []), None)
if not email:
raise ValueError("Яндекс не вернул email — нужно разрешение на доступ к почте")
return OAuthUserInfo(
provider_id=str(info["id"]),
email=email,
name=info.get("real_name") or info.get("display_name") or email.split("@")[0],
email_verified=True, # Яндекс отдаёт только подтверждённые адреса
)

View File

@@ -0,0 +1,26 @@
"""Пересчёт состояния прогона парсинга в шкалу загрузки — чистая логика.
Стадии прогона несопоставимы по единицам (получено из API источника vs.
записано в базу), а шкала в админке нужна одна. Формула живёт здесь одна на
всех, чтобы не разъезжаться между списком источников, карточкой прогона и
панелью отладки.
"""
# Прогоны, после которых двигаться уже некуда
TERMINAL_STATUSES = frozenset({"done", "partial", "error", "cancelled"})
def run_percent(status: str, stage: str, target: int, fetched: int, processed: int) -> float:
"""Процент готовности: 0→50% — выборка из источника, 50→100% — индексация.
Завершённый прогон — всегда 100%, каким бы ни был исход: шкала показывает
«работа окончена», а исход — статус рядом с ней. Пока цель неизвестна
(target=0), выборка не даёт прогресса — рисуем 0.
"""
if status in TERMINAL_STATUSES:
return 100.0
fetch_part = min(fetched / target, 1.0) * 50 if target > 0 else 0.0
if stage != "index":
return round(fetch_part, 1)
index_part = min(processed / fetched, 1.0) * 50 if fetched > 0 else 0.0
return round(fetch_part + index_part, 1)

View File

@@ -1,16 +1,27 @@
"""Redis-based rate limiter с атомарными Lua-скриптами.
"""Rate limiter: дневные/месячные лимиты — в Redis, лимит одновременных задач — в БД.
Дневные/месячные лимиты (Redis, атомарные Lua-скрипты):
Проблема наивного подхода (GET → проверка → INCR):
- Race condition: 10 конкурентных запросов могут одновременно пройти GET,
увидеть значение ниже лимита и все инкрементировать.
Решение: один Lua-скрипт выполняется атомарно на стороне Redis.
Redis гарантирует, что между командами внутри скрипта нет других операций.
Лимит одновременных задач — НЕ Redis-счётчик. Раньше был acquire/release-счётчик
(INCR при создании задачи, DECR при завершении) — но release_concurrent_slot()
никогда не вызывался ни из одного воркера, так что счётчик только рос и лимит
превышался навсегда (до часового TTL-автосброса), даже когда все задачи юзера
давно завершены. Вместо ручного счётчика, который может рассинхронизироваться
с реальностью, считаем активные задачи прямо по Task.status в Postgres —
рассинхронизации тогда не может быть в принципе.
"""
import logging
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.redis_client import get_redis
logger = logging.getLogger(__name__)
@@ -74,24 +85,6 @@ end
return {new_val, 1}
"""
# Атомарная проверка + инкремент счётчика одновременных задач.
# Возвращает 1 если слот получен, 0 если превышен лимит.
_LUA_ACQUIRE_CONCURRENT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
return 0
end
redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return 1
"""
def _period_suffix(period: str) -> str:
now = datetime.now(UTC)
return now.strftime("%Y-%m-%d") if period == "day" else now.strftime("%Y-%m")
@@ -136,33 +129,27 @@ async def check_and_increment_limit(
}
async def acquire_concurrent_slot(user_id: int, plan: str) -> bool:
async def check_concurrent_limit(db: AsyncSession, user_id: int, plan: str) -> bool:
"""
Атомарно захватить слот одновременной задачи.
Проверить лимит одновременных задач по факту в БД (queued/processing).
Возможен редкий race (два запроса одновременно оба видят N-1 активных и
оба проходят) — на практике не критично для этого лимита (защита от
злоупотребления, не от превышения на единицу), а взамен исключён класс
багов "счётчик разошёлся с реальностью и завис навсегда".
Returns:
True — слот получен (задачу можно создавать).
False — все слоты заняты.
True — лимит не превышен, задачу можно создавать.
"""
from app.models.task import Task # избегаем circular import на уровне модуля
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
max_concurrent = limits.get("concurrent", 1)
r = get_redis()
result = await r.eval(
_LUA_ACQUIRE_CONCURRENT,
1,
f"concurrent:{user_id}",
max_concurrent,
3_600, # TTL 1 час — автосброс если воркер упал не освободив слот
result = await db.execute(
select(func.count())
.select_from(Task)
.where(Task.user_id == user_id, Task.status.in_(("queued", "processing")))
)
return bool(result)
async def release_concurrent_slot(user_id: int) -> None:
"""Освободить слот одновременной задачи после завершения."""
r = get_redis()
key = f"concurrent:{user_id}"
# DECR безопасен: Redis не уходит в отрицательные значения если мы контролируем acquire
current = await r.get(key)
if current and int(current) > 0:
await r.decr(key)
current = result.scalar_one()
return current < max_concurrent

View File

@@ -2,6 +2,7 @@
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -29,7 +30,11 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
def verify_password(plain: str, hashed: str | None) -> bool:
# OAuth-пользователи не имеют пароля (hashed_password=None) — попытка
# войти паролем должна отвечать "неверный пароль", а не падать 500-й.
if hashed is None:
return False
return pwd_context.verify(plain, hashed)
@@ -61,6 +66,28 @@ def _decode_token(token: str) -> int:
) from None
@dataclass
class CachedUser:
"""Пользователь из Redis-кэша — обычный dataclass, НЕ SQLAlchemy-модель.
Раньше здесь был User.__new__(User) + __dict__.update(data) — казалось
эквивалентом, но замапленные атрибуты User (id, email, ...) — дескрипторы
данных: их __get__ обращается к self.impl, который берётся из InstanceState,
а у объекта, созданного в обход __init__/ORM-машинерии, состояния нет.
Итог — AttributeError на любое обращение к полю из кэша (нашли на
GET /tasks/{id}: current_user.id падал 500-й, как только юзер брался не
из БД, а из кэша). Обычный dataclass с теми же именами полей — просто
plain-атрибуты, без дескрипторов, падать нечему.
"""
id: int
email: str
name: str
plan: str
is_verified: bool
is_admin: bool
async def _load_user(user_id: int, db: AsyncSession):
"""Загрузить пользователя из Redis-кэша или из БД."""
from app.models.user import User # избегаем circular import на уровне модуля
@@ -71,12 +98,7 @@ async def _load_user(user_id: int, db: AsyncSession):
# Пробуем кэш
cached = await r.get(cache_key)
if cached:
data = json.loads(cached)
# Возвращаем "живой" объект из БД только по id, но без лишнего SELECT
# Создаём User без ORM-связей (достаточно для проверок в роутерах)
u = User.__new__(User)
u.__dict__.update(data)
return u
return CachedUser(**json.loads(cached))
# Кэш пустой — идём в БД
result = await db.execute(select(User).where(User.id == user_id))

View File

@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_fastapi_instrumentator import Instrumentator
from app.api import admin, auth, documents, reports, search, tasks
from app.api import admin, auth, documents, search, tasks
from app.config import settings
from app.core.minio_client import ensure_bucket
from app.core.redis_client import close_pool
@@ -78,7 +78,6 @@ app.include_router(auth.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(documents.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
# ─── Метрики Prometheus ───────────────────────────────────────────────────────

View File

@@ -1,8 +1,8 @@
"""Модели для админ-панели: источники парсинга, отстойник работ, сессии админа."""
"""Модели для админ-панели: источники парсинга, прогоны, отстойник, сессии админа."""
from datetime import datetime
from sqlalchemy import JSON, ForeignKey, String, func
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -33,12 +33,63 @@ class ParseSource(Base):
last_error: Mapped[str | None] = mapped_column(nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
docs_added: Mapped[int] = mapped_column(default=0)
# Последний (пока идёт — текущий) прогон, см. ParseRun
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
# Позиция продолжения для массовых источников: номер статьи в дампе
# Википедии или токен страницы бакета PMC
resume_token: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
def __repr__(self) -> str:
return f"<ParseSource id={self.id} type={self.source_type!r} name={self.name!r}>"
class ParseRun(Base):
"""Один прогон парсинга источника: счётчики прогресса + журнал событий.
Пишется воркером (index.run_parser) на каждом тике прогресса, читается
админкой для шкалы загрузки и отладки — по нему видно не только «упало»,
но и где именно: на какой стадии, сколько получено/проиндексировано,
сколько дублей и ошибок отдельных документов.
"""
__tablename__ = "parse_runs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("parse_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
celery_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# queued / running / done / partial / error / cancelled
status: Mapped[str] = mapped_column(String(20), default="queued", index=True)
# queued / fetch / index / finished
stage: Mapped[str] = mapped_column(String(20), default="queued")
# Цель прогона (limit источника) и фактические счётчики
target: Mapped[int] = mapped_column(default=0)
fetched: Mapped[int] = mapped_column(default=0)
processed: Mapped[int] = mapped_column(default=0)
added: Mapped[int] = mapped_column(default=0)
duplicates: Mapped[int] = mapped_column(default=0)
# Мусор от источника (без title/ext_id) — не то же самое, что ошибка
skipped: Mapped[int] = mapped_column(default=0)
failed: Mapped[int] = mapped_column(default=0)
# Кооперативная отмена: воркер сам проверяет флаг между документами
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{"ts": ISO8601, "level": "info|warning|error", "msg": str}]
log: Mapped[list | None] = mapped_column(JSON, default=list)
# Постановка в очередь (строку создаёт API) и реальный старт выполнения:
# между ними при массовом запуске проходят часы, мешать их нельзя
started_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
run_started_at: Mapped[datetime | None] = mapped_column(nullable=True)
# Последний признак жизни: по нему видно зависший прогон (running, но тишина)
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<ParseRun id={self.id} source={self.source_id} status={self.status!r}>"
class StagedWork(Base):
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.

View File

@@ -20,8 +20,13 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Nullable: пользователи, вошедшие через OAuth, пароля не заводят
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
# OAuth-провайдер ("google" / "yandex") и id пользователя у провайдера.
# Пара уникальна (см. миграцию 004) — один аккаунт провайдера не привязать дважды.
oauth_provider: Mapped[str | None] = mapped_column(String(20), nullable=True)
oauth_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_verified: Mapped[bool] = mapped_column(default=False)
# Администратор системы (доступ к админ-панели)
is_admin: Mapped[bool] = mapped_column(default=False)

View File

@@ -3,7 +3,9 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, computed_field
from app.core.progress import run_percent
# ─── Сессия доступа ───────────────────────────────────────────────────────────
@@ -61,9 +63,66 @@ class AdminDocumentResponse(BaseModel):
model_config = {"from_attributes": True}
# ─── Прогоны парсинга ─────────────────────────────────────────────────────────
class ParseRunResponse(BaseModel):
"""Состояние прогона для шкалы загрузки (без журнала — он в ParseRunDetail)."""
id: int
source_id: int
status: str
stage: str
target: int
fetched: int
processed: int
added: int
duplicates: int
skipped: int
failed: int
cancel_requested: bool = False
error: str | None = None
# started_at — постановка в очередь, run_started_at — реальный старт работы
started_at: datetime
run_started_at: datetime | None = None
heartbeat_at: datetime | None = None
finished_at: datetime | None = None
model_config = {"from_attributes": True}
@computed_field # type: ignore[prop-decorator]
@property
def percent(self) -> float:
return run_percent(self.status, self.stage, self.target, self.fetched, self.processed)
@computed_field # type: ignore[prop-decorator]
@property
def queued_s(self) -> float | None:
"""Сколько прогон ждал своей очереди, сек."""
if self.run_started_at is None:
return None
return round((self.run_started_at - self.started_at).total_seconds(), 1)
@computed_field # type: ignore[prop-decorator]
@property
def duration_s(self) -> float | None:
"""Сколько прогон реально работал, сек (None — ещё идёт или не начинался).
Прогоны до появления run_started_at (миграция 006) остаются без
длительности: у них известен только момент постановки в очередь.
"""
if self.run_started_at is None or self.finished_at is None:
return None
return round((self.finished_at - self.run_started_at).total_seconds(), 1)
class ParseRunDetail(ParseRunResponse):
log: list[dict[str, Any]] = Field(default_factory=list)
# ─── Источники парсинга ───────────────────────────────────────────────────────
class ParseSourceCreate(BaseModel):
source_type: str = Field(description="openalex / cyberleninka / arxiv")
source_type: str = Field(
description="openalex / cyberleninka / arxiv / pmc / wikipedia_ru / pmc_bulk"
)
name: str = Field(min_length=1, max_length=255)
query: str | None = None
lang: str | None = None
@@ -98,10 +157,27 @@ class ParseSourceResponse(BaseModel):
last_run_at: datetime | None = None
docs_added: int
created_at: datetime
# Последний (или текущий) прогон — источник данных для шкалы в списке
last_run: ParseRunResponse | None = None
model_config = {"from_attributes": True}
class ParseSourceBulkCreate(BaseModel):
"""Пакетное добавление: один тип/лимит, много запросов (по строке на тему)."""
source_type: str = Field(
description="openalex / cyberleninka / arxiv / pmc / wikipedia_ru / pmc_bulk"
)
queries: list[str] = Field(min_length=1, max_length=500)
name_prefix: str = ""
lang: str | None = None
year_from: int | None = None
year_to: int | None = None
limit: int = Field(default=1000, ge=1, le=100000)
run_now: bool = False
# ─── Отстойник ────────────────────────────────────────────────────────────────
class StagedWorkResponse(BaseModel):
id: int

6
services/api/conftest.py Normal file
View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

3
services/api/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,4 @@
# Зависимости для юнит-тестов api (чистая логика OAuth URL-билдера, без сети/БД).
pytest==8.2.0
pydantic-settings==2.2.1
httpx==0.27.0

View File

@@ -0,0 +1,75 @@
"""Юнит-тесты сборки OAuth-ссылок (app.core.oauth) — чистая логика, без сети."""
from urllib.parse import parse_qs, urlparse
import pytest
from app.config import settings
from app.core.oauth import OAuthNotConfigured, _redirect_uri, get_authorize_url
@pytest.fixture(autouse=True)
def _clear_oauth_settings(monkeypatch):
"""По умолчанию оба провайдера не настроены — как на чистой инсталляции."""
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "")
monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "")
monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "")
def test_redirect_uri_matches_registered_pattern():
assert _redirect_uri("google") == f"{settings.APP_URL}/api/auth/google/callback"
assert _redirect_uri("yandex") == f"{settings.APP_URL}/api/auth/yandex/callback"
def test_not_configured_raises(monkeypatch):
with pytest.raises(OAuthNotConfigured):
get_authorize_url("google", "state123")
with pytest.raises(OAuthNotConfigured):
get_authorize_url("yandex", "state123")
def test_unknown_provider_raises_value_error(monkeypatch):
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret")
with pytest.raises(ValueError):
get_authorize_url("facebook", "state123")
def test_google_authorize_url_structure(monkeypatch):
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "my-client-id")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "my-secret")
url = get_authorize_url("google", "the-state")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "accounts.google.com"
assert qs["client_id"] == ["my-client-id"]
assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/google/callback"]
assert qs["response_type"] == ["code"]
assert qs["state"] == ["the-state"]
assert "email" in qs["scope"][0]
def test_yandex_authorize_url_structure(monkeypatch):
monkeypatch.setattr(settings, "YANDEX_CLIENT_ID", "my-yandex-id")
monkeypatch.setattr(settings, "YANDEX_CLIENT_SECRET", "my-yandex-secret")
url = get_authorize_url("yandex", "the-state")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "oauth.yandex.ru"
assert qs["client_id"] == ["my-yandex-id"]
assert qs["redirect_uri"] == [f"{settings.APP_URL}/api/auth/yandex/callback"]
assert qs["state"] == ["the-state"]
def test_state_is_url_encoded_safely(monkeypatch):
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "id")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret")
# state со спецсимволами не должен ломать ссылку
url = get_authorize_url("google", "abc&def=1")
qs = parse_qs(urlparse(url).query)
assert qs["state"] == ["abc&def=1"]

View File

@@ -0,0 +1,86 @@
"""Юнит-тесты шкалы загрузки и таймингов прогонов — чистая логика, без БД."""
from datetime import datetime
from app.core.progress import run_percent
from app.schemas.admin import ParseRunResponse
def test_queued_run_shows_nothing_done():
assert run_percent("queued", "queued", target=100, fetched=0, processed=0) == 0.0
def test_fetch_stage_fills_first_half():
assert run_percent("running", "fetch", target=100, fetched=50, processed=0) == 25.0
assert run_percent("running", "fetch", target=100, fetched=100, processed=0) == 50.0
def test_index_stage_fills_second_half():
# Выборка закончена (50%), проиндексирована половина полученного → 75%
assert run_percent("running", "index", target=100, fetched=100, processed=50) == 75.0
assert run_percent("running", "index", target=100, fetched=100, processed=100) == 100.0
def test_partial_fetch_does_not_inflate_index_progress():
"""Источник отдал меньше лимита: выборка даёт свои 20%, индексация — свои."""
percent = run_percent("running", "index", target=100, fetched=40, processed=20)
assert percent == 45.0 # 20% за выборку + 25% за половину индексации
def test_unknown_target_gives_zero_instead_of_division_error():
assert run_percent("running", "fetch", target=0, fetched=17, processed=0) == 0.0
assert run_percent("running", "index", target=0, fetched=0, processed=0) == 0.0
def test_overshoot_is_clamped():
"""Источник может вернуть больше лимита — шкала не должна уезжать за 100%."""
assert run_percent("running", "fetch", target=10, fetched=99, processed=0) == 50.0
assert run_percent("running", "index", target=10, fetched=10, processed=99) == 100.0
def test_finished_runs_are_always_full():
"""Шкала показывает «работа окончена», исход виден по статусу рядом."""
for status in ("done", "partial", "error", "cancelled"):
assert run_percent(status, "finished", target=100, fetched=3, processed=1) == 100.0
# ─── Тайминги прогона в схеме ответа ─────────────────────────────────────────
# Регрессия, ради которой они и разделены: в отладке «длительность прогона»
# показывала время ожидания в очереди (часы) вместо времени работы (секунды).
def _run(**over) -> ParseRunResponse:
base = {
"id": 1, "source_id": 1, "status": "done", "stage": "finished", "target": 100,
"fetched": 100, "processed": 100, "added": 10, "duplicates": 90,
"skipped": 0, "failed": 0,
"started_at": datetime(2026, 8, 27, 12, 0, 0),
"run_started_at": datetime(2026, 8, 27, 14, 0, 0),
"finished_at": datetime(2026, 8, 27, 14, 0, 50),
}
base.update(over)
return ParseRunResponse(**base)
def test_queued_and_duration_are_measured_separately():
r = _run()
assert r.queued_s == 7200.0 # два часа в очереди
assert r.duration_s == 50.0 # полминуты работы
def test_no_durations_until_worker_picked_run_up():
r = _run(status="queued", stage="queued", run_started_at=None, finished_at=None)
assert r.queued_s is None
assert r.duration_s is None
def test_running_run_has_wait_but_no_duration_yet():
r = _run(status="running", stage="index", finished_at=None)
assert r.queued_s == 7200.0
assert r.duration_s is None
def test_legacy_runs_report_no_duration_instead_of_queue_time():
"""Прогоны до миграции 006: длительности нет — но и вранья тоже."""
r = _run(run_started_at=None)
assert r.duration_s is None

View File

@@ -58,6 +58,9 @@ export const tasksApi = {
get: (taskId: string) =>
api.get(`/tasks/${taskId}`),
getText: (taskId: string) =>
api.get(`/tasks/${taskId}/text`),
delete: (taskId: string) =>
api.delete(`/tasks/${taskId}`),
};
@@ -83,11 +86,6 @@ export const documentsApi = {
},
};
export const reportsApi = {
get: (taskId: string) =>
api.get(`/reports/${taskId}`),
};
// ─── Админка ────────────────────────────────────────────────────────────────
export const adminApi = {
openSession: () => api.post('/admin/session'),
@@ -116,9 +114,28 @@ export const adminApi = {
sources: () => api.get('/admin/sources'),
createSource: (data: Record<string, unknown>) => api.post('/admin/sources', data),
createSourcesBulk: (data: Record<string, unknown>) => api.post('/admin/sources/bulk', data),
updateSource: (id: number, data: Record<string, unknown>) => api.patch(`/admin/sources/${id}`, data),
deleteSource: (id: number) => api.delete(`/admin/sources/${id}`),
runSource: (id: number) => api.post(`/admin/sources/${id}/run`),
cancelSource: (id: number) => api.post(`/admin/sources/${id}/cancel`),
runAllSources: (sourceType?: string) =>
api.post('/admin/sources/run-all', null, { params: sourceType ? { source_type: sourceType } : undefined }),
stopAllSources: () => api.post('/admin/sources/stop-all'),
sourceRuns: (id: number) => api.get(`/admin/sources/${id}/runs`),
activeRuns: () => api.get('/admin/runs/active'),
run: (runId: number) => api.get(`/admin/runs/${runId}`),
debug: () => api.get('/admin/debug'),
uploadDocuments: (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append('files', f));
return api.post('/admin/documents/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // пачка файлов грузится дольше одиночной проверки
});
},
staging: (params?: { status?: string; limit?: number; offset?: number }) =>
api.get('/admin/staging', { params }),

View File

@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { tasksApi } from '../api/client';
import type { PlagiarismMatch, PlagiarismRecommendation } from '../types';
interface HighlightedDocumentProps {
taskId: string;
matches: PlagiarismMatch[];
recommendations?: PlagiarismRecommendation[];
}
type HighlightKind = 'uncited' | 'cited' | 'recommendation';
interface HighlightRange {
start: number;
end: number;
kind: HighlightKind;
label: string;
}
interface Segment {
text: string;
kind: HighlightKind | null;
label: string;
}
// Приоритет при пересечении диапазонов — некорректное заимствование важнее
// цитаты, цитата важнее просто «похоже по теме».
const KIND_PRIORITY: Record<HighlightKind, number> = { uncited: 3, cited: 2, recommendation: 1 };
const KIND_STYLE: Record<HighlightKind, string> = {
uncited: 'bg-red-200/70 text-red-900',
cited: 'bg-blue-100 text-blue-900',
recommendation: 'bg-teal-100 text-teal-900',
};
function buildSegments(text: string, ranges: HighlightRange[]): Segment[] {
if (!text) return [];
if (ranges.length === 0) return [{ text, kind: null, label: '' }];
const points = new Set<number>([0, text.length]);
for (const r of ranges) {
points.add(Math.max(0, Math.min(r.start, text.length)));
points.add(Math.max(0, Math.min(r.end, text.length)));
}
const bounds = Array.from(points).sort((a, b) => a - b);
const segments: Segment[] = [];
for (let i = 0; i < bounds.length - 1; i++) {
const segStart = bounds[i];
const segEnd = bounds[i + 1];
if (segStart >= segEnd) continue;
const covering = ranges.filter((r) => r.start < segEnd && r.end > segStart);
if (covering.length === 0) {
segments.push({ text: text.slice(segStart, segEnd), kind: null, label: '' });
continue;
}
covering.sort((a, b) => KIND_PRIORITY[b.kind] - KIND_PRIORITY[a.kind]);
const label = Array.from(new Set(covering.map((c) => c.label))).join(' · ');
segments.push({ text: text.slice(segStart, segEnd), kind: covering[0].kind, label });
}
return segments;
}
export function HighlightedDocument({ taskId, matches, recommendations }: HighlightedDocumentProps) {
const [open, setOpen] = useState(true);
const { data: text, isLoading, isError } = useQuery({
queryKey: ['task-text', taskId],
queryFn: () => tasksApi.getText(taskId).then((r) => r.data.text as string),
enabled: open,
retry: false,
staleTime: Infinity,
});
const ranges = useMemo<HighlightRange[]>(() => {
const fromMatches: HighlightRange[] = matches.map((m) => ({
start: m.position_start,
end: m.position_end,
kind: m.cited ? 'cited' : 'uncited',
label: `${m.source_title} — ${m.similarity.toFixed(0)}%`,
}));
const fromRecs: HighlightRange[] = (recommendations || []).map((r) => ({
start: r.position_start,
end: r.position_end,
kind: 'recommendation',
label: `Похоже по теме: ${r.source_title} — ${r.similarity.toFixed(0)}%`,
}));
return [...fromMatches, ...fromRecs];
}, [matches, recommendations]);
const segments = useMemo(() => (text ? buildSegments(text, ranges) : []), [text, ranges]);
return (
<div className="border border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-sm font-medium text-gray-700"
>
Текст работы с подсветкой совпадений
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{open && (
<div className="p-4">
{isLoading && (
<div className="flex items-center gap-2 text-sm text-gray-400 py-6 justify-center">
<Loader2 className="w-4 h-4 animate-spin" /> Загрузка текста...
</div>
)}
{isError && (
<p className="text-sm text-gray-400 py-6 text-center">
Текст работы для этой проверки недоступен.
</p>
)}
{!!text && (
<>
<div className="flex flex-wrap items-center gap-4 mb-3 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-red-200/70 inline-block" /> заимствование
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-blue-100 inline-block" /> цитата
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-teal-100 inline-block" /> похоже по теме
</span>
</div>
{ranges.length === 0 && (
<p className="text-xs text-gray-400 mb-3">
В этой проверке подсвечивать нечего — совпадений и похожих по теме источников не найдено.
</p>
)}
<div className="text-sm leading-relaxed text-gray-800 whitespace-pre-wrap max-h-[600px] overflow-y-auto pr-1">
{segments.map((seg, i) =>
seg.kind ? (
<span key={i} title={seg.label} className={`${KIND_STYLE[seg.kind]} rounded px-0.5 cursor-help`}>
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
)
)}
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
// Кнопки входа через Google/Яндекс — обычные <a>, не React Router Link:
// это реальная навигация браузера на бэкенд (/api/auth/{provider}/login),
// который редиректит на экран согласия провайдера, а не SPA-переход.
export function OAuthButtons() {
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-gray-200" />
<span className="text-xs text-gray-400">или</span>
<div className="h-px flex-1 bg-gray-200" />
</div>
<a
href="/api/auth/google/login"
className="w-full flex items-center justify-center gap-2 py-2.5 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Продолжить с Google
</a>
<a
href="/api/auth/yandex/login"
className="w-full flex items-center justify-center gap-2 py-2.5 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Продолжить с Яндекс
</a>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react';
import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types';
import { HighlightedDocument } from './HighlightedDocument';
interface PlagiarismReportProps {
data: PlagiarismResultData;
taskId: string;
}
function getSimilarityLevel(pct: number): {
@@ -46,12 +48,14 @@ const METHOD_LABELS: Record<string, string> = {
'semantic+llm': 'Семантика + LLM',
};
export function PlagiarismReport({ data }: PlagiarismReportProps) {
export function PlagiarismReport({ data, taskId }: PlagiarismReportProps) {
const level = getSimilarityLevel(data.overall_similarity);
const Icon = level.icon;
return (
<div className="space-y-6">
<HighlightedDocument taskId={taskId} matches={data.matches} recommendations={data.recommendations} />
{/* Итоговый показатель */}
<div className={clsx('p-6 rounded-xl border-2', level.bgColor, level.borderColor)}>
<div className="flex items-center gap-4">
@@ -173,6 +177,49 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
<p className="text-base font-medium">Совпадений не обнаружено</p>
</div>
)}
{/* Рекомендации по теме — тематически близкие работы, не заимствование */}
{!!data.recommendations?.length && (
<div>
<h3 className="flex items-center gap-1.5 text-base font-semibold text-gray-900 mb-1">
<Lightbulb className="w-4 h-4 text-blue-500" />
Похожие по теме работы ({data.recommendations.length})
</h3>
<p className="text-sm text-gray-500 mb-3">
Не заимствование — семантический поиск нашёл близкие по смыслу источники.
Могут пригодиться для раскрытия темы и списка литературы.
</p>
<div className="space-y-3">
{data.recommendations.map((rec, i) => (
<div key={i} className="border border-blue-100 bg-blue-50/40 rounded-xl overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-blue-100">
<span className="text-sm font-medium text-gray-700 flex-1 truncate">
{rec.source_title}
</span>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
{rec.similarity.toFixed(0)}% по смыслу
</span>
</div>
<div className="px-4 py-3">
<p className="text-sm text-gray-700 italic leading-relaxed line-clamp-2">
«{rec.fragment}»
</p>
{rec.source_url && (
<a
href={rec.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-600 hover:underline mt-1.5 inline-block"
>
Открыть источник →
</a>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { clsx } from 'clsx';
/** Цвет шкалы = исход прогона: серый пока ждёт, синий в работе, дальше по итогу. */
const BAR_COLORS: Record<string, string> = {
queued: 'bg-gray-300',
running: 'bg-brand-500',
done: 'bg-emerald-500',
partial: 'bg-amber-500',
cancelled: 'bg-gray-400',
error: 'bg-red-500',
};
interface ProgressBarProps {
percent: number;
status?: string;
/** Подпись слева под шкалой (что именно сейчас происходит) */
label?: string;
/** Показывать процент справа */
showPercent?: boolean;
className?: string;
}
export function ProgressBar({
percent,
status = 'running',
label,
showPercent = true,
className,
}: ProgressBarProps) {
const value = Math.max(0, Math.min(100, percent));
return (
<div className={clsx('w-full', className)}>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-[width] duration-500', BAR_COLORS[status] || 'bg-brand-500')}
style={{ width: `${value}%` }}
/>
</div>
{(label || showPercent) && (
<div className="flex justify-between mt-1 text-[11px] text-gray-500">
<span className="truncate">{label}</span>
{showPercent && <span className="tabular-nums shrink-0">{value.toFixed(0)}%</span>}
</div>
)}
</div>
);
}

View File

@@ -1,13 +1,14 @@
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Search, FileText, BookOpen, AlignLeft, ChevronRight } from 'lucide-react';
import { Search, FileText, BookOpen, AlignLeft, ChevronRight, Trash2 } from 'lucide-react';
import { clsx } from 'clsx';
import { StatusBadge } from './StatusBadge';
import type { Task } from '../types';
interface TaskCardProps {
task: Task;
onDelete?: (publicId: string) => void;
}
const TYPE_CONFIG = {
@@ -63,7 +64,7 @@ function getTaskSummary(task: Task): string {
return 'Результат готов';
}
export function TaskCard({ task }: TaskCardProps) {
export function TaskCard({ task, onDelete }: TaskCardProps) {
const config = TYPE_CONFIG[task.type] || TYPE_CONFIG.search;
const Icon = config.icon;
const summary = getTaskSummary(task);
@@ -94,6 +95,21 @@ export function TaskCard({ task }: TaskCardProps) {
</p>
</div>
{/* Удалить */}
{onDelete && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Удалить задачу?')) onDelete(task.public_id);
}}
title="Удалить"
className="p-1.5 rounded-lg text-gray-300 hover:text-red-600 hover:bg-red-50 flex-shrink-0 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{/* Стрелка */}
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-brand-500 flex-shrink-0 transition-colors" />
</div>

View File

@@ -15,6 +15,7 @@ import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login';
import { Register } from './pages/Register';
import { OAuthCallback } from './pages/OAuthCallback';
import { VerifyEmail } from './pages/VerifyEmail';
import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard';
@@ -24,6 +25,7 @@ import { Documents as AdminDocuments } from './pages/admin/Documents';
import { Storage as AdminStorage } from './pages/admin/Storage';
import { Sources as AdminSources } from './pages/admin/Sources';
import { Staging as AdminStaging } from './pages/admin/Staging';
import { Debug as AdminDebug } from './pages/admin/Debug';
import './index.css';
@@ -51,6 +53,7 @@ function PublicApp() {
<Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/verify-email/:token" element={<VerifyEmail />} />
</Routes>
</Layout>
@@ -75,6 +78,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<Route path="storage" element={<AdminStorage />} />
<Route path="sources" element={<AdminSources />} />
<Route path="staging" element={<AdminStaging />} />
<Route path="debug" element={<AdminDebug />} />
<Route path="*" element={<Dashboard />} />
</Routes>
</AdminLayout>

View File

@@ -1,5 +1,5 @@
import { Navigate, useNavigate, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Crown, Search, Shield, BookOpen, ShieldAlert, Settings as SettingsIcon } from 'lucide-react';
import toast from 'react-hot-toast';
import { TaskCard } from '../components/TaskCard';
@@ -17,6 +17,16 @@ const PLAN_BADGE_STYLES = {
export function Cabinet() {
const { isAuthenticated, user } = useAuthStore();
const navigate = useNavigate();
const qc = useQueryClient();
const deleteTask = useMutation({
mutationFn: (publicId: string) => tasksApi.delete(publicId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] });
toast.success('Задача удалена');
},
onError: () => toast.error('Не удалось удалить задачу'),
});
const openAdmin = async () => {
try {
@@ -144,7 +154,7 @@ export function Cabinet() {
{tasks && tasks.length > 0 && (
<div className="space-y-2">
{tasks.map((task) => (
<TaskCard key={task.public_id} task={task} />
<TaskCard key={task.public_id} task={task} onDelete={(id) => deleteTask.mutate(id)} />
))}
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
import { GraduationCap } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { OAuthButtons } from '../components/OAuthButtons';
import { useAuthStore } from '../store/auth';
import type { TokenResponse } from '../types';
@@ -78,6 +79,10 @@ export function Login() {
</button>
</form>
<div className="mt-4">
<OAuthButtons />
</div>
<p className="text-center text-sm text-gray-400 mt-6">
Нет аккаунта?{' '}
<Link to="/register" className="text-brand-600 hover:underline font-medium">

View File

@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { api } from '../api/client';
import { useAuthStore } from '../store/auth';
import type { User } from '../types';
// Токен приходит в URL-фрагменте (#token=...) после редиректа с бэкенда
// (см. app/api/auth.py::oauth_callback) — фрагмент не уходит на сервер и не
// попадает в логи/Referer, в отличие от query-параметра.
export function OAuthCallback() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
useEffect(() => {
const params = new URLSearchParams(window.location.hash.slice(1));
const token = params.get('token');
if (!token) {
toast.error('Не удалось войти — токен не получен');
navigate('/login');
return;
}
// Токен ещё не в сторе — передаём явным заголовком в обход interceptor'а
api
.get('/auth/me', { headers: { Authorization: `Bearer ${token}` } })
.then((response) => {
const user: User = response.data;
setAuth(user, token);
toast.success(`Добро пожаловать, ${user.name}!`);
navigate('/cabinet');
})
.catch(() => {
toast.error('Не удалось войти — попробуйте ещё раз');
navigate('/login');
});
}, [navigate, setAuth]);
return (
<div className="max-w-md mx-auto pt-16 text-center text-gray-500 text-sm">
Выполняется вход…
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
import { GraduationCap } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { OAuthButtons } from '../components/OAuthButtons';
import { useAuthStore } from '../store/auth';
import type { TokenResponse } from '../types';
@@ -92,6 +93,10 @@ export function Register() {
</button>
</form>
<div className="mt-4">
<OAuthButtons />
</div>
<p className="text-center text-sm text-gray-400 mt-6">
Уже есть аккаунт?{' '}
<Link to="/login" className="text-brand-600 hover:underline font-medium">

View File

@@ -104,7 +104,7 @@ export function Task() {
<h2 className="text-base font-semibold text-gray-900 mb-4">
Результат проверки плагиата
</h2>
<PlagiarismReport data={task.result as PlagiarismResultData} />
<PlagiarismReport data={task.result as PlagiarismResultData} taskId={taskId} />
</div>
)}

View File

@@ -3,6 +3,7 @@ import { NavLink, useParams, useNavigate, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
LayoutDashboard, Users, FileText, Database, HardDrive, Download, Inbox, LogOut, ShieldAlert,
Bug,
} from 'lucide-react';
import { clsx } from 'clsx';
import { adminApi } from '../../api/client';
@@ -16,6 +17,7 @@ const NAV = [
{ to: 'storage', label: 'Хранилище', icon: HardDrive },
{ to: 'sources', label: 'Источники', icon: Download },
{ to: 'staging', label: 'Отстойник', icon: Inbox },
{ to: 'debug', label: 'Отладка', icon: Bug },
];
interface AdminLayoutProps {

View File

@@ -0,0 +1,329 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Activity, AlertTriangle, CheckCircle2, Cpu, Database, Layers, RefreshCw,
Server, Settings2, ListTree, XCircle,
} from 'lucide-react';
import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
interface Health { name: string; ok: boolean; detail?: string }
interface ActiveTask { name: string; id: string; args: string; started_ago_s: number | null }
interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] }
interface Run {
id: number; source_id: number; status: string; stage: string; target: number;
fetched: number; processed: number; added: number; duplicates: number;
skipped: number; failed: number; error: string | null; percent: number;
started_at: string; run_started_at: string | null; heartbeat_at: string | null;
queued_s: number | null; duration_s: number | null;
}
interface DebugData {
generated_at: string;
celery: { workers: Worker[]; error?: string };
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
corpus: {
documents: number; documents_marked_embedded: number;
vector_index: { backend?: string; vectors?: number; dim?: number; embed_model?: string; error?: string };
fingerprints_estimate: number; elasticsearch_documents: number | null;
};
sources: {
by_status: Record<string, number>;
active_runs: Run[];
stale_run_ids: number[];
recent_problem_runs: Run[];
};
tasks_24h: Record<string, number>;
recent_failed_tasks: { public_id: string; type: string; error: string; created_at: string }[];
config: Record<string, string>;
}
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди', fetch: 'выборка', index: 'индексация', finished: 'завершено',
};
function fmtNum(n: number | null | undefined): string {
return n == null ? '—' : n.toLocaleString('ru-RU');
}
/** Сколько идущий прогон уже работает — по нему видно застрявший. */
function runningFor(run: Run): string {
if (!run.run_started_at) return 'ещё не начат';
const sec = Math.max(0, (Date.now() - new Date(run.run_started_at + 'Z').getTime()) / 1000);
return sec < 90 ? `${Math.round(sec)}с` : `${Math.round(sec / 60)} мин`;
}
export function Debug() {
const { data, isFetching, error } = useQuery({
queryKey: ['admin-debug'],
queryFn: () => adminApi.debug().then((r) => r.data as DebugData),
refetchInterval: 5000,
});
// Отдельным запросом: когда отваливается инфраструктура (брокер, Ollama),
// отладка должна первой показывать ЧТО именно недоступно, а не только следствия
const { data: health } = useQuery({
queryKey: ['admin-health'],
queryFn: () => adminApi.health().then((r) => r.data as Health[]),
refetchInterval: 10000,
});
if (error || !data) {
return (
<div className="space-y-4">
{error
? <div className="text-red-600 text-sm">Не удалось получить срез состояния: {String(error)}</div>
: <div className="text-gray-400 text-sm">Сбор данных…</div>}
<HealthCard health={health} />
</div>
);
}
const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
const queues = queuesErr ? {} : (data.queues as Record<string, { messages: number; unacked: number; consumers: number }>);
// Покрытие L3 считаем по РЕАЛЬНОМУ содержимому индекса: пометка faiss_id в БД
// остаётся и когда вектор туда не попал, и завышает картину в разы
const vectors = data.corpus.vector_index?.vectors ?? null;
const embedPercent = data.corpus.documents && vectors != null
? (vectors / data.corpus.documents) * 100
: 0;
const staleMarks = vectors != null
? Math.max(0, data.corpus.documents_marked_embedded - vectors)
: 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
Отладка
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
</h1>
<span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span>
</div>
<HealthCard health={health} />
{/* Активные прогоны заливки */}
<Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}>
{data.sources.stale_run_ids.length > 0 && (
<div className="mb-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
Прогоны без признаков жизни больше 10 минут: {data.sources.stale_run_ids.join(', ')}.
Обычно это упавший или перезапущенный worker-indexer — проверьте его логи и очередь queue.index.
</span>
</div>
)}
{data.sources.active_runs.length ? (
<div className="space-y-3">
{data.sources.active_runs.map((r) => (
<div key={r.id} className="flex items-center gap-4">
<div className="w-52 shrink-0 text-sm text-gray-600 truncate">
#{r.id} · источник {r.source_id}
<span className="text-gray-400"> · {runningFor(r)}</span>
</div>
<ProgressBar
percent={r.percent}
status={r.status}
label={`${STAGE_LABELS[r.stage] || r.stage} · получено ${r.fetched}/${r.target} · +${r.added}`}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Сейчас ничего не заливается.</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(data.sources.by_status).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
</div>
</Card>
{/* Воркеры */}
<Card icon={Cpu} title="Воркеры Celery">
{data.celery.error && <p className="text-sm text-red-600 mb-2">{data.celery.error}</p>}
{data.celery.workers.length ? (
<div className="space-y-4">
{data.celery.workers.map((w) => (
<div key={w.name}>
<div className="flex items-baseline justify-between mb-1">
<span className="font-medium text-gray-800 text-sm">{w.name}</span>
<span className="text-xs text-gray-500">
параллельно: {w.concurrency ?? '—'} · в работе: {w.active.length} · зарезервировано: {w.reserved}
</span>
</div>
{w.active.length ? (
<div className="border border-gray-100 rounded-lg divide-y divide-gray-50 text-xs font-mono">
{w.active.map((t) => (
<div key={t.id} className="px-3 py-1.5 flex gap-3">
<span className="text-gray-400 tabular-nums shrink-0">
{t.started_ago_s != null ? `${t.started_ago_s}с` : '—'}
</span>
<span className="text-gray-800 shrink-0">{t.name}</span>
<span className="text-gray-400 truncate">{t.args}</span>
</div>
))}
</div>
) : <p className="text-xs text-gray-400">простаивает</p>}
</div>
))}
</div>
) : <p className="text-sm text-gray-400">Воркеры не отвечают на ping.</p>}
</Card>
{/* Очереди */}
<Card icon={Activity} title="Очереди RabbitMQ">
{queuesErr ? (
<p className="text-sm text-red-600">{queuesErr}</p>
) : (
<table className="w-full text-sm">
<thead className="text-gray-500 text-left">
<tr><th className="py-1">Очередь</th><th className="py-1">Ждут</th><th className="py-1">В работе</th><th className="py-1">Потребителей</th></tr>
</thead>
<tbody className="divide-y divide-gray-50">
{Object.entries(queues).map(([name, q]) => (
<tr key={name}>
<td className="py-1.5 text-gray-800">{name}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.messages)}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.unacked)}</td>
<td className={`py-1.5 tabular-nums ${q.consumers ? 'text-gray-600' : 'text-red-600'}`}>{q.consumers}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* Корпус */}
<Card icon={Database} title="Корпус сравнения">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Metric label="Документов" value={fmtNum(data.corpus.documents)} />
<Metric label="Векторов в индексе" value={fmtNum(vectors)} />
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
</div>
{data.corpus.vector_index?.error && (
<p className="text-xs text-red-600 mb-2">
индекс недоступен: {data.corpus.vector_index.error}
</p>
)}
<ProgressBar
percent={embedPercent}
status={embedPercent >= 99 ? 'done' : 'partial'}
label={`покрытие L3 (реально в индексе ${data.corpus.vector_index?.backend || '—'}): ` +
`без вектора ${fmtNum(Math.max(0, data.corpus.documents - (vectors ?? 0)))} документов`}
/>
{staleMarks > 0 && (
<div className="mt-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
У {fmtNum(staleMarks)} документов в базе стоит отметка faiss_id, но вектора в индексе нет —
обычно это след пересоздания индекса после смены модели эмбеддингов.
Такие документы не участвуют в семантическом поиске и не будут пересчитаны,
пока отметку не сбросить: <code>scripts/ops/faiss_reconcile.py</code>.
</span>
</div>
)}
</Card>
{/* Проблемные прогоны */}
{data.sources.recent_problem_runs.length > 0 && (
<Card icon={AlertTriangle} title="Последние проблемные прогоны">
<div className="space-y-2 text-sm">
{data.sources.recent_problem_runs.map((r) => (
<div key={r.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-gray-50 pb-2 last:border-0">
<span className="text-gray-800">#{r.id}</span>
<span className="text-gray-500">источник {r.source_id}</span>
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
<span className="text-gray-500">
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
{r.duration_s != null && ` · работал ${Math.round(r.duration_s)}с`}
</span>
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
</div>
))}
</div>
</Card>
)}
{/* Задачи пользователей */}
<Card icon={ListTree} title="Проверки за 24 часа">
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(data.tasks_24h).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
{!Object.keys(data.tasks_24h).length && <span className="text-sm text-gray-400">задач не было</span>}
</div>
{data.recent_failed_tasks.length > 0 && (
<div className="text-xs space-y-1">
<div className="text-gray-500 mb-1">Последние упавшие:</div>
{data.recent_failed_tasks.map((t) => (
<div key={t.public_id} className="flex gap-3">
<span className="text-gray-400 shrink-0">{new Date(t.created_at).toLocaleString('ru-RU')}</span>
<span className="text-gray-700 shrink-0">{t.type}</span>
<span className="text-red-600 font-mono truncate">{t.error || '—'}</span>
</div>
))}
</div>
)}
</Card>
{/* Конфигурация */}
<Card icon={Settings2} title="Конфигурация бэкендов">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 text-sm">
{Object.entries(data.config).map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 border-b border-gray-50 py-1">
<span className="text-gray-500">{k}</span>
<span className="text-gray-800 font-mono truncate" title={v}>{v}</span>
</div>
))}
</div>
</Card>
</div>
);
}
/** Что из инфраструктуры доступно прямо сейчас — первый вопрос при разборе аварии. */
function HealthCard({ health }: { health?: Health[] }) {
return (
<Card icon={Server} title="Инфраструктура">
{health?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{health.map((h) => (
<div key={h.name} className="flex items-center justify-between gap-3 py-1 border-b border-gray-50">
<span className="flex items-center gap-2 text-sm text-gray-700">
{h.ok
? <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0" />}
{h.name}
</span>
<span className={`text-xs truncate ${h.ok ? 'text-gray-400' : 'text-red-500'}`} title={h.detail}>
{h.detail || (h.ok ? 'OK' : 'недоступен')}
</span>
</div>
))}
</div>
) : <p className="text-sm text-gray-400">Опрос сервисов…</p>}
</Card>
);
}
function Card({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-400" /> {title}
</h2>
{children}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xl font-bold text-gray-900 tabular-nums">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}

View File

@@ -1,60 +1,209 @@
import { useState } from 'react';
import { Fragment, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react';
import {
Play, Trash2, Plus, Square, PlayCircle, StopCircle, ChevronDown, ChevronRight,
Upload, Layers, RefreshCw,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
interface Run {
id: number; source_id: number; status: string; stage: string;
target: number; fetched: number; processed: number;
added: number; duplicates: number; skipped: number; failed: number;
cancel_requested: boolean; error: string | null;
started_at: string; run_started_at: string | null;
heartbeat_at: string | null; finished_at: string | null;
percent: number; queued_s: number | null; duration_s: number | null;
}
interface LogEntry { ts: string; elapsed: number; level: string; msg: string }
interface RunDetail extends Run { log: LogEntry[] }
interface Source {
id: number; source_type: string; name: string; query: string | null;
lang: string | null; year_from: number | null; year_to: number | null;
limit: number; enabled: boolean; last_status: string; last_error: string | null;
last_run_at: string | null; docs_added: number;
last_run_at: string | null; docs_added: number; last_run: Run | null;
}
const SOURCE_TYPES = [
{ value: 'openalex', label: 'OpenAlex' },
{ value: 'cyberleninka', label: 'КиберЛенинка' },
{ value: 'arxiv', label: 'arXiv' },
{ value: 'pmc', label: 'PubMed Central (по теме)' },
// Массовые: читают дамп/бакет потоком и продолжают с места остановки,
// поэтому запускаются повторно до исчерпания источника
{ value: 'wikipedia_ru', label: 'Википедия ru (дамп, массово)' },
{ value: 'pmc_bulk', label: 'PubMed Central (бакет, массово)' },
];
const STATUS_COLORS: Record<string, string> = {
idle: 'bg-gray-100 text-gray-600',
queued: 'bg-gray-100 text-gray-600',
running: 'bg-blue-100 text-blue-700',
done: 'bg-emerald-100 text-emerald-700',
partial: 'bg-amber-100 text-amber-700',
cancelled: 'bg-gray-200 text-gray-600',
error: 'bg-red-100 text-red-700',
};
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди',
fetch: 'выборка из источника',
index: 'индексация в базу',
finished: 'завершено',
};
const ACTIVE = ['queued', 'running'];
/** Что происходит с источником прямо сейчас — подпись под шкалой. */
function runLabel(run: Run): string {
if (run.stage === 'fetch') return `${STAGE_LABELS.fetch}: ${run.fetched}/${run.target}`;
if (run.stage === 'index') return `${STAGE_LABELS.index}: ${run.processed}/${run.fetched}`;
return `+${run.added} новых · ${run.duplicates} дублей${run.failed ? ` · ${run.failed} ошибок` : ''}`;
}
export function Sources() {
const qc = useQueryClient();
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 });
const { data: sources } = useQuery({
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 1000 });
const [bulk, setBulk] = useState({ open: false, source_type: 'openalex', queries: '', lang: '', limit: 1000, run_now: false });
const [expanded, setExpanded] = useState<number | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { data: sources, isFetching } = useQuery({
queryKey: ['admin-sources'],
queryFn: () => adminApi.sources().then((r) => r.data as Source[]),
refetchInterval: 5000,
refetchInterval: 3000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin-sources'] });
const fail = (e: any) => toast.error(e.response?.data?.detail || 'Ошибка');
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSource(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Источник добавлен'); setForm({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
onSuccess: () => {
invalidate();
toast.success('Источник добавлен');
setForm({ source_type: form.source_type, name: '', query: '', lang: '', limit: form.limit });
},
onError: fail,
});
const createBulk = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSourcesBulk(data),
onSuccess: (r) => {
invalidate();
toast.success(`Добавлено источников: ${r.data.created}${r.data.started ? `, запущено ${r.data.started}` : ''}`);
setBulk({ ...bulk, queries: '' });
},
onError: fail,
});
const run = useMutation({
mutationFn: (id: number) => adminApi.runSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Парсинг запущен'); },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'),
onSuccess: () => { invalidate(); toast.success('Парсинг запущен'); },
onError: fail,
});
const cancel = useMutation({
mutationFn: (id: number) => adminApi.cancelSource(id),
onSuccess: () => { invalidate(); toast.success('Остановка запрошена'); },
onError: fail,
});
const runAll = useMutation({
mutationFn: () => adminApi.runAllSources(),
onSuccess: (r) => {
invalidate();
toast.success(`Запущено ${r.data.started} из ${r.data.total} (уже шли: ${r.data.skipped_active})`);
},
onError: fail,
});
const stopAll = useMutation({
mutationFn: () => adminApi.stopAllSources(),
onSuccess: (r) => { invalidate(); toast.success(`Остановка ${r.data.cancelled} прогонов запрошена`); },
onError: fail,
});
const del = useMutation({
mutationFn: (id: number) => adminApi.deleteSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Удалён'); },
onSuccess: () => { invalidate(); toast.success('Удалён'); },
onError: fail,
});
const upload = useMutation({
mutationFn: (files: File[]) => adminApi.uploadDocuments(files),
onSuccess: (r) => {
const { accepted, rejected } = r.data;
toast.success(`Принято файлов: ${accepted.length}${rejected.length ? `, отклонено ${rejected.length}` : ''}`);
rejected.forEach((x: { filename: string; reason: string }) => toast.error(`${x.filename}: ${x.reason}`));
},
onError: fail,
});
const list = sources || [];
const active = list.filter((s) => s.last_run && ACTIVE.includes(s.last_run.status));
const activeAdded = active.reduce((sum, s) => sum + (s.last_run?.added || 0), 0);
// Общая шкала = средняя готовность идущих прогонов: столько заливки осталось
const overall = active.length
? active.reduce((sum, s) => sum + (s.last_run?.percent || 0), 0) / active.length
: 0;
return (
<div className="space-y-5">
<h1 className="text-2xl font-bold text-gray-900">Источники парсинга</h1>
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
Источники парсинга
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
</h1>
<div className="flex gap-2">
<button
onClick={() => { if (confirm(`Запустить заливку по всем включённым источникам (${list.length})?`)) runAll.mutate(); }}
disabled={runAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<PlayCircle className="w-4 h-4" /> Запустить всё
</button>
<button
onClick={() => { if (confirm('Остановить все идущие прогоны?')) stopAll.mutate(); }}
disabled={!active.length || stopAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-40"
>
<StopCircle className="w-4 h-4" /> Остановить всё
</button>
</div>
</div>
{/* Общий прогресс заливки */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="font-semibold text-gray-800">Заливка корпуса</h2>
<span className="text-sm text-gray-500">
активных источников: <b className="text-gray-800">{active.length}</b> из {list.length}
{active.length > 0 && <> · добавлено в этом заходе: <b className="text-gray-800">{activeAdded}</b></>}
</span>
</div>
<ProgressBar
percent={overall}
status={active.length ? 'running' : 'done'}
label={active.length ? `${active.length} прогонов в работе` : 'все прогоны завершены'}
/>
</div>
{/* Форма добавления */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-3">Добавить источник</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-800">Добавить источник</h2>
<button
onClick={() => setBulk({ ...bulk, open: !bulk.open })}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
>
<Layers className="w-4 h-4" /> {bulk.open ? 'обычное добавление' : 'пакетно (много тем)'}
</button>
</div>
{!bulk.open ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<select value={form.source_type} onChange={(e) => setForm({ ...form, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
<option value="openalex">OpenAlex</option>
<option value="cyberleninka">КиберЛенинка</option>
<option value="arxiv">arXiv</option>
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
@@ -74,6 +223,74 @@ export function Sources() {
>
<Plus className="w-4 h-4" /> Добавить
</button>
</>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<select value={bulk.source_type} onChange={(e) => setBulk({ ...bulk, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={bulk.lang} onChange={(e) => setBulk({ ...bulk, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={bulk.limit} onChange={(e) => setBulk({ ...bulk, limit: Number(e.target.value) })} placeholder="Лимит на тему"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" checked={bulk.run_now} onChange={(e) => setBulk({ ...bulk, run_now: e.target.checked })} />
запустить сразу
</label>
</div>
<textarea
value={bulk.queries}
onChange={(e) => setBulk({ ...bulk, queries: e.target.value })}
placeholder={'Одна тема на строку:\nмашинное обучение\nэкономика труда\nгражданское право'}
rows={5}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono"
/>
<button
onClick={() => {
const queries = bulk.queries.split('\n').map((q) => q.trim()).filter(Boolean);
if (!queries.length) { toast.error('Добавьте хотя бы одну тему'); return; }
createBulk.mutate({
source_type: bulk.source_type, queries, lang: bulk.lang || null,
limit: bulk.limit, run_now: bulk.run_now,
});
}}
disabled={createBulk.isPending}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<Plus className="w-4 h-4" /> Добавить пачкой
</button>
</>
)}
</div>
{/* Загрузка готовых работ в базу сравнения */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-1">Загрузить работы в базу</h2>
<p className="text-sm text-gray-500 mb-3">
Файлы (PDF, DOCX, TXT) попадают прямо в базу сравнения — с ними будут сверяться проверяемые работы.
Это не проверка на плагиат.
</p>
<input
ref={fileInput}
type="file"
multiple
accept=".pdf,.docx,.txt"
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length) upload.mutate(files);
e.target.value = '';
}}
/>
<button
onClick={() => fileInput.current?.click()}
disabled={upload.isPending}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<Upload className="w-4 h-4" /> {upload.isPending ? 'Загрузка…' : 'Выбрать файлы'}
</button>
</div>
{/* Список */}
@@ -81,34 +298,138 @@ export function Sources() {
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-3 py-3 w-8"></th>
<th className="px-4 py-3">Название</th><th className="px-4 py-3">Тип</th>
<th className="px-4 py-3">Запрос</th><th className="px-4 py-3">Лимит</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Добавлено</th><th className="px-4 py-3"></th>
<th className="px-4 py-3">Лимит</th>
<th className="px-4 py-3 min-w-[220px]">Прогресс</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Всего</th><th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{sources?.map((s) => (
<tr key={s.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800">{s.name}</td>
{list.map((s) => {
const r = s.last_run;
const isActive = !!r && ACTIVE.includes(r.status);
return (
<Fragment key={s.id}>
<tr className="hover:bg-gray-50">
<td className="px-3 py-3">
<button onClick={() => setExpanded(expanded === s.id ? null : s.id)}
className="text-gray-400 hover:text-gray-600" title="Журнал прогона">
{expanded === s.id ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
</td>
<td className="px-4 py-3 text-gray-800">
{s.name}
{s.query && <div className="text-xs text-gray-400 truncate max-w-[220px]">{s.query}</div>}
</td>
<td className="px-4 py-3 text-gray-600">{s.source_type}</td>
<td className="px-4 py-3 text-gray-500 max-w-[160px] truncate">{s.query || '—'}</td>
<td className="px-4 py-3 text-gray-600">{s.limit}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`} title={s.last_error || ''}>{s.last_status}</span>
{r ? <ProgressBar percent={r.percent} status={r.status} label={runLabel(r)} />
: <span className="text-xs text-gray-400">не запускался</span>}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`}
title={s.last_error || ''}>{s.last_status}</span>
</td>
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
<td className="px-4 py-3 flex gap-1">
<button onClick={() => run.mutate(s.id)} disabled={s.last_status === 'running'} title="Запустить"
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded disabled:opacity-40"><Play className="w-4 h-4" /></button>
<td className="px-4 py-3">
<div className="flex gap-1">
{isActive ? (
<button onClick={() => cancel.mutate(s.id)} title="Остановить"
className="p-1.5 text-gray-400 hover:text-amber-600 rounded"><Square className="w-4 h-4" /></button>
) : (
<button onClick={() => run.mutate(s.id)} title="Запустить"
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded"><Play className="w-4 h-4" /></button>
)}
<button onClick={() => { if (confirm('Удалить источник?')) del.mutate(s.id); }} title="Удалить"
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
{expanded === s.id && (
<tr>
<td colSpan={8} className="bg-gray-50 px-6 py-4">
<RunLog runId={r?.id} live={isActive} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
{!sources?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
{!list.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
</div>
</div>
);
}
/** Секунды → «45с» / «12 мин» / «2 ч 5 мин»: в отладке важен порядок, не точность. */
function fmtDuration(seconds: number): string {
if (seconds < 90) return `${Math.round(seconds)}с`;
const min = Math.round(seconds / 60);
if (min < 90) return `${min} мин`;
return `${Math.floor(min / 60)} ч ${min % 60} мин`;
}
const LOG_COLORS: Record<string, string> = {
error: 'text-red-600',
warning: 'text-amber-600',
info: 'text-gray-600',
};
/** Журнал последнего прогона источника — что именно происходило по шагам. */
function RunLog({ runId, live }: { runId?: number; live: boolean }) {
const { data, isLoading } = useQuery({
queryKey: ['admin-run', runId],
queryFn: () => adminApi.run(runId!).then((r) => r.data as RunDetail),
enabled: !!runId,
refetchInterval: live ? 3000 : false,
});
if (!runId) return <div className="text-sm text-gray-400">Источник ещё не запускался.</div>;
if (isLoading || !data) return <div className="text-sm text-gray-400">Загрузка журнала…</div>;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<Chip label="прогон" value={`#${data.id}`} />
<Chip label="стадия" value={STAGE_LABELS[data.stage] || data.stage} />
<Chip label="получено" value={`${data.fetched}/${data.target}`} />
<Chip label="обработано" value={String(data.processed)} />
<Chip label="добавлено" value={String(data.added)} />
<Chip label="дублей" value={String(data.duplicates)} />
{data.skipped > 0 && <Chip label="без метаданных" value={String(data.skipped)} />}
{data.failed > 0 && <Chip label="ошибок" value={String(data.failed)} />}
<Chip label="поставлен в очередь" value={new Date(data.started_at).toLocaleString('ru-RU')} />
{data.queued_s != null && <Chip label="ждал очереди" value={fmtDuration(data.queued_s)} />}
{data.duration_s != null && <Chip label="работал" value={fmtDuration(data.duration_s)} />}
{data.finished_at && <Chip label="завершён" value={new Date(data.finished_at).toLocaleString('ru-RU')} />}
</div>
{data.error && (
<div className="text-xs text-red-700 bg-red-50 border border-red-100 rounded-lg p-3 font-mono break-all">
{data.error}
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg max-h-64 overflow-y-auto font-mono text-xs">
{data.log?.length ? data.log.map((e, i) => (
<div key={i} className="px-3 py-1 border-b border-gray-50 last:border-0 flex gap-3">
<span className="text-gray-400 shrink-0 tabular-nums">+{e.elapsed.toFixed(0)}с</span>
<span className={`${LOG_COLORS[e.level] || 'text-gray-600'} break-all`}>{e.msg}</span>
</div>
)) : <div className="px-3 py-2 text-gray-400">Записей нет.</div>}
</div>
</div>
);
}
function Chip({ label, value }: { label: string; value: string }) {
return (
<span className="px-2 py-1 bg-white border border-gray-200 rounded-lg text-gray-600">
{label}: <b className="text-gray-800">{value}</b>
</span>
);
}

View File

@@ -62,6 +62,16 @@ export interface PlagiarismMatch {
cited?: boolean;
}
export interface PlagiarismRecommendation {
fragment: string;
position_start: number;
position_end: number;
similarity: number;
source_title: string;
source_url: string | null;
source_db: string;
}
export interface PlagiarismResultData {
overall_similarity: number;
// Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат.
@@ -77,6 +87,10 @@ export interface PlagiarismResultData {
fuzzy: number;
semantic_llm: number;
};
// Тематически близкие работы, которые LLM не подтвердила как заимствование —
// не плагиат, но кандидаты для раскрытия/развития темы. Опционально: старые
// задачи в БД посчитаны без этого поля.
recommendations?: PlagiarismRecommendation[];
}
// ─── Библиография ─────────────────────────────────────────────────────────────

View File

@@ -28,4 +28,11 @@ COPY . .
# Директория для FAISS индекса (монтируется как volume)
RUN mkdir -p /data/index
# -c 1 обязателен, пока VECTOR_BACKEND=faiss: индекс — синглтон в памяти процесса
# (app/faiss_manager.py). При -c N Celery форкает N процессов, у каждого своя копия
# индекса, и save() каждого затирает чужие векторы в общем файле — эмбеддинги
# теряются молча. Параллельность тут всё равно не нужна: Ollama занимает все ядра
# сервера обработкой одного запроса (замер: 2.29 req/s при конкурентности 1 против
# 2.48 при 16). Поднимать -c имеет смысл только вместе с VECTOR_BACKEND=qdrant,
# который допускает конкурентную запись.
CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.gpu", "-c", "1", "-n", "gpu@%h", "--loglevel=info"]

View File

@@ -9,7 +9,7 @@ celery_app = Celery(
"worker_gpu",
broker=settings.RABBITMQ_URL,
backend=settings.REDIS_URL,
include=["app.tasks.search", "app.tasks.plagiarism"],
include=["app.tasks.search", "app.tasks.plagiarism", "app.tasks.stats"],
)
celery_app.conf.update(

View File

@@ -38,15 +38,36 @@ class Settings(BaseSettings):
# Ollama
OLLAMA_URL: str = "http://ollama:11434"
# LLM_BACKEND: "ollama" (локальная модель, нужен GPU-хост) или "openrouter"
# (облачный API, дешёвая модель — для L4-проверки парафраза "умность" не
# критична, это не творческая задача). Переключается без изменения кода.
LLM_BACKEND: str = "ollama"
OPENROUTER_API_KEY: str = ""
OPENROUTER_MODEL: str = "deepseek/deepseek-chat"
OPENROUTER_URL: str = "https://openrouter.ai/api/v1/chat/completions"
# OpenRouter блокирует запросы из РФ — заворачиваем именно эти запросы
# через sing-box (docker-compose сервис singbox-proxy). Пусто = без прокси.
OPENROUTER_PROXY_URL: str = "socks5://singbox-proxy:1080"
# FAISS / ML
FAISS_INDEX_PATH: str = "/data/index/faiss.index"
FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json"
EMBED_MODEL: str = "paraphrase-multilingual-mpnet-base-v2"
EMBED_DEVICE: str = "cuda"
EMBED_BATCH_SIZE: int = 64
EMBED_DIM: int = 768 # Размерность вектора paraphrase-multilingual-mpnet-base-v2
FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat
FAISS_NPROBE: int = 64 # Количество кластеров для поиска
# EMBED_BACKEND: "ollama" (GGUF-модель через Vulkan, для AMD GPU без ROCm/atomics),
# "sentence_transformers" (локальная загрузка, CUDA/CPU) или "cloud" (облачный
# инференс той же модели — не нужен вообще никакой локальный GPU).
EMBED_BACKEND: str = "ollama"
EMBED_MODEL: str = "bge-m3"
EMBED_DEVICE: str = "cuda" # используется только при EMBED_BACKEND=sentence_transformers
# Ollama обрабатывает тексты в пачке последовательно (один "slot" в llama.cpp,
# не параллельно) — 64 реальных документа в один HTTP-запрос регулярно не
# укладывались в таймаут. Меньше пачка — короче и надёжнее каждый запрос.
EMBED_BATCH_SIZE: int = 16
EMBED_DIM: int = 1024 # bge-m3; было 768 у paraphrase-multilingual-mpnet-base-v2
# DeepInfra — OpenAI-совместимый инференс BAAI/bge-m3 в облаке, та же модель,
# что и на VM109, дешёвый пей-пер-токен. Используется при EMBED_BACKEND=cloud.
CLOUD_EMBED_URL: str = "https://api.deepinfra.com/v1/openai/embeddings"
CLOUD_EMBED_MODEL: str = "BAAI/bge-m3"
CLOUD_EMBED_API_KEY: str = ""
# Векторный бэкенд: "faiss" (файловый синглтон, дефолт) или "qdrant" (сервис,
# снимает SPOF и конкурентную запись). Переключается без изменения кода.

View File

@@ -1,10 +1,17 @@
"""Singleton менеджер sentence-transformers модели.
"""Singleton менеджер эмбеддинг-модели.
Модель загружается один раз при первом обращении и кэшируется в памяти GPU.
Бэкенд переключается через EMBED_BACKEND:
- "ollama" — HTTP-вызов к Ollama /api/embed (GGUF-модель через Vulkan, работает на AMD GPU)
- "sentence_transformers" — локальная загрузка через sentence-transformers (CUDA/CPU)
- "cloud" — облачный инференс той же модели (DeepInfra, OpenAI-совместимый формат)
Модель (в случае sentence_transformers) загружается один раз при первом обращении
и кэшируется в памяти GPU.
"""
import logging
import httpx
import numpy as np
from app.config import settings
@@ -54,11 +61,16 @@ class ModelManager:
texts: Список текстов для кодирования
Returns:
numpy массив формы (len(texts), 768), нормализованный для cosine similarity
numpy массив формы (len(texts), EMBED_DIM), нормализованный для cosine similarity
"""
if not texts:
return np.array([]).reshape(0, settings.EMBED_DIM)
if settings.EMBED_BACKEND == "ollama":
return cls._encode_ollama(texts)
if settings.EMBED_BACKEND == "cloud":
return cls._encode_cloud(texts)
model = cls.get_model()
vectors = model.encode(
texts,
@@ -69,6 +81,53 @@ class ModelManager:
)
return vectors.astype(np.float32)
@classmethod
def _encode_ollama(cls, texts: list[str]) -> np.ndarray:
"""Закодировать тексты через Ollama /api/embed (пакетами по EMBED_BATCH_SIZE)."""
all_vectors: list[list[float]] = []
batch_size = settings.EMBED_BATCH_SIZE
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = httpx.post(
f"{settings.OLLAMA_URL}/api/embed",
json={"model": settings.EMBED_MODEL, "input": batch},
timeout=300.0, # Ollama обрабатывает тексты в пачке последовательно
# (виден один "slot" в логах llama.cpp), не параллельно — на реальных
# (не тестовых) текстах 64 шт. в 120с не укладывались, отсюда ReadTimeout
# и потеря батча целиком (без ретрая).
)
response.raise_for_status()
all_vectors.extend(response.json()["embeddings"])
vectors = np.array(all_vectors, dtype=np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return vectors / norms # Нормализация для cosine через inner product
@classmethod
def _encode_cloud(cls, texts: list[str]) -> np.ndarray:
"""Закодировать тексты через облачный инференс (DeepInfra, OpenAI-формат)."""
all_vectors: list[list[float]] = []
batch_size = settings.EMBED_BATCH_SIZE
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = httpx.post(
settings.CLOUD_EMBED_URL,
json={"model": settings.CLOUD_EMBED_MODEL, "input": batch},
headers={"Authorization": f"Bearer {settings.CLOUD_EMBED_API_KEY}"},
timeout=120.0,
)
response.raise_for_status()
data = response.json()["data"]
# OpenAI-формат может вернуть элементы не по порядку — сортируем по index
data.sort(key=lambda d: d["index"])
all_vectors.extend(d["embedding"] for d in data)
vectors = np.array(all_vectors, dtype=np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return vectors / norms # Нормализация для cosine через inner product
@classmethod
def encode_single(cls, text: str) -> np.ndarray:
"""Закодировать один текст. Удобный метод."""

View File

@@ -1,4 +1,11 @@
"""Клиент для Ollama HTTP API — LLM анализ парафраза и суммаризация."""
"""Клиент для LLM (анализ парафраза, суммаризация) — Ollama или OpenRouter.
Бэкенд переключается через LLM_BACKEND:
- "ollama" — локальный сервер, полный контроль, но требует GPU-хост.
- "openrouter" — облачный API (OpenAI-совместимый), дешёвая модель типа
DeepSeek. Для L4 (да/нет + уверенность, не творческая задача) "ум" модели
не критичен — но зато не нужен вообще никакой локальный GPU/Ollama.
"""
import json
import logging
@@ -11,13 +18,63 @@ logger = logging.getLogger(__name__)
class OllamaClient:
"""HTTP клиент для Ollama LLM."""
"""HTTP клиент для LLM (Ollama или OpenRouter, см. LLM_BACKEND)."""
def __init__(self) -> None:
self.base_url = settings.OLLAMA_URL
self.model = "qwen2.5:7b"
self.timeout = 60.0 # секунд
def _complete(self, prompt: str, temperature: float, num_predict: int, json_mode: bool = False) -> str | None:
"""Единая точка входа для генерации текста — прячет разницу Ollama/OpenRouter.
Возвращает сырой текст ответа модели или None при ошибке (недоступность,
таймаут и т.п. — вызывающий код сам решает, как деградировать).
"""
if settings.LLM_BACKEND == "openrouter":
return self._complete_openrouter(prompt, temperature, num_predict, json_mode)
return self._complete_ollama(prompt, temperature, num_predict, json_mode)
def _complete_ollama(self, prompt: str, temperature: float, num_predict: int, json_mode: bool) -> str | None:
try:
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature, "num_predict": num_predict},
}
if json_mode:
payload["format"] = "json"
response = httpx.post(f"{self.base_url}/api/generate", json=payload, timeout=self.timeout)
response.raise_for_status()
return response.json().get("response", "")
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(f"Ollama недоступна: {e}")
return None
def _complete_openrouter(self, prompt: str, temperature: float, num_predict: int, json_mode: bool) -> str | None:
try:
payload: dict = {
"model": settings.OPENROUTER_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": num_predict,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
response = httpx.post(
settings.OPENROUTER_URL,
json=payload,
headers={"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}"},
timeout=self.timeout,
proxy=settings.OPENROUTER_PROXY_URL or None,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(f"OpenRouter недоступен: {e}")
return None
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
"""
Проверить является ли text_b парафразом text_a с помощью LLM.
@@ -44,42 +101,24 @@ class OllamaClient:
{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}"""
try:
response = httpx.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": 0.1, # Детерминированный вывод
"num_predict": 200,
},
},
timeout=self.timeout,
)
response.raise_for_status()
llm_response = self._complete(prompt, temperature=0.1, num_predict=200, json_mode=True)
except Exception as e:
logger.error(f"Неожиданная ошибка при обращении к LLM: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)}
result = response.json()
llm_response = result.get("response", "{}")
if llm_response is None:
return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"}
# Парсим JSON из ответа
parsed = json.loads(llm_response)
try:
parsed = json.loads(llm_response or "{}")
return {
"is_paraphrase": bool(parsed.get("is_paraphrase", False)),
"confidence": float(parsed.get("confidence", 0.0)),
"reason": str(parsed.get("reason", "")),
}
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(f"Ollama недоступна: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"}
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Ошибка парсинга ответа Ollama: {e}")
logger.warning(f"Ошибка парсинга ответа LLM: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"}
except Exception as e:
logger.error(f"Неожиданная ошибка при обращении к Ollama: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)}
def summarize(self, title: str, abstract: str, lang: str = "ru") -> str:
"""
@@ -109,26 +148,15 @@ class OllamaClient:
Ответ:"""
try:
response = httpx.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3,
"num_predict": 300,
},
},
timeout=self.timeout,
)
response.raise_for_status()
return response.json().get("response", "").strip()
result = self._complete(prompt, temperature=0.3, num_predict=300)
except Exception as e:
logger.error(f"Ошибка суммаризации через Ollama: {e}")
logger.error(f"Ошибка суммаризации через LLM: {e}")
return abstract[:500] if abstract else title
if result is None:
return abstract[:500] if abstract else title
return result.strip()
def is_available(self) -> bool:
"""Проверить доступность Ollama сервера."""
try:

View File

@@ -60,6 +60,7 @@ def aggregate_results(
semantic_matches: list[dict[str, Any]],
total_fragments: int,
full_text: str = "",
related_candidates: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Свести совпадения уровней в итог проверки.
@@ -77,11 +78,15 @@ def aggregate_results(
total_fragments: всего проверенных фрагментов документа
full_text: полный текст проверяемого документа — нужен для is_cited;
пустая строка → ни один фрагмент не размечается как цитата
related_candidates: кандидаты уровня 3 (FAISS), которые прошли порог
семантической схожести, но LLM не подтвердила парафраз/плагиат —
не заимствование, но тематически близкая работа. Используются
для "recommendations", а не для процента схожести.
Returns:
dict с полями overall_similarity, uncited_similarity, matches (с полем
"cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
uncited_fragments, by_method.
uncited_fragments, by_method, recommendations.
"""
all_matches = level1_matches + level2_matches + semantic_matches
@@ -101,6 +106,18 @@ def aggregate_results(
pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0
return round(min(pct, 100.0), 2)
# Рекомендации: лучший кандидат на источник (без уже засчитанных как совпадение),
# отсортированы по убыванию схожести, не более 10 — чтобы не захламлять отчёт.
flagged_sources = {m.get("source_url") or m.get("source_title") for m in semantic_matches}
best_by_source: dict[str, dict[str, Any]] = {}
for c in related_candidates or []:
key = c.get("source_url") or c.get("source_title", "")
if not key or key in flagged_sources:
continue
if key not in best_by_source or c.get("similarity", 0) > best_by_source[key].get("similarity", 0):
best_by_source[key] = c
recommendations = sorted(best_by_source.values(), key=lambda c: c.get("similarity", 0), reverse=True)[:10]
return {
"overall_similarity": _pct(flagged_positions),
"uncited_similarity": _pct(uncited_positions),
@@ -114,4 +131,5 @@ def aggregate_results(
"fuzzy": len(level2_matches),
"semantic_llm": len(semantic_matches),
},
"recommendations": recommendations,
}

View File

@@ -99,6 +99,7 @@ def check_plagiarism(
ollama = OllamaClient()
vector_store = get_backend()
semantic_matches: list[dict] = []
related_candidates: list[dict] = []
for i, fragment in enumerate(fragments):
frag_text = fragment.get("text", "")
@@ -120,24 +121,38 @@ def check_plagiarism(
if not doc_meta:
continue
# user_submission — чужие непубличные загрузки (и свои же прошлые
# прогоны того же файла), не легитимный источник для сравнения.
if doc_meta["source"] == "user_submission":
continue
llm_result = {"is_paraphrase": False, "confidence": 0.0, "reason": ""}
if source_text:
llm_result = ollama.check_paraphrase(source_text, frag_text)
if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD:
semantic_matches.append({
candidate = {
"fragment": frag_text[:300],
"position_start": fragment.get("start", 0),
"position_end": fragment.get("end", len(frag_text)),
"similarity": round(score * 100, 1),
"method": "semantic+llm",
"confidence": llm_result["confidence"],
"reason": llm_result.get("reason", ""),
"source_title": doc_meta["title"],
"source_url": doc_meta["url"],
"source_db": doc_meta["source"],
}
if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD:
semantic_matches.append({
**candidate,
"method": "semantic+llm",
"confidence": llm_result["confidence"],
"reason": llm_result.get("reason", ""),
})
else:
# Похоже по смыслу, но LLM не подтвердила заимствование —
# не плагиат, но тематически близкая работа: кандидат в
# рекомендации "источники для раскрытия темы", а не в отчёт
# о нарушениях.
related_candidates.append(candidate)
if (i + 1) % 10 == 0:
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
@@ -146,7 +161,12 @@ def check_plagiarism(
from app.scoring import aggregate_results
result = aggregate_results(
level1_matches, level2_matches, semantic_matches, len(fragments), full_text=text
level1_matches,
level2_matches,
semantic_matches,
len(fragments),
full_text=text,
related_candidates=related_candidates,
)
# Сохранить результат
@@ -199,8 +219,13 @@ def check_plagiarism(
raise self.retry(exc=exc, countdown=120) from exc
@celery_app.task(name="gpu.embed_documents")
def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
@celery_app.task(
name="gpu.embed_documents",
bind=True,
max_retries=3,
default_retry_delay=30,
)
def embed_documents(self, doc_ids: list[int]) -> dict[str, Any]:
"""
Построить эмбеддинги для документов и добавить их в FAISS индекс.
@@ -234,7 +259,14 @@ def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
]
ids = [d.id for d in docs]
try:
vectors = ModelManager.encode(texts)
except Exception as exc:
# Таймаут/недоступность бэкенда эмбеддингов — не терять батч молча,
# а повторить (раньше падало без ретрая, документы просто выпадали
# из переиндексации).
logger.warning(f"Не удалось построить эмбеддинги для {ids}: {exc}")
raise self.retry(exc=exc) from exc
store = get_backend()
store.add_vectors(vectors, ids)

View File

@@ -0,0 +1,39 @@
"""Служебная задача: состояние векторного индекса для панели отладки.
Индекс живёт в памяти и на диске worker-gpu, у API к нему доступа нет. Без этой
задачи админка показывала покрытие L3 по колонке `documents.faiss_id` — а она
врёт: пометка остаётся и тогда, когда вектор в индекс не попал (или индекс был
пересоздан после смены модели эмбеддингов). Здесь возвращается то, что в индексе
есть на самом деле.
"""
from typing import Any
from celery.utils.log import get_task_logger
from app.celery_app import celery_app
from app.config import settings
from app.vector_store import get_backend
logger = get_task_logger(__name__)
@celery_app.task(name="gpu.index_stats")
def index_stats() -> dict[str, Any]:
"""Реальное число векторов в активном бэкенде (FAISS или Qdrant)."""
backend = get_backend()
try:
# У FAISS индекс ленивый: без обращения ntotal вернёт 0 на холодном воркере
if hasattr(backend, "_ensure"):
backend._ensure()
total = backend.total_vectors()
except Exception as e:
logger.warning(f"index_stats: не удалось прочитать индекс: {e}")
return {"backend": settings.VECTOR_BACKEND, "error": str(e)[:200]}
return {
"backend": settings.VECTOR_BACKEND,
"vectors": total,
"dim": settings.EMBED_DIM,
"embed_model": settings.EMBED_MODEL,
}

View File

@@ -8,6 +8,7 @@ qdrant-client==1.19.0 # альтернативный векторный бэк
torch==2.3.0
numpy==1.26.4
httpx==0.27.0
socksio==1.0.0 # SOCKS5-прокси для httpx (нужен для OpenRouter из РФ, см. app.ollama_client)
minio==7.2.7
datasketch==1.6.5
pydantic-settings==2.2.1

View File

@@ -139,3 +139,56 @@ def test_no_full_text_means_nothing_marked_cited():
out = aggregate_results([_m("A", 0)], [], [], total_fragments=2) # full_text не передан
assert out["matches"][0]["cited"] is False
assert out["overall_similarity"] == out["uncited_similarity"]
# ─── aggregate_results: recommendations ──────────────────────────────────────
def _cand(title: str, similarity: float, url: str | None = None) -> dict:
return {
"fragment": "фрагмент",
"position_start": 0,
"position_end": 8,
"similarity": similarity,
"source_title": title,
"source_url": url,
"source_db": "openalex",
}
def test_no_related_candidates_means_empty_recommendations():
out = aggregate_results([], [], [], total_fragments=10)
assert out["recommendations"] == []
def test_related_candidates_become_recommendations_sorted_by_similarity():
out = aggregate_results(
[], [], [], total_fragments=10,
related_candidates=[_cand("Low", 60.0), _cand("High", 90.0)],
)
titles = [r["source_title"] for r in out["recommendations"]]
assert titles == ["High", "Low"]
def test_recommendations_dedup_keeps_best_per_source():
out = aggregate_results(
[], [], [], total_fragments=10,
related_candidates=[_cand("A", 60.0, "url-a"), _cand("A", 85.0, "url-a")],
)
assert len(out["recommendations"]) == 1
assert out["recommendations"][0]["similarity"] == 85.0
def test_recommendations_capped_at_ten():
candidates = [_cand(f"S{i}", float(i), f"url-{i}") for i in range(15)]
out = aggregate_results([], [], [], total_fragments=10, related_candidates=candidates)
assert len(out["recommendations"]) == 10
def test_source_already_flagged_as_match_excluded_from_recommendations():
semantic = [{**_cand("A", 90.0, "url-a"), "method": "semantic+llm"}]
out = aggregate_results(
[], [], semantic, total_fragments=10,
related_candidates=[_cand("A", 60.0, "url-a"), _cand("B", 70.0, "url-b")],
)
titles = [r["source_title"] for r in out["recommendations"]]
assert titles == ["B"]

View File

@@ -85,6 +85,68 @@ def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:
return fingerprint
def winnow_ordered(text: str, k: int = 5, window: int = 4) -> list[int]:
"""То же, что winnow, но с сохранением порядка появления отпечатков в тексте.
Нужно там, где отпечатки приходится обрезать по лимиту: множество не хранит
порядка, и `list(winnow(text))[:limit]` берёт произвольное подмножество —
у длинного документа целые куски остаются без покрытия, и списывание из них
не находится. Со списком в порядке текста обрезку можно делать равномерной
(см. sample_evenly).
Args:
text: Исходный текст
k: Размер k-граммы
window: Размер скользящего окна
Returns:
Отпечатки в порядке появления в тексте, без повторов
"""
tokens = text.lower().split()
if len(tokens) < k:
return []
hashes = [hash_ngram(ng) for ng in get_ngrams(tokens, k)]
if not hashes:
return []
ordered: list[int] = []
seen: set[int] = set()
prev_min_idx = -1
for i in range(len(hashes) - window + 1):
window_hashes = hashes[i : i + window]
min_val = min(window_hashes)
min_idx = i + window_hashes.index(min_val)
if min_idx != prev_min_idx:
if min_val not in seen:
seen.add(min_val)
ordered.append(min_val)
prev_min_idx = min_idx
return ordered
def sample_evenly(items: list[int], limit: int) -> list[int]:
"""Оставить не больше limit элементов, равномерно по всей длине списка.
Берём каждый n-й элемент, а не первые limit штук: обрезка «с начала»
оставила бы без отпечатков весь конец документа.
Args:
items: Отпечатки в порядке текста (winnow_ordered)
limit: Максимум отпечатков; 0 или меньше — не ограничивать
Returns:
Подсписок длиной не больше limit, сохраняющий порядок
"""
if limit <= 0 or len(items) <= limit:
return items
step = len(items) / limit
return [items[int(i * step)] for i in range(limit)]
def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float:
"""
Коэффициент Жаккара для двух fingerprint'ов.

View File

@@ -0,0 +1,132 @@
"""Пакетная запись документов корпуса — путь для массовых источников.
Обычный `index.add_document` пишет по одному документу через ORM: удобно для
парсеров, отдающих сотни статей, но на миллионах это тупик. Замер на проде:
построчная вставка отпечатков — 6.7 тыс. строк/с, COPY — 21 тыс. строк/с, а
миллион статей это ~2 млрд отпечатков. Разница между сутками и неделями.
Поэтому массовые источники (дампы Википедии, бакет PMC) идут сюда: документы
вставляются одной командой с ON CONFLICT, отпечатки — через COPY.
Elasticsearch и эмбеддинги здесь намеренно не трогаются: L1 и L2 начинают
работать сразу, а векторы для L3 досчитываются отдельно
(`scripts/ops/reembed_missing.py`) — иначе заливка упирается в скорость
сервера эмбеддингов и тормозит на порядок.
"""
import contextlib
import io
import logging
import time
from typing import Any
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
from app.config import settings
logger = logging.getLogger(__name__)
# Сколько попыток пережить обрыв соединения с базой. Массовая заливка идёт
# часами, и разрыв сети за это время — норма, а не исключение.
DB_RETRIES = 5
def connect():
"""Отдельное подключение psycopg2: COPY недоступен через ORM-сессию."""
return psycopg2.connect(
host=settings.POSTGRES_HOST,
port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB,
user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
def write_batch(conn, docs: list[dict[str, Any]], fp_limit: int) -> tuple[Any, dict[str, int]]:
"""Записать пачку документов с отпечатками, пережив обрыв соединения.
Args:
conn: активное подключение psycopg2 (может быть заменено при обрыве)
docs: документы в унифицированном формате парсеров; нужны ext_id,
source, title и text — остальное необязательно
fp_limit: максимум отпечатков на документ
Returns:
(соединение, счётчики) — соединение может оказаться новым, если
пришлось переподключаться; счётчики: added / duplicates / fingerprints
"""
if not docs:
return conn, {"added": 0, "duplicates": 0, "fingerprints": 0}
for attempt in range(1, DB_RETRIES + 1):
try:
return conn, _write_once(conn, docs, fp_limit)
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
logger.warning(
"bulk_writer: база недоступна (%s), повтор через %sс [%s/%s]",
str(e).strip()[:80], wait, attempt, DB_RETRIES,
)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
try:
conn = connect()
except Exception:
continue
raise RuntimeError(f"не удалось записать пачку после {DB_RETRIES} попыток")
def _write_once(conn, docs: list[dict[str, Any]], fp_limit: int) -> dict[str, int]:
"""Одна попытка записи; счётчики возвращаются только при успехе."""
added = duplicates = fingerprints = 0
with conn.cursor() as cur:
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, doi, year, lang, journal,
abstract, url, authors, indexed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
[
(
d["ext_id"], d["source"], (d.get("title") or "")[:1000], d.get("doi"),
d.get("year"), d.get("lang"), (d.get("journal") or None),
(d.get("text") or "")[:2000], d.get("url"),
)
for d in docs
],
)
cur.execute(
"SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([d["ext_id"] for d in docs],),
)
id_by_ext = {ext: doc_id for doc_id, ext in cur.fetchall()}
buf = io.StringIO()
for d in docs:
doc_id = id_by_ext.get(d["ext_id"])
if doc_id is None:
continue
# Уже с отпечатками — значит документ залит прошлым прогоном
cur.execute("SELECT 1 FROM fingerprints WHERE doc_id = %s LIMIT 1", (doc_id,))
if cur.fetchone():
duplicates += 1
continue
hashes = sample_evenly(winnow_ordered(d.get("text") or ""), fp_limit)
if not hashes:
continue
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fingerprints += len(hashes)
added += 1
buf.seek(0)
if added:
cur.copy_from(buf, "fingerprints", columns=("doc_id", "hash_value", "position"))
conn.commit()
return {"added": added, "duplicates": duplicates, "fingerprints": fingerprints}

View File

@@ -26,5 +26,12 @@ celery_app.conf.update(
},
task_acks_late=True,
task_reject_on_worker_lost=True,
# Один неподтверждённый месседж на процесс пула. При acks_late=True всё
# предвыбранное висит unacked, а RabbitMQ рвёт канал по consumer_timeout
# (1800с) с момента ДОСТАВКИ, а не начала выполнения. С дефолтным префетчем
# (4×concurrency) массовая заливка — сотни долгих run_parser в очереди —
# гарантированно роняет воркер на задачах, которые ещё даже не начинались,
# и он уходит в краш-луп на передоставленных сообщениях (docs/DR-HA.md §6).
worker_prefetch_multiplier=1,
result_expires=86400,
)

View File

@@ -59,12 +59,27 @@ class Settings(BaseSettings):
FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным
EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов
# Сколько документов массового источника пишется одной транзакцией.
# Больше — меньше накладных расходов, но длиннее транзакция: 500 статей по
# 2000 отпечатков это миллион строк за раз, и на таком объёме соединение
# обрывалось. 150 — компромисс, проверенный на заливке Википедии.
BULK_WRITE_BATCH: int = 150
# Бюджет времени одного прогона index.run_parser. RabbitMQ рвёт канал
# consumer'а, не сделавшего ack за consumer_timeout (по умолчанию 1800с):
# воркер падает, сообщение передоставляется, таск начинается заново —
# бесконечный краш-луп (docs/DR-HA.md §6). Прогон закругляется раньше и
# честно помечается partial: остаток дозаливается повторным запуском.
PARSER_TIME_BUDGET_S: float = 1500.0
# Автоматически добавлять проверенные работы студентов в базу для сравнения
# (как в коммерческих системах — Антиплагиат.ру/Turnitin ловят списывание у
# предыдущих потоков именно так). Без этого каждая работа лежит в StagedWork
# и ждёт ручного одобрения админом — сейчас это дефолт для роста корпуса;
# выключить, если нужна модерация перед публикацией.
AUTO_APPROVE_SUBMISSIONS: bool = True
# предыдущих потоков именно так). Выключено по умолчанию: без ручной
# модерации студент, перепроверивший тот же файл дважды, получал 100%
# "плагиата" — против собственной же более ранней загрузки. L1/L2/L3 теперь
# и так исключают source=user_submission из сравнения, так что включать это
# обратно есть смысл только вместе с реальной защитой от self/cross-match.
AUTO_APPROVE_SUBMISSIONS: bool = False
# App
ENVIRONMENT: str = "development"

View File

@@ -1,9 +1,11 @@
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
"""Синхронное подключение к PostgreSQL, MinIO и Redis для индексер-воркера."""
import logging
from collections.abc import Generator
from contextlib import contextmanager
from datetime import UTC, datetime
import redis
from minio import Minio
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -65,3 +67,47 @@ def update_task_status(task_id: str, status: str, error: str | None = None) -> N
if error:
task.error = error
session.commit()
_redis_client: redis.Redis | None = None
def get_redis() -> redis.Redis:
"""Получить или создать sync Redis клиент (тот же Redis, что и у API —
там считаются rate-limit'ы, ключи вида rl:{user_id}:{action}:{period})."""
global _redis_client
if _redis_client is None:
_redis_client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def refund_plagiarism_quota(task_id: str) -> None:
"""Вернуть месячную квоту проверок плагиата владельцу задачи.
Вызывается, когда задача провалилась ДО начала реальной проверки (битый
файл, не тот формат) — API списывает квоту синхронно при загрузке файла,
заранее, не дожидаясь, распознается ли он вообще. Без возврата пользователь
терял бы месячный лимит (у free — 1 в месяц) за одну неудачную попытку с
неправильным файлом. Ключ и формат периода — как в
api/app/core/rate_limiter.py (rl:{user_id}:plagiarism:{YYYY-MM}), чтобы
декремент попадал в тот же счётчик, что инкрементил API.
"""
from app.models import Task
try:
with db_session() as session:
task = session.get(Task, task_id)
if task is None:
return
user_id = task.user_id
period = datetime.now(UTC).strftime("%Y-%m")
key = f"rl:{user_id}:plagiarism:{period}"
r = get_redis()
current = r.get(key)
if current and int(current) > 0:
r.decr(key)
logger.info(f"Квота plagiarism возвращена пользователю {user_id} (задача {task_id!r})")
except Exception as e:
# Невозврат квоты — не повод валить обработку ошибки задачи
logger.warning(f"Не удалось вернуть квоту для задачи {task_id!r}: {e}")

View File

@@ -23,6 +23,12 @@ def extract_text_from_pdf(data: bytes) -> str:
"""
import fitz # PyMuPDF
# Частый случай: файл с расширением .pdf, но внутри — HTML/другое (например,
# сохранённая веб-страница). Даём понятное сообщение, а не сырую ошибку MuPDF
# вида "code=7: no objects found", которая ничего не скажет пользователю.
if not data.startswith(b"%PDF-"):
raise ValueError("Файл повреждён или не является PDF-документом")
try:
doc = fitz.open(stream=data, filetype="pdf")
except Exception as e:

View File

@@ -2,7 +2,7 @@
from datetime import datetime
from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func
from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -77,9 +77,36 @@ class ParseSource(Base):
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
docs_added: Mapped[int] = mapped_column(default=0)
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
resume_token: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
class ParseRun(Base):
"""Прогон парсинга: счётчики прогресса и журнал (пишет run_parser)."""
__tablename__ = "parse_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(ForeignKey("parse_sources.id"))
celery_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="queued")
stage: Mapped[str] = mapped_column(String(20), default="queued")
target: Mapped[int] = mapped_column(default=0)
fetched: Mapped[int] = mapped_column(default=0)
processed: Mapped[int] = mapped_column(default=0)
added: Mapped[int] = mapped_column(default=0)
duplicates: Mapped[int] = mapped_column(default=0)
skipped: Mapped[int] = mapped_column(default=0)
failed: Mapped[int] = mapped_column(default=0)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
log: Mapped[list | None] = mapped_column(JSON, default=list)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
run_started_at: Mapped[datetime | None] = mapped_column(nullable=True)
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
class StagedWork(Base):
__tablename__ = "staged_works"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -100,4 +127,13 @@ class StagedWork(Base):
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
__all__ = ["Base", "User", "Task", "Document", "Fingerprint", "ParseSource", "StagedWork"]
__all__ = [
"Base",
"User",
"Task",
"Document",
"Fingerprint",
"ParseSource",
"ParseRun",
"StagedWork",
]

View File

@@ -0,0 +1,124 @@
"""Счётчики прогресса прогона парсинга — чистая логика, без БД и Celery.
Отделено от tasks/index.py по той же причине, что и staging.py: здесь живут
решения, которые легко ломаются молча (как часто писать в БД, когда пора
закругляться по бюджету времени, сколько строк журнала хранить) — их надо
тестировать изолированно, без RabbitMQ/PostgreSQL.
Бюджет времени — не оптимизация, а защита: RabbitMQ рвёт канал consumer'а,
не сделавшего ack за `consumer_timeout` (по умолчанию 1800с), Celery-процесс
падает, сообщение передоставляется и таск начинается заново — бесконечный
краш-луп, который уже дважды съедал весь пул воркера (см. docs/DR-HA.md §6).
Поэтому прогон сам останавливается раньше дедлайна и честно помечается
`partial`, а не доводит воркер до падения.
"""
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
# Значения по умолчанию: бюджет заметно меньше consumer_timeout (1800с),
# чтобы после остановки успеть дописать статус в БД и сдаться брокеру.
DEFAULT_BUDGET_S = 1500.0
DEFAULT_FLUSH_INTERVAL_S = 2.0
LOG_TAIL_LIMIT = 200
class RunProgress:
"""Состояние одного прогона: счётчики, журнал, тайминги.
Воркер дёргает `count_*`/`log`, а по `should_flush()` сбрасывает
`snapshot()` в таблицу parse_runs.
"""
def __init__(
self,
target: int,
budget_s: float = DEFAULT_BUDGET_S,
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
log_limit: int = LOG_TAIL_LIMIT,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.target = max(0, target)
self.budget_s = budget_s
self.flush_interval_s = flush_interval_s
self.log_limit = log_limit
self._clock = clock
self._started = clock()
self._last_flush = self._started
self.stage = "queued"
self.fetched = 0
self.processed = 0
self.added = 0
self.duplicates = 0
self.skipped = 0
self.failed = 0
self.entries: list[dict[str, Any]] = []
# ── тайминги ──────────────────────────────────────────────────────────────
@property
def elapsed(self) -> float:
return self._clock() - self._started
@property
def over_budget(self) -> bool:
"""Пора закругляться, пока брокер не оборвал канал."""
return self.elapsed >= self.budget_s
def should_flush(self) -> bool:
"""Прошло ли достаточно времени с прошлой записи в БД."""
return (self._clock() - self._last_flush) >= self.flush_interval_s
def mark_flushed(self) -> None:
self._last_flush = self._clock()
# ── счётчики ──────────────────────────────────────────────────────────────
def count_fetched(self, total: int) -> None:
"""Сколько сырых документов получено от источника (нарастающим итогом)."""
self.fetched = total
def count_result(self, status: str) -> None:
"""Учесть результат обработки одного документа.
indexed / duplicate / skipped (мусор от источника: нет title или
ext_id) считаются отдельно от failed — иначе панель отладки показывала
бы тысячи «ошибок» там, где источник просто отдал неполные записи.
"""
self.processed += 1
if status == "indexed":
self.added += 1
elif status == "duplicate":
self.duplicates += 1
elif status == "skipped":
self.skipped += 1
else:
self.failed += 1
# ── журнал ────────────────────────────────────────────────────────────────
def log(self, level: str, msg: str) -> None:
"""Добавить запись журнала, храня только хвост последних log_limit строк."""
self.entries.append({
"ts": datetime.now(UTC).isoformat(timespec="seconds"),
"elapsed": round(self.elapsed, 1),
"level": level,
"msg": msg[:500],
})
if len(self.entries) > self.log_limit:
del self.entries[: len(self.entries) - self.log_limit]
# ── выгрузка ──────────────────────────────────────────────────────────────
def snapshot(self) -> dict[str, Any]:
"""Поля для UPDATE parse_runs."""
return {
"stage": self.stage,
"target": self.target,
"fetched": self.fetched,
"processed": self.processed,
"added": self.added,
"duplicates": self.duplicates,
"skipped": self.skipped,
"failed": self.failed,
"log": list(self.entries),
}

View File

@@ -1,19 +1,20 @@
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
import contextlib
import io
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from celery.utils.log import get_task_logger
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from app.algorithms.minhash import add_to_lsh, find_similar
from app.algorithms.winnowing import winnow
from app.algorithms.winnowing import sample_evenly, winnow, winnow_ordered
from app.celery_app import celery_app
from app.config import settings
from app.db import db_session, get_minio, update_task_status
from app.db import db_session, get_minio, refund_plagiarism_quota, update_task_status
from app.extractors.docx import extract_text_from_docx, extract_text_from_txt
from app.extractors.pdf import extract_text_from_pdf
from app.fragments import split_into_fragments
@@ -108,13 +109,20 @@ def extract_and_check(
continue
frag_hashes = list(frag_fp)
# Источник, разделяющий больше всего отпечатков с этим фрагментом
# Источник, разделяющий больше всего отпечатков с этим фрагментом.
# user_submission исключены: это чужие непубличные загрузки (и
# свои же прошлые прогоны того же файла) — сравнение с ними даёт
# ложные 100%-совпадения, а не реальный плагиат из источника.
row = session.execute(
select(
Fingerprint.doc_id,
func.count(Fingerprint.id).label("cnt"),
)
.where(Fingerprint.hash_value.in_(frag_hashes))
.join(Document, Document.id == Fingerprint.doc_id)
.where(
Fingerprint.hash_value.in_(frag_hashes),
Document.source != "user_submission",
)
.group_by(Fingerprint.doc_id)
.order_by(func.count(Fingerprint.id).desc())
.limit(1)
@@ -164,7 +172,7 @@ def extract_and_check(
try:
doc_id = int(key.split(":")[-1])
doc = session.get(Document, doc_id)
if not doc:
if not doc or doc.source == "user_submission":
continue
level2_matches.append({
@@ -202,6 +210,16 @@ def extract_and_check(
"level2": len(level2_matches),
}
except ValueError as exc:
# Детерминированная ошибка (битый файл, не тот формат, пустой текст) —
# повтор не поможет: результат будет тем же на 2-й и 3-й попытке. Не
# ретраим (быстрее фидбек юзеру) и возвращаем месячную квоту — реальная
# проверка так и не началась, списывать не за что.
logger.warning(f"Задача {task_id!r}: не удалось обработать файл: {exc}")
update_task_status(task_id, "failed", str(exc))
refund_plagiarism_quota(task_id)
return {"task_id": task_id, "status": "failed", "error": str(exc)}
except Exception as exc:
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
update_task_status(task_id, "failed", str(exc))
@@ -267,8 +285,9 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[
# Вычислить fingerprints
text = doc_data.get("full_text") or doc_data.get("abstract", "") or ""
if text:
fp = winnow(text)
fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
fingerprints_to_add = sample_evenly(
winnow_ordered(text), settings.MAX_FINGERPRINTS_PER_DOC
)
for i, hash_val in enumerate(fingerprints_to_add):
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
@@ -315,8 +334,12 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[
queue="queue.gpu",
)
# Обогащение полным текстом: скачать PDF по url и пересчитать fingerprints
if settings.FETCH_FULL_TEXT and doc_data.get("url"):
# Обогащение полным текстом: скачать PDF по url и пересчитать fingerprints.
# Если full_text уже есть (например, OCR-фрагмент из ответа поиска
# КиберЛенинки) — не дублируем запрос: страница статьи там HTML, а не PDF,
# enrich_full_text гарантированно ничего не найдёт, только зря нагрузит
# источник (и получит 503 при массовой заливке).
if settings.FETCH_FULL_TEXT and doc_data.get("url") and not doc_data.get("full_text"):
celery_app.send_task(
"index.enrich_full_text",
args=[doc_id, doc_data["url"]],
@@ -326,6 +349,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 = sample_evenly(winnow_ordered(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,
@@ -345,51 +415,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",
@@ -496,76 +532,105 @@ def _stage_work(
logger.warning(f"Не удалось добавить работу {task_id!r} в отстойник: {e}")
@celery_app.task(name="index.run_parser")
def run_parser(source_id: int) -> dict[str, Any]:
"""Запустить парсинг источника и наполнить базу документов.
def _last_pmc_key() -> str | None:
"""Ключ бакета после последней залитой статьи PMC — старт листинга.
Переиспользует парсеры из scripts/parsers (BaseParser.run) и
задачу add_document для каждого полученного документа.
Бакет отдаётся в лексикографическом порядке ключей, и ext_id вида
`pmc:PMC10000000` соответствует ключу `PMC10000000.1`. Берём максимальный
залитый и продолжаем после него.
"""
import sys
from datetime import datetime
from app.models import ParseSource
# Парсеры лежат в /parsers (скопированы в образ)
if "/parsers" not in sys.path:
sys.path.insert(0, "/parsers")
from sqlalchemy import text as sql_text
with db_session() as session:
src = session.get(ParseSource, source_id)
if src is None:
return {"status": "error", "reason": "источник не найден"}
cfg = {
"source_type": src.source_type,
"query": src.query,
"lang": src.lang,
"year_from": src.year_from,
"year_to": src.year_to,
"limit": src.limit,
}
src.last_status = "running"
session.commit()
row = session.execute(sql_text(
"SELECT max(ext_id) FROM documents WHERE ext_id LIKE 'pmc:PMC%'"
)).first()
if not row or not row[0]:
return None
return row[0].split(":", 1)[1] + ".1"
added = 0
error_msg = None
try:
# Выбрать парсер по типу и собрать совместимые с его fetch() аргументы
stype = cfg["source_type"]
if stype == "openalex":
def _parser_for(source_type: str, cfg: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
"""Инстанс парсера и совместимые с его fetch() аргументы по типу источника."""
limit = cfg["limit"]
query = cfg.get("query") or ""
if source_type == "openalex":
from openalex import OpenAlexParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
kwargs = {
"query": query,
"limit": limit,
"lang": cfg.get("lang"),
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
"open_access_only": settings.INGEST_OPEN_ACCESS_ONLY,
}
elif stype == "cyberleninka":
elif source_type == "cyberleninka":
from cyberleninka import CyberLeninkaParser as P
fetch_kwargs = {"query": cfg.get("query") or "", "limit": cfg["limit"]}
elif stype == "arxiv":
kwargs = {"query": query, "limit": limit}
elif source_type == "arxiv":
from arxiv import ArxivParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
"year_from": cfg.get("year_from"),
}
elif stype == "pmc":
kwargs = {"query": query, "limit": limit, "year_from": cfg.get("year_from")}
elif source_type == "pmc":
from pmc import PMCParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
kwargs = {
"query": query,
"limit": limit,
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
elif source_type == "wikipedia_ru":
# Массовый источник: позиция — байтовое смещение в multistream-дампе.
# Раньше хранился номер статьи, и каждый прогон перечитывал дамп с
# начала: на 20 тысячах это стоило 6 минут из 25, дальше росло линейно
from wikipedia_ru import WikipediaRuParser as P
kwargs = {
"limit": limit,
"dump_path": query or None, # query = путь к дампу, если задан
"start_offset": int(cfg.get("resume_token") or 0),
}
elif source_type == "core":
# Массовый источник: позиция — "год:смещение". Выборка режется по
# годам, потому что offset у CORE упирается в 100 000 (см. core.py)
from core import COREParser as P
kwargs = {
"limit": limit,
"query": query,
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
"resume_token": cfg.get("resume_token"),
}
elif source_type == "pmc_bulk":
from pmc_bulk import PMCBulkParser as P
kwargs = {
"limit": limit,
"resume_token": cfg.get("resume_token"),
# Токена ещё нет (первый прогон после ручных заливок) — начинаем
# после последней уже залитой статьи, иначе листинг часами
# перемалывает существующие как дубли
"start_after": None if cfg.get("resume_token") else _last_pmc_key(),
}
else:
raise ValueError(f"неизвестный тип источника: {stype}")
raise ValueError(f"неизвестный тип источника: {source_type}")
parser = P()
# fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs)
return P(), kwargs
def _ingest_one_by_one(parser: Any, raw_docs: Any, prog: Any, run_id: int) -> tuple[bool, bool]:
"""Обычный путь: документ за документом через add_document.
Подходит источникам, отдающим сотни статей: работает ORM-логика с
дедупликацией, индексацией в Elasticsearch и обогащением полным текстом.
Returns:
(отменён, исчерпан бюджет времени)
"""
cancelled = exhausted = False
prog.stage = "index"
prog.count_fetched(len(raw_docs))
prog.log("info", f"получено {len(raw_docs)} документов, индексация")
_write_run(run_id, **prog.snapshot())
# Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu
# кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений,
@@ -581,31 +646,353 @@ def run_parser(source_id: int) -> dict[str, Any]:
embed_batch.clear()
for raw in raw_docs:
if not cancelled and prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на индексации")
if cancelled or exhausted:
break
try:
doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")):
prog.count_result("skipped")
continue
result = add_document(doc, dispatch_embed=False)
prog.count_result(result.get("status", "error"))
if result.get("status") == "indexed":
added += 1
embed_batch.append(result["doc_id"])
if len(embed_batch) >= batch_size:
_flush_embed()
except Exception as e:
prog.count_result("error")
prog.log("warning", f"документ пропущен: {e}")
logger.warning(f"run_parser: ошибка документа: {e}")
if prog.should_flush():
cancelled = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
if cancelled:
prog.log("warning", "отмена по запросу из админки")
_flush_embed()
return cancelled, exhausted
def _ingest_bulk(
parser: Any, raw_docs: Any, prog: Any, run_id: int, source_id: int
) -> tuple[bool, bool]:
"""Массовый путь: статьи приходят потоком и пишутся пачками через COPY.
Отличия от обычного пути и почему они нужны:
- парсер отдаёт генератор, поэтому счётчик «получено» растёт по ходу дела,
а не известен заранее;
- запись идёт пакетно (bulk_writer), иначе миллионы отпечатков занимают
недели вместо суток;
- позиция продолжения сохраняется в источнике, чтобы следующий прогон
начинал с места остановки, а не перечитывал дамп с начала.
Эмбеддинги здесь не диспатчатся: они считаются заметно медленнее заливки и
делали бы её узким местом. Векторы досчитываются отдельно —
`scripts/ops/reembed_missing.py`.
Returns:
(отменён, исчерпан бюджет времени)
"""
from app.bulk_writer import connect, write_batch
from app.models import ParseSource
cancelled = exhausted = False
prog.stage = "index"
prog.log("info", "массовый источник: запись пачками")
_write_run(run_id, **prog.snapshot())
conn = connect()
batch: list[dict[str, Any]] = []
resume_token: str | None = None
batch_size = settings.BULK_WRITE_BATCH
def save_resume() -> None:
"""Запомнить позицию в источнике — с неё продолжит следующий прогон."""
if resume_token is None:
return
with db_session() as session:
src = session.get(ParseSource, source_id)
if src:
src.resume_token = str(resume_token)
session.commit()
def flush() -> bool:
"""Записать накопленное; True — попросили остановиться."""
nonlocal conn, batch
if not batch:
return False
conn, counts = write_batch(conn, batch, settings.MAX_FINGERPRINTS_PER_DOC)
for _ in range(counts["added"]):
prog.count_result("indexed")
for _ in range(counts["duplicates"]):
prog.count_result("duplicate")
batch = []
save_resume()
stop = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
return stop
try:
for raw in raw_docs:
if prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан")
break
try:
doc = parser.transform(raw)
except Exception as e:
prog.count_result("error")
logger.warning(f"run_parser bulk: ошибка документа: {e}")
continue
if not (doc and doc.get("ext_id") and doc.get("text")):
prog.count_result("skipped")
continue
# Позиция продолжения: у Википедии — номер статьи в дампе,
# у PMC — токен страницы бакета
resume_token = doc.get("dump_offset") or doc.get("resume_token") or resume_token
batch.append(doc)
prog.count_fetched(prog.fetched + 1)
if len(batch) >= batch_size and flush():
cancelled = True
prog.log("warning", "отмена по запросу из админки")
break
if not cancelled and flush():
cancelled = True
finally:
with contextlib.suppress(Exception):
conn.close()
return cancelled, exhausted
def _write_run(run_id: int, **fields: Any) -> bool:
"""Записать прогресс прогона и вернуть True, если запрошена отмена.
Отмена кооперативная: админка ставит cancel_requested, воркер узнаёт о ней
на ближайшем тике прогресса и останавливается сам. Так прогон завершается
с осмысленным статусом и не оставляет источник висеть в running (что было
бы при жёстком revoke уже начатого таска).
"""
from app.models import ParseRun
with db_session() as session:
row = session.execute(
update(ParseRun)
.where(ParseRun.id == run_id)
.values(heartbeat_at=datetime.now(UTC), **fields)
.returning(ParseRun.cancel_requested)
).first()
return bool(row and row[0])
@celery_app.task(name="index.run_parser", bind=True)
def run_parser(self, source_id: int, run_id: int | None = None) -> dict[str, Any]:
"""Запустить парсинг источника и наполнить базу документов.
Переиспользует парсеры из scripts/parsers и задачу add_document для
каждого полученного документа. Ход заливки пишется в parse_runs (шкала
загрузки и журнал в админке); прогон можно отменить из админки и он сам
закругляется по бюджету времени, не доводя воркер до падения по
consumer_timeout RabbitMQ (см. app/progress.py).
Args:
source_id: ID источника в parse_sources
run_id: ID заранее созданной строки parse_runs (её создаёт админка,
чтобы прогон был виден в очереди ещё до старта). Без него строка
создаётся здесь — для запусков из скриптов.
"""
import sys
from app.models import ParseRun, ParseSource
from app.progress import RunProgress
# Парсеры лежат в /parsers (bind-mount scripts/parsers, см. compose)
if "/parsers" not in sys.path:
sys.path.insert(0, "/parsers")
with db_session() as session:
src = session.get(ParseSource, source_id)
if src is None:
return {"status": "error", "reason": "источник не найден"}
cfg = {
"source_type": src.source_type,
"query": src.query,
"lang": src.lang,
"year_from": src.year_from,
"year_to": src.year_to,
"limit": src.limit,
# Массовые источники продолжают с сохранённой позиции
"resume_token": src.resume_token,
}
src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.now(UTC)
if run_id is None:
run = ParseRun(source_id=source_id, status="running", stage="fetch")
session.add(run)
session.flush()
run_id = run.id
src.last_run_id = run_id
session.commit()
prog = RunProgress(target=cfg["limit"], budget_s=settings.PARSER_TIME_BUDGET_S)
prog.stage = "fetch"
prog.log("info", f"старт: {cfg['source_type']} q={cfg.get('query') or '—'} limit={cfg['limit']}")
_write_run(
run_id,
status="running",
celery_task_id=self.request.id,
error=None,
# Отдельно от started_at (постановка в очередь): при массовом запуске
# между ними часы ожидания, и без этой отметки «длительность прогона»
# в отладке показывала очередь, а не работу
run_started_at=datetime.now(UTC),
**prog.snapshot(),
)
cancelled = False
exhausted = False # остановлены бюджетом времени, а не концом выдачи
error_msg = None
try:
parser, fetch_kwargs = _parser_for(cfg["source_type"], cfg)
def on_fetch_progress(fetched: int) -> bool:
"""Тик прогресса выборки; False — парсеру пора остановиться."""
nonlocal cancelled, exhausted
prog.count_fetched(fetched)
if prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на выборке")
return False
if prog.should_flush():
if _write_run(run_id, **prog.snapshot()):
cancelled = True
prog.log("warning", "отмена по запросу из админки")
return False
prog.mark_flushed()
return True
# fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs, progress_cb=on_fetch_progress)
if getattr(parser, "bulk", False):
# Массовые источники (дамп Википедии, бакет PMC) отдают генератор:
# миллионы статей нельзя ни держать в памяти, ни писать по одной
# через ORM — для них отдельный путь с пакетной записью
cancelled, exhausted = _ingest_bulk(parser, raw_docs, prog, run_id, source_id)
else:
cancelled, exhausted = _ingest_one_by_one(parser, raw_docs, prog, run_id)
except Exception as e:
error_msg = str(e)[:500]
prog.log("error", f"прогон упал: {error_msg}")
logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True)
if error_msg:
status = "error"
elif cancelled:
status = "cancelled"
elif exhausted:
status = "partial"
else:
status = "done"
prog.stage = "finished"
prog.log(
"info" if status in ("done", "partial") else "warning",
f"итог: {status}, добавлено {prog.added}, дублей {prog.duplicates}, "
f"ошибок {prog.failed}, за {prog.elapsed:.0f}с",
)
_write_run(
run_id,
status=status,
error=error_msg,
finished_at=datetime.now(UTC),
**prog.snapshot(),
)
with db_session() as session:
src = session.get(ParseSource, source_id)
if src:
src.last_status = "error" if error_msg else "done"
src.last_status = status
src.last_error = error_msg
src.docs_added = (src.docs_added or 0) + added
src.docs_added = (src.docs_added or 0) + prog.added
src.last_run_at = datetime.now(UTC)
session.commit()
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg}
return {
"status": status,
"run_id": run_id,
"added": prog.added,
"duplicates": prog.duplicates,
"failed": prog.failed,
"error": error_msg,
}
@celery_app.task(name="index.ingest_upload", bind=True, max_retries=2, default_retry_delay=60)
def ingest_upload(
self,
minio_key: str,
filename: str,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Добавить загруженный админом файл в базу документов (корпус для сравнения).
Это не проверка плагиата: файл сразу становится источником, с которым
сравниваются работы студентов. Текст извлекается тем же кодом, что и в
extract_and_check, дальше — обычный add_document (дедуп, fingerprints,
LSH, Elasticsearch, эмбеддинги).
"""
meta = meta or {}
try:
minio = get_minio()
response = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
file_data = response.read()
response.close()
response.release_conn()
ext = Path(filename).suffix.lower()
if ext == ".pdf":
text = extract_text_from_pdf(file_data)
elif ext == ".docx":
text = extract_text_from_docx(file_data)
else:
text = extract_text_from_txt(file_data)
if not text.strip():
raise ValueError("не удалось извлечь текст")
except ValueError as exc:
logger.warning(f"ingest_upload {minio_key}: {exc}")
return {"status": "failed", "minio_key": minio_key, "error": str(exc)}
except Exception as exc:
logger.error(f"ingest_upload {minio_key}: {exc}", exc_info=True)
raise self.retry(exc=exc, countdown=60) from exc
doc_data = {
"source": meta.get("source") or "manual_upload",
# Ключ MinIO уникален (UUID в имени) — годится как ext_id для дедупа
"ext_id": f"upload:{minio_key}",
"title": meta.get("title") or Path(filename).stem,
"authors": meta.get("authors") or [],
"year": meta.get("year"),
"lang": meta.get("lang"),
"abstract": text[:2000],
"full_text": text,
"minio_key": minio_key,
}
result = add_document(doc_data)
logger.info(
f"ingest_upload: {filename!r} → {result.get('status')} "
f"(doc_id={result.get('doc_id')}, {len(text)} симв.)"
)
return {"status": result.get("status"), "doc_id": result.get("doc_id"), "filename": filename}

View File

@@ -6,3 +6,4 @@ xxhash==3.4.1
datasketch==1.6.5
numpy==1.26.4
pydantic-settings==2.2.1
PyMuPDF==1.24.0

View File

@@ -12,3 +12,4 @@ langdetect==1.0.9
beautifulsoup4==4.12.3 # парсер CyberLeninka: детали статьи (fetch_article_details)
pydantic-settings==2.2.1
httpx==0.27.0
socksio==1.0.0 # SOCKS5-прокси для httpx (CORE блокирует запросы из РФ, см. scripts/parsers/core.py)

View File

@@ -0,0 +1,41 @@
"""Юнит-тесты извлечения текста из PDF (app.extractors.pdf)."""
import pytest
from app.extractors.pdf import extract_text_from_pdf
def _minimal_pdf_bytes(text: str) -> bytes:
"""Собрать валидный однострочный PDF с заданным текстом через сам PyMuPDF."""
import fitz
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text)
data = doc.tobytes()
doc.close()
return data
def test_extracts_text_from_valid_pdf():
# ASCII — встроенный шрифт PyMuPDF не рендерит кириллицу без явного указания
# шрифта с поддержкой юникода; для проверки самой логики извлечения не важно.
text = extract_text_from_pdf(_minimal_pdf_bytes("Hello, world"))
assert "Hello, world" in text
def test_non_pdf_content_raises_friendly_error():
# Частый реальный случай: файл с расширением .pdf, а внутри — HTML
html = b"<!DOCTYPE html><html><body>not a pdf</body></html>"
with pytest.raises(ValueError, match="не является PDF"):
extract_text_from_pdf(html)
def test_empty_bytes_raises_friendly_error():
with pytest.raises(ValueError, match="не является PDF"):
extract_text_from_pdf(b"")
def test_truncated_pdf_header_only_raises():
# Начинается как PDF (проходит magic-byte проверку), но структуры внутри нет
with pytest.raises(ValueError):
extract_text_from_pdf(b"%PDF-1.4\n" + b"garbage" * 20)

View File

@@ -0,0 +1,104 @@
"""Юнит-тесты счётчиков прогона парсинга (app.progress) — без БД и Celery."""
from app.progress import RunProgress
class FakeClock:
"""Управляемое время: тесты бюджета не должны ничего ждать по-настоящему."""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def test_counts_split_by_result_status():
prog = RunProgress(target=10, clock=FakeClock())
for status in ("indexed", "indexed", "duplicate", "skipped", "boom"):
prog.count_result(status)
assert prog.processed == 5
assert prog.added == 2
assert prog.duplicates == 1
# Мусор от источника не должен смешиваться с реальными ошибками:
# иначе панель отладки показывает ошибки там, где их нет
assert prog.skipped == 1
assert prog.failed == 1
def test_over_budget_only_after_deadline():
clock = FakeClock()
prog = RunProgress(target=100, budget_s=1500, clock=clock)
assert not prog.over_budget
clock.advance(1499)
assert not prog.over_budget
clock.advance(2)
assert prog.over_budget
def test_flush_is_throttled_by_interval():
clock = FakeClock()
prog = RunProgress(target=100, flush_interval_s=2.0, clock=clock)
assert not prog.should_flush()
clock.advance(2.5)
assert prog.should_flush()
prog.mark_flushed()
assert not prog.should_flush()
clock.advance(2.5)
assert prog.should_flush()
def test_log_keeps_only_tail():
prog = RunProgress(target=1, log_limit=3, clock=FakeClock())
for i in range(10):
prog.log("info", f"строка {i}")
assert len(prog.entries) == 3
assert [e["msg"] for e in prog.entries] == ["строка 7", "строка 8", "строка 9"]
def test_log_entry_shape():
clock = FakeClock()
prog = RunProgress(target=1, clock=clock)
clock.advance(12.34)
prog.log("warning", "x" * 900)
entry = prog.entries[0]
assert entry["level"] == "warning"
assert entry["elapsed"] == 12.3
# Длинные сообщения режем: журнал целиком лежит в одной JSON-колонке
assert len(entry["msg"]) == 500
assert entry["ts"]
def test_snapshot_carries_all_counters():
prog = RunProgress(target=42, clock=FakeClock())
prog.stage = "index"
prog.count_fetched(30)
prog.count_result("indexed")
prog.log("info", "поехали")
snap = prog.snapshot()
assert snap["stage"] == "index"
assert snap["target"] == 42
assert snap["fetched"] == 30
assert snap["processed"] == 1
assert snap["added"] == 1
assert snap["log"][0]["msg"] == "поехали"
def test_snapshot_log_is_detached_copy():
"""Снимок уходит в БД как есть — последующие записи не должны его менять."""
prog = RunProgress(target=1, clock=FakeClock())
prog.log("info", "первая")
snap = prog.snapshot()
prog.log("info", "вторая")
assert len(snap["log"]) == 1

View File

@@ -5,7 +5,9 @@ from app.algorithms.winnowing import (
get_ngrams,
hash_ngram,
jaccard_similarity,
sample_evenly,
winnow,
winnow_ordered,
)
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
@@ -77,3 +79,57 @@ def test_compute_similarity_partial_overlap_is_between():
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
sim = compute_similarity(LONG, modified)
assert 0.0 < sim < 1.0
# ─── Обрезка отпечатков по лимиту ────────────────────────────────────────────
# Регрессия: winnow() возвращает set, и list(fp)[:limit] брал произвольное
# подмножество — у длинного документа целые куски оставались без покрытия,
# и списывание из них не находилось.
def test_winnow_ordered_matches_winnow_by_content():
"""Тот же набор отпечатков, что и у winnow, только с порядком."""
assert set(winnow_ordered(LONG)) == winnow(LONG)
def test_winnow_ordered_has_no_duplicates():
ordered = winnow_ordered(LONG)
assert len(ordered) == len(set(ordered))
def test_winnow_ordered_follows_text_order():
"""Отпечатки начала текста идут раньше отпечатков продолжения."""
tail = " совершенно другой хвост про выпечку хлеба и закваску в тёплой печи"
ordered = winnow_ordered(LONG + tail)
head_prints = set(winnow_ordered(LONG))
positions = [i for i, h in enumerate(ordered) if h in head_prints]
# Отпечатки первой половины сосредоточены в начале списка, а не разбросаны
assert max(positions) < len(ordered)
assert positions[0] == 0
def test_sample_evenly_keeps_everything_under_limit():
items = [1, 2, 3]
assert sample_evenly(items, 10) == items
assert sample_evenly(items, 0) == items # 0 = без ограничения
def test_sample_evenly_respects_limit():
items = list(range(1000))
assert len(sample_evenly(items, 100)) == 100
def test_sample_evenly_covers_whole_document():
"""Главное свойство: выборка растянута по всей длине, а не обрезана с начала."""
items = list(range(1000))
sampled = sample_evenly(items, 10)
assert sampled[0] == 0
assert sampled[-1] >= 900 # хвост документа тоже покрыт
assert sampled == sorted(sampled) # порядок сохранён
def test_sample_evenly_spreads_uniformly():
items = list(range(100))
sampled = sample_evenly(items, 10)
gaps = [b - a for a, b in zip(sampled, sampled[1:], strict=False)]
assert max(gaps) - min(gaps) <= 1 # шаг ровный