Compare commits

..

75 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
jze9
b471ec767a feat: две доработки в духе коммерческих систем — цитаты и авто-корпус
Some checks failed
Deploy / test (push) Failing after 4m15s
Deploy / deploy (push) Has been skipped
Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает
две дыры относительно Антиплагиат.ру/Turnitin, о которых договорились.

1. Различение цитаты и голого плагиата (app.scoring.is_cited, worker-gpu):
   эвристика — фрагмент считается процитированным, если обрамлён кавычками
   («…», "…") либо сразу за ним (в пределах ~150 симв.) идёт скобочная ссылка
   с годом: [Иванов, 2023], (Smith, 2020) — совпадает и с нашим же форматом
   ГОСТ 7.0.5. aggregate_results теперь принимает full_text, размечает
   match["cited"] и считает uncited_similarity (доля БЕЗ похожих на цитаты —
   ближе к тому, что коммерческие системы называют "% некорректных
   заимствований") отдельно от overall_similarity (как было, для совместимости).
   Фронтенд: бейдж "Цитата" на совпадении + строка с разбивкой в отчёте
   (аддитивные опциональные поля в типах — старые задачи не ломаются).

2. Автопополнение корпуса проверенными работами (как у коммерческих систем —
   так ловится списывание у предыдущих потоков). Раньше загруженная на проверку
   работа складывалась в StagedWork и ждала РУЧНОГО одобрения админом — де-факто
   не пополняла базу для сравнения. Теперь index.auto_approve_submission
   (диспатчится из gpu.check_plagiarism ПОСЛЕ сохранения результата — чтобы
   работа не сматчилась сама с собой) добавляет её в documents автоматически,
   под настройкой AUTO_APPROVE_SUBMISSIONS (default True). Ручное
   approve/reject в админке остаётся рабочим (идемпотентно — auto-approve
   пропускает уже не-pending записи), пригодится при AUTO_APPROVE=False.
   Конвертация StagedWork→doc_data вынесена в чистый app/staging.py (без
   Celery/SQLAlchemy/MinIO) — тестируется изолированно, идентична ручному
   пути в admin.py (POST /admin/staging/{id}/approve).

Тестов добавлено 16 (scoring 8→18, новый staging.py — 6). Оба mypy-гейта
расширены (scoring.py, staging.py). Тестов всего: 112 (было 82 в последнем
подсчёте README — таблица давно отставала, заодно поправил на актуальные цифры
по всем сервисам, включая забытый в прошлый раз Qdrant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:09:43 +05:00
jze9
92a1ce0f57 feat(ingestion): скрипт бэкфилла full_text для CyberLeninka из OCR
Оставался незакоммиченным с предыдущего шага (докачка full-text). Переспрашивает
59 уже засеянных cyberleninka-тем из parse_sources, матчит raw-статьи с уже
залитыми documents по ext_id, и там где minio_key ещё не проставлен — пересчитывает
Winnowing-отпечатки из OCR full_text (не короткой annotation) и сохраняет текст в
MinIO. Идемпотентно (пропускает уже обработанные). Не трогает faiss_id/эмбеддинги.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:09:18 +05:00
jze9
2aac40ed3e feat(parsers): новый источник PubMed Central (PMC) — реальный полный текст
Отвечает на "неужели больше нет библиотек" — добавлен четвёртый источник.
PMC Open Access Subset (NCBI E-utilities) — крупнейший биомедицинский открытый
архив, бесплатный API без обязательного ключа (NCBI_API_KEY — опционально,
поднимает лимит 3→10 запросов/сек). В отличие от остальных источников отдаёт
РЕАЛЬНЫЙ полный текст статьи (bodyJATS XML) прямо в ответе efetch — 17-53К
символов на статью в проверке вживую, не аннотацию и не 700-символьный OCR-
фрагмент. Никаких новых зависимостей — xml.etree.ElementTree (stdlib) + httpx,
по образцу arxiv.py.

- scripts/parsers/pmc.py: esearch (open access[filter]) → efetch (батчи по 20,
  JATS XML) → плоские словари → unified schema. Год-фильтр, свой User-Agent.
- Зарегистрирован в index.run_parser (services/worker-indexer/app/tasks/index.py)
  и в CLI run_parser.py — доступен как source_type="pmc" наравне с остальными.
- 7 юнит-тестов на реальных JATS XML-фрагментах (без сети): извлечение полей,
  фильтр contrib-type=author (не editor), пустой/битый XML, батч из 2 статей.

Рассмотрены и НЕ добавлены: Semantic Scholar (общий rate-limit исчерпан без
API-ключа, ключ — самостоятельная регистрация юзера) и CORE.ac.uk (обязателен
ключ). Google Scholar/ResearchGate — намеренно не трогаем, скрейпинг нарушает ToS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:47:15 +05:00
jze9
cc87a11f07 feat(parsers): CyberLeninka full_text из OCR-фрагментов поиска (без доп. запросов)
Поисковый API КиберЛенинки отдаёт поле "ocr" — список OCR-фрагментов текста
статьи (начало + фрагмент с ключевыми словами), прямо в ответе search. Раньше
transform() игнорировал его (full_text всегда None), и Winnowing-отпечатки (L1)
считались только по короткой annotation (~150 симв.) либо не считались вовсе,
если аннотации не было.

Теперь ocr (список) склеивается, чистится (<b>/сущности) и идёт в full_text —
add_document уже умеет: `text = full_text or abstract`. Даёт заметно более точные
L1-отпечатки для ВСЕХ будущих CyberLeninka-документов бесплатно — 0 доп. HTTP-
запросов, работает и там, где annotation вообще пустая (проверено вживую).

2 новых теста (ocr→full_text, отсутствие ocr→None). Тестов в файле: 7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:37:12 +05:00
jze9
92e23b5209 feat(ingestion): расширение охвата корпуса — 64 новые дисциплины
Продолжение seed_ru_sources.py: все 30 базовых RU-тем + 26 EN-тем (OpenAlex) +
4 категории arXiv уже отработаны (done). Этот скрипт добавляет НОВОЕ поверх —
углубление RU (специализированные подотрасли права/экономики/IT, 29 тем) и
расширение EN на области, которых не было (право, лингвистика, история,
искусство, науки о Земле и т.д., 20 тем) + 15 новых тем arXiv. Идемпотентно
(префиксы ru2:/oa3:/arxiv2: не пересекаются с уже существующими).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:53:01 +05:00
jze9
ea5c3a962c docs: визуальная схема системы (Mermaid) — docs/DIAGRAM.md
Диаграмма компонентов и потоков данных (frontend/API/очереди/воркеры/общая
инфра/опциональные Qdrant и Prometheus-Grafana) + sequence-диаграмма конвейера
проверки плагиата L1-L4. Дополняет текстовое ARCHITECTURE.md, на который
ссылается. ARCHITECTURE.md теперь ссылается на DIAGRAM.md и INGESTION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:47:45 +05:00
jze9
6099ef621f fix(parsers): ленивый импорт bs4 в CyberLeninka + добавить в зависимости
Some checks failed
Deploy / test (push) Failing after 2m35s
Deploy / deploy (push) Has been skipped
Прод-воркер падал на index.run_parser с 'No module named bs4': парсер импортил
beautifulsoup4 на верхнем уровне, а в образе worker-indexer его нет. Но bs4 нужен
только для fetch_article_details (детали статьи), а заливке (fetch+transform) — нет.

- импорт bs4 сделан ленивым (внутри fetch_article_details) → заливка работает даже
  без bs4 в образе;
- beautifulsoup4 добавлен в worker-indexer/requirements.txt (для деталей статьи).

Это была вторая причина, почему CyberLeninka никогда не наполняла базу (первая —
GET вместо POST, 405). Проверено вживую: fetch без bs4 в пути работает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:10:55 +05:00
jze9
9d005486df fix(parsers): починить CyberLeninka + подготовить русскую заливку корпуса
Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских
студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP
405) и ни разу не наполнял базу.

- cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка
  (API отдаёт список, не строку); чистка <b>-подсветки и HTML-сущностей (&quot;).
  Проверено вживую: 5/5 студенческих тем возвращают реальные русские статьи.
- Юнит-тесты парсера (scripts/parsers/tests/, 5 шт.) + обвязка; run_tests.sh обобщён
  на пути → парсеры теперь в тест-гейте CI. Всего тестов: 87.
- scripts/seed_ru_sources.py: сидер parse_sources по 30 студенческим дисциплинам
  (dry-run по умолчанию, --apply для записи). НЕ запускает заливку — готовит задания.
- docs/INGESTION.md: runbook (текущее состояние, шаги запуска, проверка, масштаб).

Прод-путь index.run_parser уже поддерживает cyberleninka и openalex(lang=ru).
Заливку не запускал — это отдельный go (ресурсоёмко: GPU-эмбеддинги, рост БД).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:02:16 +05:00
jze9
e6c44f30dd docs: полное описание проекта — docs/ARCHITECTURE.md
Каноничная документация всей системы: назначение, схема, 6 сервисов и их зоны
ответственности, модель данных (все таблицы), Celery-очереди/задачи, ключевые
потоки (проверка плагиата, поиск, библиография, наполнение корпуса), 4 уровня
детекции, векторный бэкенд, инфра-топология (узлы/адреса), конфигурация, гейты
качества, наблюдаемость/эксплуатация, безопасность, раскладка репозитория.

README ссылается на ARCHITECTURE.md и DR-HA.md. Данные сверены с кодом (модели,
TaskType/TaskStatus, источники, тарифы, маршрутизация задач).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:45:23 +05:00
jze9
d32a07d0a8 feat(ops): DR-тест восстановления бэкапа PG + runbook HA/DR
Закрыта слепая зона «бэкапы есть, но восстановление не проверялось».

- scripts/ops/pg_restore_verify.sh — берёт последний дамп из MinIO backups/pg/,
  restore в ЭФЕМЕРНЫЙ postgres:16, проверяет ключевые таблицы (users/documents/
  tasks). Прод не трогает, всё в одноразовом контейнере. Под cron раз в неделю.
- docs/DR-HA.md — runbook: бэкапы, проверка восстановления, потоковая репликация
  PG, Redis-реплика+Sentinel, таблица SPOF со статусом митигаций.

Провижн реплик PG/Redis — на Proxmox (нужен новый LXC), это работа на железе, не
в репозитории; runbook даёт конкретные шаги. Векторный SPOF уже снимается Qdrant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:40:03 +05:00
jze9
d2d99d8231 feat(obs): наблюдаемость Prometheus + Grafana под профилем observability
Раньше видимости в прод не было — только email-монитор (костыль). Добавлено
без нагрузки по умолчанию (профиль не поднимается, пока не попросят):

- API: /metrics через prometheus-fastapi-instrumentator (кол-во/латентность
  запросов по хендлерам);
- Prometheus (infra/prometheus/prometheus.yml) скрейпит API и Flower — из Flower
  приходят метрики Celery (задачи, время, воркеры) вообще без доп. кода;
- Grafana с автопровижном источника Prometheus (infra/grafana/provisioning),
  пароль через GRAFANA_ADMIN_PASSWORD;
- docker-compose.prod.yml: сервисы prometheus/grafana под profiles:[observability]
  + volumes; .env.example и README пополнены.

Запуск: docker compose --profile observability up -d prometheus grafana.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:37:47 +05:00
jze9
012b17304f feat(gpu): Qdrant как векторный бэкенд под флагом — снимает SPOF FAISS
FAISS-индекс — файловый синглтон в RAM одного воркера (save на каждую запись,
без блокировок, без HA, не горизонтален). Добавлен альтернативный бэкенд Qdrant
с тем же classmethod-интерфейсом, выбор через VECTOR_BACKEND — аддитивно и
безопасно: дефолт остаётся faiss, ничего не ломается, пока не переключат.

- app/qdrant_manager.py — search/add_vectors/save(no-op)/total_vectors поверх
  qdrant-client (коллекция Cosine, id точки = doc_id, upsert идемпотентен);
- app/vector_store.py — get_backend() по настройке; задачи search/plagiarism
  переведены на него (больше не импортируют FAISSManager напрямую);
- app/migrate_faiss_to_qdrant.py — перелив существующих векторов (reconstruct
  из IndexIDMap2 → upsert), идемпотентно;
- docker-compose.prod.yml — сервис qdrant под профилем `qdrant` (по умолчанию не
  поднимается, ресурсов не ест) + volume; README — раздел про переключение.

Тесты (9) гоняют QdrantManager против ВСТРОЕННОГО Qdrant (qdrant-client :memory:,
не моки) + диспетчеризацию бэкенда. Всего тестов: 82 (indexer 24, gost 24, gpu 34).
Плюсы Qdrant активируются только после явного переключения + миграции.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:34:43 +05:00
jze9
37ee18ac55 docs(readme): секция «Тестирование и качество» + фикс устаревших фактов
Добавлена секция про CI-гейты (ruff+mypy, 73 юнит-теста, make-команды,
таблица покрытия по слоям L1-L4/скоринг/фрагменты/ГОСТ/библиография).

Заодно поправлены устаревшие технические утверждения, разошедшиеся с кодом:
- FAISS-GPU (IVFFlat) → FAISS-CPU (IndexIDMap2 · IndexFlatIP) — реальный индекс;
- Ollama llama3:8b → qwen2.5:7b; убран RTX 2060 (LLM на отдельном GPU-сервере);
- уточнено описание 4 уровней проверки под фактическую реализацию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:22:45 +05:00
jze9
ccc3521e74 refactor(gost): вынести сборку списка литературы в app.bibliography + 7 тестов
Сортировка (кириллица→латиница), нумерация и выбор форматтера жили внутри
Celery-задачи с БД и не тестировались — хотя это порядок и вид готового списка
литературы, который видит студент. Вынес в чистый app.bibliography.build_bibliography:

- нумерация сквозная с 1; total = число записей;
- сортировка по фамилии первого автора, кириллица раньше латиницы;
- стиль 7.1 → полное описание (format_full), иначе 7.0.5 → краткая ссылка;
- doc_id сохраняется в каждой записи; пустой список → total 0.

ORM→dict конверсия осталась в задаче (она из БД), поведение сохранено 1:1.
Добавлен в mypy-гейт. Тестов всего: 73 (indexer 24, gost 24, gpu 25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:20:01 +05:00
jze9
b2d2061a83 refactor(indexer): вынести разбиение на фрагменты в app.fragments + 5 тестов
_split_into_fragments жила в тяжёлой Celery-задаче (celery/sqlalchemy/minio) и
не тестировалась, хотя именно она определяет, ЧТО проверяется на плагиат.
Вынес в чистый app.fragments.split_into_fragments — тестируется изолированно:

- пустой/пробельный текст → [];
- текст короче 20 слов → отбрасывается порогом длины;
- короткий текст → один фрагмент на весь объём, start=0;
- скользящее окно: перекрытие ровно (window-step) слов, хвост==голова соседа;
- start/end — корректные символьные офсеты в исходный текст.

Поведение сохранено 1:1. Добавлен в mypy-гейт. Тестов всего: 66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:17:33 +05:00
jze9
4c15f11efa refactor(gpu): вынести скоринг плагиата в чистый app.scoring + 8 тестов
Итоговый процент схожести (тот, что видит студент) и дедупликация совпадений
жили внутри Celery-задачи check_plagiarism и не тестировались. Вынес в чистую
функцию app.scoring.aggregate_results (без Celery/БД/сети) — правильная «высота»
(доменная логика отдельно от оркестрации) и теперь покрыто тестами:

- процент = доля УНИКАЛЬНЫХ помеченных позиций от всех фрагментов;
- одна позиция с несколькими источниками считается один раз (не раздувает %);
- точный дубль (source_title:pos) дедуплицируется;
- деление на ноль фрагментов; кап на 100%; округление до 2 знаков; by_method.

Поведение сохранено 1:1 (лог задачи переведён на result[...]). scoring.py добавлен
в mypy-гейт. Тестов всего: 61 (indexer 19, gost 17, gpu 25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:13:27 +05:00
jze9
426796a9a7 test(gpu): покрыть L4 OllamaClient (парсинг парафраза, 12 тестов)
Сеть замокана (monkeypatch httpx.post/get). Проверяется устойчивость слоя LLM:
- нормализация ответа: is_paraphrase→bool, confidence→float, reason→str;
- кривой/невалидный JSON от модели → безопасные дефолты, не падение;
- отсутствие ключа "response" → пустой объект → безопасные значения;
- таймаут/ConnectError → «LLM недоступна»; прочие ошибки перехвачены;
- summarize: strip ответа и фолбэк в аннотацию/заголовок при ошибке;
- is_available: 200 → True, исключение → False.

Тестов всего: 53 (indexer 19, gost 17, gpu 17). httpx добавлен в requirements-test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:10:34 +05:00
jze9
e79f254af6 chore(lint): добавить mypy в CI-гейт — старт градуальной типизации
Ruff уже стоял; теперь рядом mypy как проверка типов. Прагматичный конфиг
mypy.ini (ловит реальные несовпадения типов/обращения к None/неверные аргументы,
но не требует аннотаций везде и не шумит на сторонних либах) — чтобы гейт был
зелёным и расширяемым.

Область на старте — только чистая логика, которая уже типобезопасна:
worker-indexer/app/algorithms (L1 winnowing, L2 minhash) и
worker-gost/app/formatters (ГОСТ 7.1 / 7.0.5). Запуск per-service, чтобы
резолвился локальный пакет app. faiss_manager вне области пока — требует
Optional-рефактора _index; на следующий заход.

run_lint.sh теперь гоняет ruff + mypy в одном контейнере; шаг CI переименован.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:07:44 +05:00
jze9
2daaa8c8a4 chore(lint): ruff-гейт в CI + фиксы (0 находок) — блокирует кривой деплой
Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода
(services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе.

Конфиг ruff.toml: правила E/F/W/I/UP/B/SIM/C4, line-length 100. Осознанно
выключены E501 (длину держит форматтер; длинные RU-комментарии — норма),
B008 (Depends()/Query() в дефолтах — идиома FastAPI, не баг) и UP042
((str, Enum)→StrEnum меняет __str__/сериализацию — не трогаем).

Починено под ноль находок:
- B904 (11): raise ... from exc / from None — читаемые цепочки исключений в
  Celery-ретраях и HTTPException, ошибки обработки не маскируют исходные.
- SIM105 (5): try/except/pass → contextlib.suppress (faiss remove_ids, lsh.remove,
  сброс кэша, ws-disconnect, парс года).
- C416/SIM108/B905/F841/UP035/UP017/F401/I001: dict(rows), тернарник, zip strict,
  мёртвая переменная, устаревшие импорты, timezone.utc→UTC, чистка/сортировка.

Обвязка: scripts/run_lint.sh (ruff в изолированном python:3.11-slim), шаг «Линт»
в job test перед юнит-тестами (падаем раньше). make lint / make lint-fix.
Все 41 юнит-тест по-прежнему зелёные, изменённые файлы компилируются.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 17:20:24 +05:00
jze9
ecc10a4413 test: юнит-суит L1/L2/L3 + ГОСТ (41 тест) и гейт в CI перед деплоем
Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились
руками. Покрыта чистая логика детекции и форматирования (без БД/Redis/GPU/Ollama):

- worker-indexer: L1 Winnowing (точные совпадения, идемпотентность отпечатка,
  диапазон signed int64) и L2 MinHash LSH (шинглы, Jaccard, near-duplicate +
  upsert через in-memory-фолбэк).
- worker-gpu: L3 FAISS — возврат doc_id из PostgreSQL (IndexIDMap2),
  идемпотентность add_vectors (remove-before-add, без дублей), self-match ≈ 1,
  ранжирование. Прямо стережёт баги, из-за которых индекс переписывался.
- worker-gost: ГОСТ 7.1-2003 и 7.0.5-2008 — авторы (≤3 / 4+ «и др.»/et al.),
  статья/книга/web, DOI, порядок сортировки кириллица→латиница, стр. в ссылке.

Обвязка: per-service pytest.ini/conftest/requirements-test. scripts/run_tests.sh
гоняет тесты в изолированных python:3.11-slim контейнерах (не засоряя хост),
через Tsinghua-зеркало. CI: job `test` теперь гейтит `deploy` (needs: test) —
падение тестов блокирует прод-деплой. make test / make test-one SVC=...

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 17:11:08 +05:00
154 changed files with 9833 additions and 695 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
@@ -39,3 +48,11 @@ SMTP_TLS_VERIFY=true
# App
APP_URL=https://academic.jze9.ru
ENVIRONMENT=development
# Векторный бэкенд (опционально): faiss (по умолчанию) или qdrant. См. README.
VECTOR_BACKEND=faiss
QDRANT_URL=http://qdrant:6333
QDRANT_COLLECTION=documents
# Наблюдаемость (профиль observability, опционально)
GRAFANA_ADMIN_PASSWORD=admin

View File

@@ -13,7 +13,38 @@ concurrency:
cancel-in-progress: false
jobs:
test:
runs-on: deploy
steps:
- name: Клонировать репозиторий (depth 1 — для тестов история не нужна)
run: |
set -euo pipefail
SRC="$(mktemp -d)"
echo "SRC=$SRC" >> "$GITHUB_ENV"
git clone --branch main --depth 1 \
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
- name: Линт (ruff + mypy) — быстрый гейт, падаем раньше тестов
run: |
set -euo pipefail
cd "$SRC"
bash scripts/run_lint.sh
- name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах
run: |
set -euo pipefail
cd "$SRC"
bash scripts/run_tests.sh
- name: Убрать временный чекаут
if: always()
# Часть файлов (mypy/pytest кэш) создана внутри Docker-контейнеров от
# root — обычный пользователь раннера не может их удалить. Это не
# повод валить джобу: код уже проверен, чистка — best-effort.
run: rm -rf "${SRC:-/tmp/none}" || true
deploy:
needs: test # деплой только если юнит-тесты прошли
runs-on: deploy
steps:
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
@@ -27,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"
@@ -34,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

@@ -1,4 +1,4 @@
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
.PHONY: dev build migrate logs shell-api shell-gpu lint lint-fix test test-one down ps restart clean test-up test-down test-logs test-ps
# ─── Переменные ────────────────────────────────────────────────────────────────
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
@@ -100,18 +100,25 @@ shell-redis:
@echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
# ─── Линтинг и тесты ──────────────────────────────────────────────────────────
# Ruff-линт всего Python-кода в изолированном контейнере (конфиг — ruff.toml).
# Тот же скрипт гоняет CI как гейт перед деплоем. Автофиксы: make lint-fix.
lint:
$(COMPOSE_PROD) exec api ruff check . --fix
$(COMPOSE_PROD) exec api mypy app/
bash scripts/run_lint.sh
lint-fix:
ruff check services/ scripts/ --fix
lint-frontend:
cd services/frontend && npm run lint
# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах —
# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем.
test:
$(COMPOSE_PROD) exec api pytest tests/ -v
bash scripts/run_tests.sh
test-cov:
$(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
# Тесты одного сервиса, напр.: make test-one SVC=worker-gost
test-one:
bash scripts/run_tests.sh $(SVC)
# ─── Утилиты ──────────────────────────────────────────────────────────────────
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —

149
README.md
View File

@@ -5,6 +5,9 @@
Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию.
Всё асинхронно: студент закрыл браузер, получил email когда готово.
> 📐 Полное описание системы — [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
> Отказоустойчивость и восстановление — [docs/DR-HA.md](docs/DR-HA.md).
## Архитектура
```
@@ -20,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│ └──────────────────┘
└────────────────┘
@@ -32,7 +35,8 @@
Инфраструктура:
PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8
MinIO (4TB) · Ollama (llama3:8b) · NVIDIA RTX 2060
MinIO (S3) · Ollama с bge-m3 на отдельном сервере эмбеддингов (192.168.1.40)
LLM-парафраз (L4) — Ollama или OpenRouter, переключается LLM_BACKEND
```
## Быстрый старт
@@ -74,8 +78,8 @@ make migrate # Применить Alembic миграции
make logs # Логи всех сервисов
make shell-api # Shell в контейнере API
make shell-gpu # Shell в контейнере GPU воркера
make lint # Запустить линтер
make test # Запустить тесты
make lint # ruff + mypy в контейнере (тот же гейт, что в CI)
make test # юнит-тесты всех сервисов в контейнерах
make clean # Удалить контейнеры и volumes
```
@@ -84,20 +88,25 @@ make clean # Удалить контейнеры и volumes
| Компонент | Технологии |
|-----------|-----------|
| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic |
| GPU Worker | sentence-transformers, FAISS-GPU (IVFFlat), Ollama (llama3:8b) |
| 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 |
| Поиск | Elasticsearch 8 (BM25) + FAISS GPU (cosine) |
| Поиск | Elasticsearch 8 (BM25) + FAISS (cosine, IndexFlatIP) |
| Хранилище | MinIO (S3-совместимый) |
| Frontend | React 18, Vite, TypeScript, TailwindCSS, Zustand, React Query v5 |
## Проверка плагиата (4 уровня)
1. **Winnowing + MinHash** (~мс) — точное совпадение по fingerprints
2. **n-граммы + Jaccard** (~сек) — нечёткое совпадение
3. **FAISS GPU cosine** (~мс) — семантическое сходство (порог 0.75)
4. **Ollama Llama3** (~2сек) — LLM анализ парафраза (confidence > 0.7)
1. **Winnowing** — точные/частичные совпадения по fingerprint'ам (xxHash + скользящее окно)
2. **MinHash LSH** — нечёткие совпадения (шинглы + Jaccard, индекс в общем Redis)
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%.
## Тарифные планы
@@ -127,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` для полного списка переменных.
@@ -138,26 +156,117 @@ 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) — на случай отдельного выделенного сервера в будущем. |
## Продакшн деплой
Автоматический: push в `main` → Gitea Actions (`.gitea/workflows/deploy.yml`) → гейт
`test` (lint + юнит-тесты) → `deploy` → `scripts/deploy.sh` на app-хосте. Умная
пересборка — образ пересобирается только у сервисов, чей код изменился с прошлого
деплоя (маркер SHA в `.last_deploy_sha`); правка `docker-compose.prod.yml` триггерит
полную пересборку всех пяти бэкенд-сервисов.
**`.env` на проде НЕ редактируется руками.** Первым шагом `deploy.sh` логинится в
self-hosted Infisical (Machine Identity, Universal Auth) и генерирует `.env` заново
из окружения `prod` на каждом запуске — ручные правки файла на сервере переживут
максимум до следующего деплоя. Менять секреты/конфиг — через Infisical
(`https://infisical.jze9.ru`, проект `academ`), не через `.env` напрямую. Если
Infisical недоступен или вернул подозрительно мало ключей, деплой падает раньше
синка кода и не трогает рабочий `.env`.
Фронтенд собирается отдельно (`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/`.
Ручной запуск деплоя (например, после смены секрета в Infisical без изменений кода) —
`workflow_dispatch` в Gitea Actions, или локально: `PVE_PASSWORD=... INFISICAL_CLIENT_ID=... INFISICAL_CLIENT_SECRET=... bash scripts/deploy.sh` из полного чекаута репозитория.
## Векторный бэкенд (FAISS / Qdrant)
Семантический индекс (уровень 3) спрятан за `app.vector_store.get_backend()` и
переключается настройкой `VECTOR_BACKEND` — без изменения кода:
- **`faiss`** (по умолчанию) — файловый `IndexIDMap2(IndexFlatIP)` в RAM воркера.
Просто, но это единая точка отказа и без конкурентной записи.
- **`qdrant`** — сетевой сервис: снимает SPOF, допускает конкурентный upsert из
нескольких воркеров, переживает рестарт, масштабируется горизонтально.
Переключение на Qdrant (аддитивно, ничего не ломает до шага 2):
```bash
# На сервере
cp .env.example .env
nano .env # Настроить все пароли и ключи, указать реальные адреса общих Postgres/Redis/RabbitMQ/MinIO/Ollama
# 1. Поднять Qdrant (профиль qdrant в docker-compose.prod.yml)
docker compose -f docker-compose.prod.yml --profile qdrant up -d qdrant
# Сертификат ДО первого запуска nginx-контейнера (standalone, порт 80 должен быть свободен)
certbot certonly --standalone -d academic.jze9.ru
# 2. В .env выставить VECTOR_BACKEND=qdrant (QDRANT_URL по умолчанию http://qdrant:6333)
make build
make up
make migrate
# 3. Перелить существующие векторы FAISS → Qdrant (идемпотентно)
docker compose -f docker-compose.prod.yml exec worker-gpu python -m app.migrate_faiss_to_qdrant
# 4. Перезапустить GPU-воркер
docker compose -f docker-compose.prod.yml up -d worker-gpu
```
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`), контейнер просто читает его.
## Наблюдаемость (Prometheus + Grafana)
Опционально (профиль `observability`, по умолчанию не поднимается):
```bash
docker compose -f docker-compose.prod.yml --profile observability up -d prometheus grafana
```
- API отдаёт HTTP-метрики на `/metrics` (кол-во и латентность запросов по хендлерам).
- Prometheus (`infra/prometheus/prometheus.yml`) скрейпит API и **Flower** — из
Flower приходят метрики Celery (задачи, время выполнения, воркеры) без доп. кода.
- Grafana с автоподключённым источником Prometheus (`infra/grafana/provisioning/`);
пароль admin — `GRAFANA_ADMIN_PASSWORD` в Infisical (prod). Если не задан — падает
на дефолт `admin`, поэтому перед включением профиля `observability` в проде
убедиться, что значение в Infisical реально установлено (не пустое).
## Тестирование и качество кода
Перед деплоем CI (`.gitea/workflows/deploy.yml`, job `test`) прогоняет два гейта,
и `deploy` стартует, только если оба зелёные — кривой код в прод не уезжает:
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
2. **Юнит-тесты** — `pytest` по сервисам: 150 тестов на ядро детекции, скоринга,
парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
(БД/Redis/GPU/Ollama замоканы либо не нужны).
```bash
make lint # ruff + mypy в изолированном контейнере
make lint-fix # авто-исправления ruff
make test # все юнит-тесты в контейнерах
make test-one SVC=worker-gost # тесты одного сервиса
```
Всё гоняется в `python:3.11-slim` (не засоряя хост) через
[`scripts/run_lint.sh`](scripts/run_lint.sh) и [`scripts/run_tests.sh`](scripts/run_tests.sh).
**Что покрыто.** Чистая логика вынесена из Celery-задач в отдельные тестируемые
модули (доменная логика отдельно от оркестрации):
| Слой | Модуль | Тестов |
|------|--------|:------:|
| L1 — точные совпадения | `worker-indexer/app/algorithms/winnowing.py` | 13 |
| 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/` | 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

@@ -13,6 +13,9 @@ networks:
volumes:
es_prod_data:
faiss_index_prod:
qdrant_prod_data:
prometheus_data:
grafana_data:
x-app-env: &app-env
env_file: .env
@@ -49,6 +52,51 @@ services:
retries: 10
start_period: 60s
# Qdrant — альтернативный векторный бэкенд (снимает SPOF файлового FAISS).
# Опционален: поднимается только с профилем и активируется VECTOR_BACKEND=qdrant.
# docker compose -f docker-compose.prod.yml --profile qdrant up -d qdrant
# docker compose -f docker-compose.prod.yml exec worker-gpu python -m app.migrate_faiss_to_qdrant
# Тег сервера при необходимости поднять до версии клиента (qdrant-client 1.19).
qdrant:
image: qdrant/qdrant:v1.12.4
container_name: antiplagiator-qdrant
profiles: ["qdrant"]
volumes:
- qdrant_prod_data:/qdrant/storage
networks:
- antiplagiator
restart: unless-stopped
# ─── Наблюдаемость (опционально; профиль observability) ────────────────────
# docker compose -f docker-compose.prod.yml --profile observability up -d prometheus grafana
# Prometheus скрейпит api:/metrics и flower:/metrics (метрики Celery).
prometheus:
image: prom/prometheus:v2.54.1
container_name: antiplagiator-prometheus
profiles: ["observability"]
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
networks:
- antiplagiator
restart: unless-stopped
grafana:
image: grafana/grafana:11.2.0
container_name: antiplagiator-grafana
profiles: ["observability"]
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
networks:
- antiplagiator
restart: unless-stopped
depends_on:
- prometheus
# ─── Приложение ────────────────────────────────────────────────────────────
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
api:
@@ -57,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
@@ -72,6 +138,8 @@ services:
depends_on:
elasticsearch:
condition: service_healthy
singbox-proxy:
condition: service_started
worker-indexer:
build:
@@ -84,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:

300
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,300 @@
# Архитектура — Академический помощник
Каноничное описание системы. Обновляется вместе с кодом; при расхождении верить
коду, а не этому файлу. Визуальная схема — [DIAGRAM.md](DIAGRAM.md). Смежные
документы: [DR-HA.md](DR-HA.md) (отказоустойчивость), [INGESTION.md](INGESTION.md)
(наполнение корпуса), [../README.md](../README.md) (быстрый старт и команды).
## 1. Назначение
Веб-сервис для студентов: ввёл тему → система находит **открытые** академические
источники, делает изложения, форматирует список литературы по ГОСТ и проверяет текст
на плагиат. Всё **асинхронно**: пользователь отправляет задачу, закрывает браузер и
получает результат на email (или в реальном времени через WebSocket, если вкладка открыта).
## 2. Общая схема
```
┌────────────────────────────┐
│ Frontend (React SPA) │ CT 102 → nginx
│ Home/Search/Check/Cabinet/ │
│ Bibliography/Settings/Admin │
└──────────────┬──────────────┘
│ HTTPS (/api, /ws)
┌──────────────▼──────────────┐
│ API Gateway (FastAPI) │ 1.32:8000
│ JWT · rate-limit · WebSocket │
│ /metrics (Prometheus) │
└───┬───────────────────────┬──┘
│ publish (RabbitMQ) │ read/write
│ │
┌────────────────────┼───────────────────┐ │
▼ ▼ ▼ │
queue.index queue.gpu queue.gost
┌───────────┐ ┌────────────┐ ┌───────────┐
│ worker- │ │ worker-gpu │ │ worker- │
│ indexer │ │ FAISS/Qdr. │ │ gost │
│ L1 Winnow │ │ L3 семант. │ │ ГОСТ 7.1/ │
│ L2 MinHash│ │ L4 LLM │ │ 7.0.5 │
│ PDF/DOCX │ │ эмбеддинги │ │ │
└─────┬─────┘ └──────┬─────┘ └─────┬─────┘
│ │ │
└─────────┬─────────┴───────┬──────────┘
▼ ▼
queue.notify общие данные
┌────────────┐ ┌──────────────────────────────┐
│ worker- │ │ PostgreSQL 1.38 · Redis 1.35 │
│ notifier │ │ RabbitMQ .82 · MinIO 1.21 │
│ SMTP email │ │ Elasticsearch (local 1.32) │
└────────────┘ │ Ollama 1.40 (bge-m3, эмбед.) │
│ OpenRouter/DeepSeek — LLM (опц.)│
│ [opt] Qdrant · Prometheus/Graf.│
└──────────────────────────────┘
```
## 3. Сервисы
| Сервис | Технологии | Ответственность |
|--------|-----------|-----------------|
| **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, 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 |
## 4. Модель данных (PostgreSQL)
- **users** — `email`, `hashed_password` (bcrypt), `name`, `is_verified`, `is_admin`,
`plan` (free/student/premium), `verification_token`.
- **tasks** — `id` (UUID), `public_id` (внешний), `user_id`, `type` (`TaskType`:
search/plagiarism/summarize/gost), `status` (`TaskStatus`:
queued→processing→done/failed), `celery_task_id`, `input_data` (JSON),
`result` (JSON), `error`, `queue_position`.
- **documents** — корпус источников: `source` (openalex/arxiv/cyberleninka/
user_submission), `ext_id`, `doi`, `title`, `authors` (JSON), `year`, `lang`,
`journal/volume/issue/pages`, `abstract`, `url`, `minio_key` (full-text в MinIO),
`faiss_id`.
- **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1.
- **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу.
- **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** — одноразовые коды входа в админку.
## 5. Асинхронный конвейер (Celery + RabbitMQ)
Брокер — RabbitMQ, backend результатов — Redis. Маршрутизация по префиксу задачи:
| Очередь | Задачи | Воркер |
|---------|--------|--------|
| `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 |
Важно: API **коммитит задачу в БД до** `send_task` (иначе гонка dispatch-before-commit).
## 6. Ключевые потоки
**Проверка плагиата.** upload (api, файл→MinIO, Task) → `index.extract_and_check`
(извлечь текст → фрагментация → **L1 Winnowing** по fingerprints → **L2 MinHash LSH**
в Redis) → передаёт частичные совпадения в `gpu.check_plagiarism` (**L3** FAISS/Qdrant
семантика по фрагментам → для подозрительных **L4** LLM-парафраз) →
`app.scoring.aggregate_results` (итоговый %) → результат в Task → `notify.send_task_done`.
Фронтенд (`HighlightedDocument.tsx`) показывает исходный текст работы с подсветкой
совпадающих/процитированных фрагментов поверх обычного списка нарушений
(`PlagiarismReport.tsx`).
**Поиск источников.** api → `gpu.search_semantic`: эмбеддинг запроса → векторный поиск
(FAISS/Qdrant) + Elasticsearch BM25 → объединение → результат.
**Список литературы.** api → `gost.format_bibliography`: документы из БД →
`app.bibliography.build_bibliography` (сортировка кириллица→латиница, нумерация,
формат 7.1/7.0.5) → результат.
**Наполнение корпуса.** админка → `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 уровня
1. **L1 Winnowing** (`worker-indexer/app/algorithms/winnowing.py`) — точные/частичные
совпадения: k-граммы → xxHash → минимум в скользящем окне → fingerprint; Jaccard.
2. **L2 MinHash LSH** (`.../minhash.py`) — нечёткие совпадения: шинглы → MinHash (128
перм.) → LSH-индекс в **общем Redis** (префикс `antiplag_lsh`, upsert, graceful-фолбэк
в память).
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. Векторный бэкенд
Абстрагирован за `worker-gpu/app/vector_store.get_backend()`; `VECTOR_BACKEND=faiss`
(файловый синглтон, дефолт) или `qdrant` (сервис, снимает SPOF/конкурентную запись).
Переключение и миграция — см. README, раздел «Векторный бэкенд».
## 9. Инфраструктура и топология
| Компонент | Узел | Примечание |
|-----------|------|-----------|
| api + воркеры + Elasticsearch | 1.32 (app-хост) | docker-compose.prod.yml |
| Frontend (nginx, статика + TLS) | CT 102 | деплоится отдельно |
| PostgreSQL 16 | 1.38 (выделенный LXC) | UTF8; бэкап→MinIO |
| Redis 7 | 1.35 (выделенный LXC) | кэш, rate-limits, LSH |
| RabbitMQ | 192.168.20.82 | брокер Celery |
| MinIO (S3) | 1.21 | документы, full-text, бэкапы |
| 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-профилями |
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/RabbitMQ/MinIO не в compose — общая инфра берётся из `.env`,
который на каждом деплое генерируется заново из Infisical (см. §10).
## 10. Конфигурация и секреты
Приложение читает `.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. Качество и тесты
**Замер качества детекции** — [`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`.
- Чистая доменная логика вынесена из Celery-задач в тестируемые модули
(`scoring.py`, `fragments.py`, `bibliography.py`). Подробнее — README «Тестирование».
## 12. Наблюдаемость и эксплуатация
- **Мониторинг**: [`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. Безопасность
JWT-аутентификация, bcrypt-хэши паролей, email-верификация, отдельный вход в админку
(одноразовые коды, `admin_sessions`). Rate-limits по тарифу — в Redis. CORS — явные
origins. Пользователь видит только свои задачи (ownership проверяется, в т.ч. на WebSocket).
## 14. Раскладка репозитория
```
services/ api, worker-{gpu,indexer,notifier,gost}, frontend
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
```

113
docs/DIAGRAM.md Normal file
View File

@@ -0,0 +1,113 @@
# Схема системы
Визуальная схема к [ARCHITECTURE.md](ARCHITECTURE.md) (там — полное текстовое описание).
## Компоненты и потоки данных
```mermaid
flowchart TB
subgraph client["Клиент"]
FE["Frontend (React SPA)<br/>CT 102, nginx"]
end
subgraph app["App-хост 1.32"]
API["API (FastAPI)<br/>JWT · rate-limit · /metrics"]
MQ[("RabbitMQ .82<br/>брокер Celery")]
subgraph workers["Celery-воркеры"]
IDX["worker-indexer<br/>L1 Winnowing · L2 MinHash<br/>PDF/DOCX · парсеры"]
GPU["worker-gpu<br/>L3 семантика · L4 LLM<br/>эмбеддинги"]
GOST["worker-gost<br/>ГОСТ 7.1 / 7.0.5"]
NOTIFY["worker-notifier<br/>SMTP email"]
end
ES[("Elasticsearch<br/>BM25, local")]
VEC{{"Векторный бэкенд<br/>VECTOR_BACKEND"}}
FAISS["FAISS<br/>(файл, дефолт)"]
QDR["Qdrant<br/>(опционально)"]
end
subgraph shared["Общая инфраструктура"]
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 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)"]
PROM["Prometheus"]
GRAF["Grafana"]
end
FE -->|"HTTPS /api, /ws"| API
API -->|"send_task"| MQ
API <-->|"users/tasks"| PG
API <-->|"session/rate-limit"| REDIS
MQ -->|"queue.index"| IDX
MQ -->|"queue.gpu"| GPU
MQ -->|"queue.gost"| GOST
MQ -->|"queue.notify"| NOTIFY
IDX <-->|"documents/fingerprints"| PG
IDX <-->|"MinHash LSH (antiplag_lsh)"| REDIS
IDX <-->|"PDF/full-text"| MINIO
IDX --> ES
IDX -->|"gpu.embed_documents"| MQ
GPU <-->|"documents"| PG
GPU -->|"L3 эмбеддинги"| OLLAMA
GPU --> VEC
VEC --> FAISS
VEC -.->|"переключение флагом"| QDR
GPU -.->|"L4 парафраз (LLM_BACKEND)"| OLLAMA
GPU -.->|"L4 парафраз, опц."| OPENROUTER
GPU --> ES
GOST <-->|"documents"| PG
NOTIFY <-->|"tasks"| PG
NOTIFY -->|"email"| SMTP(["jze9mail.ru"])
API -->|"/metrics"| PROM
PROM --> GRAF
style VEC fill:#00000000,stroke-dasharray: 4 3
style obs fill:#00000000,stroke-dasharray: 4 3
```
## Конвейер проверки плагиата (4 уровня)
```mermaid
sequenceDiagram
participant U as Студент
participant API as API
participant IDX as worker-indexer
participant GPU as worker-gpu
participant LLM as Ollama/OpenRouter<br/>(LLM_BACKEND)
U->>API: upload файла
API->>API: сохранить в MinIO, создать Task, commit
API->>IDX: index.extract_and_check
IDX->>IDX: извлечь текст → фрагменты
IDX->>IDX: L1 Winnowing (fingerprints)
IDX->>IDX: L2 MinHash LSH (Redis)
IDX->>GPU: gpu.check_plagiarism (частичные совпадения)
loop по подозрительным фрагментам
GPU->>GPU: L3 семантический поиск (FAISS/Qdrant)
GPU->>LLM: L4 check_paraphrase
LLM-->>GPU: is_paraphrase, confidence
end
GPU->>GPU: app.scoring.aggregate_results (итоговый %)
GPU->>API: результат → Task.result
GPU-->>U: notify.send_task_done → email/WebSocket
```
## Легенда
- Сплошные стрелки — прямые вызовы/запросы; пунктир у `VEC` — переключение бэкенда
по настройке `VECTOR_BACKEND`, не одновременная работа обоих. Пунктир у L4
(`OLLAMA`/`OPENROUTER`) — то же самое для `LLM_BACKEND`.
- `obs` (Prometheus/Grafana) и `Qdrant` — опциональны, поднимаются под
`docker compose --profile qdrant|observability`, по умолчанию выключены.

101
docs/DR-HA.md Normal file
View File

@@ -0,0 +1,101 @@
# Отказоустойчивость и восстановление (HA / DR)
Актуальная топология: PostgreSQL на выделенном LXC **1.38**, Redis на **1.35**,
RabbitMQ на **192.168.20.82**, app+ES на **1.32**. Это не docker-compose, поэтому
HA для БД/кэша делается на уровне Proxmox/LXC, а не в этом репозитории.
## 1. Бэкапы (сделано)
`pg_dump → gzip → MinIO backups/pg/`, cron ежедневно 03:00 на 1.38, ротация 14 дней.
## 2. Проверка восстановимости (сделано — было слепой зоной)
Бэкап без проверенного восстановления = отсутствие бэкапа. Скрипт
[`scripts/ops/pg_restore_verify.sh`](../scripts/ops/pg_restore_verify.sh) берёт
последний дамп из MinIO, restore в **эфемерный** `postgres:16` и проверяет ключевые
таблицы. Прод не трогает.
```bash
bash scripts/ops/pg_restore_verify.sh # креды из .env
```
Рекомендация: cron раз в неделю на 1.32, алерт (как в antiplag_monitor) при ненулевом коде.
## 3. HA PostgreSQL — потоковая репликация (требует новый LXC)
Провижн реплики — задача на Proxmox (нужен ещё один LXC, напр. 1.39). Шаги:
1. **Primary (1.38)** `postgresql.conf`: `wal_level=replica`, `max_wal_senders=5`,
`wal_keep_size=1GB`; `pg_hba.conf`: строка `replication` для IP реплики.
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`
в Infisical (окружение `prod` — не в `.env` на сервере напрямую, его перезапишет
следующий деплой, см. ARCHITECTURE.md §10) и передеплой/рестарт сервисов
(или автоматизация через Patroni + etcd — если нужен авто-failover).
4. Проверка лага: `SELECT * FROM pg_stat_replication` на primary.
## 4. HA Redis — реплика + Sentinel (требует новый LXC)
Redis у нас — кэш/rate-limits/LSH-индекс (префикс `antiplag_lsh`). Потеря = деградация,
не потеря данных задач (они в PG). Если нужна устойчивость:
1. Второй Redis (реплика): `replicaof 1.35 6379` + тот же `requirepass`.
2. **3× Sentinel** (на app-хостах): `sentinel monitor antiplag 1.35 6379 2`,
авто-переключение мастера.
3. Клиенты (Celery/кэш) → на Sentinel-aware подключение (`redis.sentinel`),
либо оставить прямое подключение + ручной перевод `REDIS_URL` при аварии.
## 5. Единые точки отказа — статус
| Компонент | SPOF | Митигация |
|-----------|:----:|-----------|
| PostgreSQL 1.38 | да | бэкап+restore-тест ✅; реплика — §3 (нужен LXC) |
| Redis 1.35 | да | graceful-фолбэк LSH в память ✅; реплика+Sentinel — §4 |
| Векторный индекс | было | `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 (доступа для этого пока не заводили).

186
docs/INGESTION.md Normal file
View File

@@ -0,0 +1,186 @@
# Наполнение корпуса — runbook
## Текущее состояние (на 2026-08-31)
- **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).
## Что подготовлено
- **Парсер CyberLeninka починен** (`scripts/parsers/cyberleninka.py`): раньше слал GET
на `/api/search` → HTTP 405; теперь POST с JSON-телом (`mode=articles`), authors из
списка, чистка `<b>`/HTML-сущностей. Проверено вживую (5/5 тем) + юнит-тесты
(`scripts/parsers/tests/`), в гейте CI.
- **Прод-путь готов**: `index.run_parser(source_id)` уже умеет `cyberleninka` и
`openalex` c `lang=ru`.
- **Сидер источников**: `scripts/seed_ru_sources.py` — 30 студенческих дисциплин.
## Запуск русской заливки
```bash
# 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 русских документов на первый заход.
## Проверка результата
```sql
SELECT lang, count(*) FROM documents GROUP BY lang ORDER BY 2 DESC; -- должен появиться ru
SELECT source, count(*) FROM documents WHERE source='cyberleninka'; -- > 0
```
## Масштаб (следующий уровень)
- Больше тем + выше `--limit`; добавить OpenAlex `lang=ru` (качество ниже — англ.
заголовки с меткой ru).
- Для миллионов — **bulk**, а не постраничный API. Снапшот OpenAlex для этого не
годится: там только метаданные, а миллионы аннотаций детекции не дают (см.
провал КиберЛенинки выше). Рабочий источник полных текстов — бакет
`pmc-oa-opendata`, см. «Массовая заливка» выше.
- На масштабе обязателен `VECTOR_BACKEND=qdrant` (FAISS flat не тянет), а таблица
`fingerprints` (уже ~113M строк на 177K доков — партиционирование стоит планировать
заранее, не постфактум) потребует партиционирования. См.
[ARCHITECTURE.md](ARCHITECTURE.md) и [DR-HA.md](DR-HA.md).

View File

@@ -0,0 +1,10 @@
# Автопровижн источника данных Grafana → Prometheus (профиль observability).
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,20 @@
# Prometheus scrape-конфиг (профиль observability в docker-compose.prod.yml).
# Скрейпит HTTP-метрики API и Celery-метрики из Flower (Flower 2.0 отдаёт их сам).
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: api
metrics_path: /metrics
static_configs:
- targets: ["api:8000"]
- job_name: flower # метрики Celery: задачи, время выполнения, воркеры
metrics_path: /metrics
static_configs:
- targets: ["flower:5555"]

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"
}
}

14
mypy.ini Normal file
View File

@@ -0,0 +1,14 @@
# Прагматичная конфигурация mypy (старт градуальной типизации).
# Ловит реальные ошибки типов (несовпадения, обращения к None, неверные
# аргументы), но НЕ требует аннотаций везде и не шумит на нетипизированных
# сторонних либах — чтобы гейт был зелёным и его можно было расширять по мере
# типизации кода. Область проверки задаётся в scripts/run_lint.sh (пока только
# чистая логика L1/L2 + ГОСТ-форматтеры).
[mypy]
python_version = 3.11
ignore_missing_imports = true
check_untyped_defs = true
warn_return_any = false
disallow_untyped_defs = false
no_implicit_optional = true
warn_redundant_casts = true

26
ruff.toml Normal file
View File

@@ -0,0 +1,26 @@
# Линтер/форматтер для всех Python-сервисов и скриптов.
# Гоняется как гейт в CI (scripts/run_lint.sh) — падение блокирует деплой.
target-version = "py311"
line-length = 100
[lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes (неиспользуемое, неопределённое)
"W", # pycodestyle warnings
"I", # isort (порядок импортов)
"UP", # pyupgrade (устаревшие конструкции)
"B", # flake8-bugbear (частые баги)
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
]
ignore = [
"E501", # длину строк держит форматтер, а не линтер; длинные RU-комментарии — норма
"B008", # FastAPI-идиома: Depends()/Query() в значениях по умолчанию — не баг
"UP042", # (str, Enum) → StrEnum меняет __str__ (сериализацию) — не трогаем намеренно
]
[lint.per-file-ignores]
# В тестах допускаем импорт-после-кода (importorskip) и «неотсортированные» блоки
"**/tests/*" = ["E402"]
"**/conftest.py" = ["E402"]

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Бэкфилл full_text для уже залитых документов CyberLeninka из OCR-фрагментов.
Парсер (scripts/parsers/cyberleninka.py) раньше игнорировал поле "ocr" в ответе
поиска (список OCR-фрагментов текста статьи) — full_text всегда был None, и
Winnowing-отпечатки (L1) считались по короткой аннотации. Это исправлено для
НОВЫХ документов; этот скрипт досчитывает уже существующие.
Алгоритм: для каждой ранее засеянной cyberleninka-темы (parse_sources) —
переспросить search API (тот же query/limit — переспрос идемпотентен и не бьёт
по документу дважды), сматчить raw-статьи с уже существующими documents по
ext_id, и там где full_text ещё не сохранён (minio_key IS NULL):
1. пересчитать fingerprints по full_text (заменить провизорные из annotation);
2. сохранить full_text в MinIO (тот же путь, что enrich_full_text: corpus/{id}.txt);
3. проставить documents.minio_key.
Не трогает faiss_id/эмбеддинги (embed_documents использует title+abstract, не
full_text — пересчитывать нечего). Идемпотентно: документы с уже проставленным
minio_key пропускаются.
Запуск:
python scripts/backfill_cyberleninka_fulltext.py # dry-run (посчитать)
python scripts/backfill_cyberleninka_fulltext.py --apply # применить
"""
import argparse
import io
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "parsers"))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent
/ "services" / "worker-indexer" / "app" / "algorithms"))
def _load_env() -> None:
env = Path(__file__).resolve().parent.parent / ".env"
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
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
def _pg_dsn() -> dict:
return {
"host": os.environ["POSTGRES_HOST"], "port": int(os.environ.get("POSTGRES_PORT", "5432")),
"dbname": os.environ["POSTGRES_DB"], "user": os.environ["POSTGRES_USER"],
"password": os.environ["POSTGRES_PASSWORD"],
}
MAX_FP = 20000
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
_load_env()
import psycopg2
from cyberleninka import CyberLeninkaParser
from minio import Minio
from winnowing import winnow
conn = psycopg2.connect(**_pg_dsn())
minio = Minio(
os.environ["MINIO_ENDPOINT"],
access_key=os.environ["MINIO_ACCESS_KEY"], secret_key=os.environ["MINIO_SECRET_KEY"],
secure=False,
)
bucket = os.environ.get("MINIO_BUCKET_DOCS", "documents")
with conn.cursor() as cur:
cur.execute(
"SELECT id, query, \"limit\" FROM parse_sources "
"WHERE source_type='cyberleninka' AND last_status='done' ORDER BY id"
)
topics = cur.fetchall()
print(f"Тем CyberLeninka к обработке: {len(topics)}")
parser = CyberLeninkaParser()
matched = updated = no_ocr = not_found = already_done = 0
for _sid, query, limit in topics:
raws = list(parser.fetch(query=query or "", limit=limit))
for raw in raws:
d = parser.transform(raw)
if not (d and d.get("ext_id")):
continue
with conn.cursor() as cur:
cur.execute(
"SELECT id, minio_key FROM documents WHERE ext_id=%s AND source='cyberleninka'",
(d["ext_id"],),
)
row = cur.fetchone()
if not row:
not_found += 1
continue
doc_id, minio_key = row
matched += 1
if minio_key:
already_done += 1
continue
if not d.get("full_text"):
no_ocr += 1
continue
if args.apply:
text = d["full_text"]
key = f"corpus/{doc_id}.txt"
data = text.encode("utf-8")
minio.put_object(bucket, key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8")
hashes = list(winnow(text))[:MAX_FP]
with conn.cursor() as cur:
cur.execute("DELETE FROM fingerprints WHERE doc_id=%s", (doc_id,))
if hashes:
from psycopg2.extras import execute_values
execute_values(
cur, "INSERT INTO fingerprints (doc_id,hash_value,position) VALUES %s",
[(doc_id, h, i) for i, h in enumerate(hashes)],
)
cur.execute("UPDATE documents SET minio_key=%s WHERE id=%s", (key, doc_id))
conn.commit()
updated += 1
print(f" {(query or '')[:32]:32} raw={len(raws):4} | обновлено всего={updated}", flush=True)
print(f"\nИТОГ: сматчено={matched} обновлено={updated} "
f"уже_было={already_done} без_ocr={no_ocr} не_найдено={not_found}"
f"{' [DRY-RUN — ничего не записано, добавьте --apply]' if not args.apply else ''}")
conn.close()
if __name__ == "__main__":
main()

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

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# DR-тест: проверка ВОССТАНОВИМОСТИ бэкапа PostgreSQL.
#
# Берёт последний дамп из MinIO (backups/pg/), поднимает ЭФЕМЕРНЫЙ контейнер
# postgres:16, восстанавливает в него дамп и проверяет, что ключевые таблицы не
# пусты. Прод не трогается — всё в одноразовом контейнере/сети, удаляются на выходе.
#
# Зачем: бэкапы делаются (cron на 1.38), но восстановление раньше никто не проверял —
# «непроверенный бэкап = отсутствие бэкапа». Запускать по cron раз в неделю.
#
# Требуется docker + доступ до MinIO. Креды берутся из .env (или окружения):
# MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY,
# MINIO_BUCKET_BACKUPS (=backups), POSTGRES_DB, POSTGRES_USER.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if [ -f "$ROOT/.env" ]; then set -a; . "$ROOT/.env"; set +a; fi
: "${MINIO_ENDPOINT:?нужен MINIO_ENDPOINT}"
: "${MINIO_ACCESS_KEY:?нужен MINIO_ACCESS_KEY}"
: "${MINIO_SECRET_KEY:?нужен MINIO_SECRET_KEY}"
BUCKET="${MINIO_BUCKET_BACKUPS:-backups}"
PREFIX="${PG_BACKUP_PREFIX:-pg/}"
PGDB="${POSTGRES_DB:-antiplagiator}"
PGUSER="${POSTGRES_USER:-antiplagiator}"
SUFFIX="$$-$RANDOM"
PG="drtest-pg-$SUFFIX"
WORK="$(mktemp -d)"
MC_HOST="http://${MINIO_ACCESS_KEY}:${MINIO_SECRET_KEY}@${MINIO_ENDPOINT}"
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; rm -rf "$WORK"; }
trap cleanup EXIT
echo "▶ Поиск последнего дампа в minio://$BUCKET/$PREFIX"
LATEST="$(docker run --rm -e MC_HOST_dr="$MC_HOST" minio/mc:latest \
sh -c "mc ls --recursive dr/$BUCKET/$PREFIX | awk '{print \$NF}' | grep -E '\.(sql\.)?gz$' | sort | tail -1")"
[ -n "$LATEST" ] || { echo "❌ дампы не найдены"; exit 1; }
FNAME="$(basename "$LATEST")"
echo " последний: $FNAME"
echo "▶ Скачивание дампа"
docker run --rm -e MC_HOST_dr="$MC_HOST" -v "$WORK":/w minio/mc:latest \
sh -c "mc cp 'dr/$BUCKET/$PREFIX$FNAME' /w/dump.gz"
echo "▶ Эфемерный postgres:16"
docker run -d --name "$PG" \
-e POSTGRES_PASSWORD=drtest -e POSTGRES_DB="$PGDB" -e POSTGRES_USER="$PGUSER" \
postgres:16 >/dev/null
for _ in $(seq 1 30); do
docker exec "$PG" pg_isready -U "$PGUSER" -d "$PGDB" >/dev/null 2>&1 && break
sleep 1
done
echo "▶ Восстановление"
gunzip -c "$WORK/dump.gz" | docker exec -i "$PG" psql -v ON_ERROR_STOP=0 -U "$PGUSER" -d "$PGDB" >/dev/null 2>&1
echo "▶ Проверка ключевых таблиц"
rc=0
for tbl in users documents tasks; do
n="$(docker exec "$PG" psql -U "$PGUSER" -d "$PGDB" -tAc "SELECT count(*) FROM $tbl" 2>/dev/null | tr -d '[:space:]' || echo ERR)"
echo " $tbl: ${n:-ERR}"
{ [ -z "$n" ] || [ "$n" = "ERR" ]; } && rc=1
done
if [ "$rc" -eq 0 ]; then
echo "✅ Бэкап восстанавливается, ключевые таблицы на месте ($FNAME)"
else
echo "❌ Восстановление не прошло проверку — разобраться СРОЧНО"
exit 1
fi

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

@@ -6,14 +6,13 @@ API: https://info.arxiv.org/help/api/index.html
Использует Atom XML API.
"""
import time
import logging
import time
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__)
@@ -47,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.
@@ -56,6 +56,7 @@ class ArxivParser(BaseParser):
limit: Максимальное количество документов
categories: Список категорий arXiv (cs.AI, math.ST и т.д.)
year_from: Год публикации от
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей
@@ -95,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
@@ -203,9 +208,6 @@ class ArxivParser(BaseParser):
# Определить язык (arXiv — преимущественно английский)
lang = "en"
# Категории как JSON
categories = raw.get("categories", [])
return {
"source": self.source_name,
"ext_id": f"arxiv:{ext_id}",

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 источника

View File

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

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

@@ -3,19 +3,20 @@
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
Сайт: https://cyberleninka.ru
Используем парсинг HTML страниц со строгим rate limiting (1 req/sec),
а не недокументированное API.
Поиск — через JSON-API `POST /api/search` (`{"mode":"articles", "q", "size", "from"}`)
со строгим rate limiting (1 req/sec). Ответ подсвечивает совпадения тегами <b>…</b>
в name/annotation — чистим при трансформации.
"""
import contextlib
import html
import logging
import re
import time
import logging
from typing import Any
import httpx
from bs4 import BeautifulSoup
from base import BaseParser
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
@@ -46,6 +47,7 @@ class CyberLeninkaParser(BaseParser):
query: str = "",
limit: int = 500,
subject: str | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]:
"""
Получить статьи из КиберЛенинки.
@@ -54,6 +56,7 @@ class CyberLeninkaParser(BaseParser):
query: Поисковый запрос
limit: Максимальное количество статей
subject: Предметная область (опционально)
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей статей
@@ -63,13 +66,16 @@ class CyberLeninkaParser(BaseParser):
while len(results) < limit:
try:
params: dict[str, Any] = {
# /api/search КиберЛенинки — это POST с JSON-телом (GET даёт 405).
# mode=articles обязателен, иначе поиск не по статьям.
payload: dict[str, Any] = {
"mode": "articles",
"q": query,
"size": min(10, limit - len(results)),
"from": page * 10,
}
response = self.client.get(SEARCH_URL, params=params)
response = self.client.post(SEARCH_URL, json=payload)
response.raise_for_status()
data = response.json()
@@ -81,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
@@ -114,28 +126,37 @@ class CyberLeninkaParser(BaseParser):
if not ext_id:
return {}
# Авторы (строка вида "Иванов И.И., Петров П.П.")
authors_str = raw.get("authors", "") or ""
authors = _parse_cyberleninka_authors(authors_str)
# Авторы: API отдаёт список строк "Фамилия И. О." (иногда — одну строку)
raw_authors = raw.get("authors") or []
authors = (
_parse_cyberleninka_authors(raw_authors)
if isinstance(raw_authors, str)
else _authors_from_list(raw_authors)
)
# Год
year_raw = raw.get("year")
year = None
if year_raw:
try:
with contextlib.suppress(ValueError, TypeError):
year = int(str(year_raw)[:4])
except (ValueError, TypeError):
pass
# URL
link = raw.get("link", "")
url = f"{BASE_URL}{link}" if link.startswith("/") else link or None
# OCR-фрагменты текста статьи — приходят прямо в ответе поиска (список
# кусков, обычно начало статьи + фрагмент с ключевыми словами), без
# доп. запроса. Не полный текст всей статьи, но заметно богаче аннотации —
# используем как full_text для более точных Winnowing-отпечатков (L1).
ocr = raw.get("ocr")
full_text = _clean(" ".join(ocr)) if isinstance(ocr, list) and ocr else None
return {
"source": self.source_name,
"ext_id": ext_id,
"doi": raw.get("doi") or None,
"title": (raw.get("name") or "").strip() or None,
"title": _clean(raw.get("name")) or None,
"authors": authors,
"year": year,
"lang": "ru", # КиберЛенинка — только русскоязычные
@@ -143,9 +164,9 @@ class CyberLeninkaParser(BaseParser):
"volume": raw.get("volume") or None,
"issue": raw.get("number") or None,
"pages": raw.get("pages") or None,
"abstract": (raw.get("annotation") or "").strip() or None,
"abstract": _clean(raw.get("annotation")) or None,
"url": url,
"full_text": None,
"full_text": full_text,
}
def fetch_article_details(self, url: str) -> dict[str, Any]:
@@ -165,6 +186,9 @@ class CyberLeninkaParser(BaseParser):
response = self.client.get(url)
response.raise_for_status()
# Ленивый импорт: заливке (fetch/transform) bs4 не нужен, только деталям статьи
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "html.parser")
meta = {}
@@ -191,6 +215,26 @@ class CyberLeninkaParser(BaseParser):
return {}
def _clean(text: str | None) -> str:
"""Убрать HTML-теги подсветки (<b>…</b>), декодировать сущности (&quot;), обрезать."""
if not text:
return ""
return html.unescape(re.sub(r"<[^>]+>", "", str(text))).strip()
def _authors_from_list(items: list) -> list[dict[str, str]]:
"""Авторы из списка строк 'Фамилия И. О.' → [{last_name, initials}, ...]."""
authors: list[dict[str, str]] = []
for item in items:
name = _clean(str(item))
words = name.split()
if not words:
continue
initials = " ".join(words[1:]) if len(words) >= 2 else ""
authors.append({"last_name": words[0], "initials": initials})
return authors
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
"""
Разбить строку авторов КиберЛенинки на список.

View File

@@ -9,18 +9,47 @@ API: https://docs.openalex.org/
- Идемпотентность: проверка по ext_id перед добавлением
"""
import time
import logging
from typing import Any, Generator
import time
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:

220
scripts/parsers/pmc.py Normal file
View File

@@ -0,0 +1,220 @@
"""Парсер PubMed Central (PMC) — NCBI E-utilities.
PMC Open Access Subset — крупнейший биомедицинский открытый архив с реальным
полным текстом статей (не только аннотацией). API бесплатный, ключ не нужен
(рекомендуется для повышения лимита с 3 до 10 запросов/сек — NCBI_API_KEY).
Документация: https://www.ncbi.nlm.nih.gov/books/NBK25501/
Два запроса на пачку: esearch (ID) → efetch (полные JATS XML статьи, откуда
разом достаём метаданные + abstract + body — тело статьи, реальный полный
текст, а не аннотация или OCR-фрагмент).
"""
import logging
import os
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__)
EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
BATCH_SIZE = 20
# 3 запроса/сек без ключа, 10/сек с ключом (NCBI_API_KEY в окружении)
RATE_LIMIT_DELAY = 0.12 if os.environ.get("NCBI_API_KEY") else 0.35
class PMCParser(BaseParser):
"""Парсер PubMed Central через NCBI E-utilities."""
source_name = "pmc"
def __init__(self) -> None:
super().__init__()
self.api_key = os.environ.get("NCBI_API_KEY")
self.client = httpx.Client(
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
timeout=30.0,
)
def _params(self, **extra: Any) -> dict[str, Any]:
p = dict(extra)
if self.api_key:
p["api_key"] = self.api_key
return p
def fetch(
self,
query: str = "",
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.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
year_from: Год публикации от
year_to: Год публикации до
progress_cb: см. base.ProgressCallback
Returns:
Список сырых словарей (уже с извлечёнными метаданными + full_text)
"""
term = f"{query} AND open access[filter]" if query else "open access[filter]"
if year_from or year_to:
lo = year_from or 1900
hi = year_to or 3000
term += f' AND ("{lo}"[PDAT] : "{hi}"[PDAT])'
ids = self._search_ids(term, limit)
if not ids:
return []
results: list[dict[str, Any]] = []
for i in range(0, len(ids), BATCH_SIZE):
batch = ids[i : i + BATCH_SIZE]
try:
response = self.client.get(
f"{EUTILS}/efetch.fcgi",
params=self._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"),
)
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}")
if e.response.status_code == 429:
time.sleep(5)
continue
except Exception as e:
logger.error(f"Ошибка efetch PMC (батч {i}): {e}")
continue
return results[:limit]
def _search_ids(self, term: str, limit: int) -> list[str]:
"""Собрать PMC ID постранично через esearch."""
ids: list[str] = []
retstart = 0
page = min(200, limit)
while len(ids) < limit:
try:
response = self.client.get(
f"{EUTILS}/esearch.fcgi",
params=self._params(
db="pmc", term=term, retstart=retstart,
retmax=min(page, limit - len(ids)), retmode="json",
),
)
response.raise_for_status()
page_ids = response.json().get("esearchresult", {}).get("idlist", [])
if not page_ids:
break
ids.extend(page_ids)
retstart += len(page_ids)
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC esearch HTTP ошибка: {e.response.status_code}")
break
except Exception as e:
logger.error(f"Ошибка esearch PMC: {e}")
break
return ids[:limit]
def _parse_articles(self, xml_text: str) -> list[dict[str, Any]]:
"""Разобрать JATS XML (пачка статей из efetch) в плоские словари."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logger.error(f"Ошибка парсинга XML PMC: {e}")
return []
return [self._parse_article(art) for art in root.findall("article")]
@staticmethod
def _text(el: ET.Element | None) -> str | None:
"""Склеить весь текст элемента, включая вложенные теги форматирования."""
return "".join(el.itertext()).strip() if el is not None else None
def _parse_article(self, article: ET.Element) -> dict[str, Any]:
"""Извлечь метаданные + abstract + полный текст из одной <article>."""
am = article.find(".//article-meta")
if am is None:
return {}
pmcaid = doi = None
for aid in am.findall("article-id"):
if aid.get("pub-id-type") == "pmcaid":
pmcaid = aid.text
elif aid.get("pub-id-type") == "doi":
doi = aid.text
authors = []
for c in am.findall(".//contrib-group/contrib[@contrib-type='author']"):
surname = c.find(".//surname")
given = c.find(".//given-names")
if surname is not None and surname.text:
authors.append({
"last_name": surname.text.strip(),
"first_name": (given.text or "").strip() if given is not None else "",
})
year = None
for y in am.findall(".//pub-date/year"):
if y.text and y.text.isdigit():
year = int(y.text)
break
body = article.find("body")
full_text = None
if body is not None:
paragraphs = [self._text(p) for p in body.findall(".//p")]
full_text = " ".join(p for p in paragraphs if p) or None
return {
"id": pmcaid,
"doi": doi,
"title": self._text(am.find(".//title-group/article-title")),
"journal": self._text(article.find(".//journal-meta/journal-title-group/journal-title")),
"authors": authors,
"year": year,
"abstract": self._text(am.find(".//abstract")),
"full_text": full_text,
}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Преобразовать статью PMC в унифицированный формат."""
ext_id = raw.get("id")
if not ext_id:
return {}
authors = self.normalize_authors(raw.get("authors", []))
return {
"source": self.source_name,
"ext_id": f"pmc:{ext_id}",
"doi": raw.get("doi"),
"title": (raw.get("title") or "").strip() or None,
"authors": authors,
"year": raw.get("year"),
"lang": "en", # PMC — практически полностью англоязычный корпус
"journal": raw.get("journal"),
"volume": None,
"issue": None,
"pages": None,
"abstract": raw.get("abstract"),
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{ext_id}/",
"full_text": raw.get("full_text"),
}

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,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,4 @@
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
pytest==8.2.0
httpx==0.27.0
beautifulsoup4==4.12.3

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,64 @@
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
annotation — с HTML-подсветкой (<b>) и сущностями (&quot;).
"""
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
# Форма ответа POST /api/search КиберЛенинки
SAMPLE = {
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> &quot;X&quot;",
"annotation": "В статье <b>рассматривается</b> вопрос",
"authors": ["Вишникина А. Д.", "Клопова А. А."],
"journal": "Экономика и социум",
"year": 2024,
"link": "/article/n/soderzhanie",
}
def test_clean_strips_tags_and_entities():
assert _clean('<b>Тест</b> &quot;X&quot;') == 'Тест "X"'
assert _clean(None) == ""
assert _clean(" чисто ") == "чисто"
def test_authors_from_list():
a = _authors_from_list(["Вишникина А. Д.", "Иванов И."])
assert a[0] == {"last_name": "Вишникина", "initials": "А. Д."}
assert a[1] == {"last_name": "Иванов", "initials": "И."}
assert _authors_from_list([]) == []
def test_transform_cleans_and_maps_fields():
t = CyberLeninkaParser().transform(SAMPLE)
assert "<b>" not in t["title"] and "&quot;" not in t["title"]
assert t["title"].startswith("СОДЕРЖАНИЕ ЭКОНОМИЧЕСКОГО АНАЛИЗА")
assert t["lang"] == "ru"
assert t["year"] == 2024
assert t["journal"] == "Экономика и социум"
assert t["url"] == "https://cyberleninka.ru/article/n/soderzhanie"
assert t["source"] == "cyberleninka"
assert t["authors"][0]["last_name"] == "Вишникина"
assert "<b>" not in t["abstract"]
def test_transform_handles_string_authors():
raw = dict(SAMPLE, authors="Иванов И.И., Петров П.П.")
t = CyberLeninkaParser().transform(raw)
assert len(t["authors"]) == 2
def test_transform_empty_without_id_or_link():
assert CyberLeninkaParser().transform({"name": "x"}) == {}
def test_transform_uses_ocr_as_full_text():
raw = dict(SAMPLE, ocr=["Первый <b>фрагмент</b> статьи.", "Второй &amp; фрагмент."])
t = CyberLeninkaParser().transform(raw)
assert t["full_text"] == "Первый фрагмент статьи. Второй & фрагмент."
def test_transform_full_text_none_without_ocr():
assert CyberLeninkaParser().transform(SAMPLE)["full_text"] is None
assert CyberLeninkaParser().transform(dict(SAMPLE, ocr=[]))["full_text"] is None

View File

@@ -0,0 +1,88 @@
"""Юнит-тесты парсера PubMed Central (PMC) — чистая логика (без сети).
_parse_article работает с реальными xml.etree.ElementTree узлами (JATS XML из
efetch), поэтому тесты строят минимальные JATS-фрагменты, а не мокают HTTP.
"""
import xml.etree.ElementTree as ET
from pmc import PMCParser
ARTICLE_XML = """
<article>
<front>
<journal-meta>
<journal-title-group><journal-title>Journal of Testing</journal-title></journal-title-group>
</journal-meta>
<article-meta>
<article-id pub-id-type="pmcaid">1234567</article-id>
<article-id pub-id-type="doi">10.1000/test.123</article-id>
<title-group><article-title>A <italic>Study</italic> of Testing</article-title></title-group>
<contrib-group>
<contrib contrib-type="author">
<name><surname>Ivanov</surname><given-names>Ivan</given-names></name>
</contrib>
<contrib contrib-type="editor">
<name><surname>NotAnAuthor</surname><given-names>X</given-names></name>
</contrib>
</contrib-group>
<pub-date pub-type="epub"><year>2024</year></pub-date>
<abstract><p>This is the <bold>abstract</bold> text.</p></abstract>
</article-meta>
</front>
<body>
<p>First paragraph of the body.</p>
<p>Second paragraph with <xref>a ref</xref> inline.</p>
</body>
</article>
"""
def _article() -> ET.Element:
return ET.fromstring(ARTICLE_XML)
def test_parse_article_extracts_all_fields():
raw = PMCParser()._parse_article(_article())
assert raw["id"] == "1234567"
assert raw["doi"] == "10.1000/test.123"
assert raw["title"] == "A Study of Testing" # вложенный <italic> склеен
assert raw["journal"] == "Journal of Testing"
assert raw["year"] == 2024
assert raw["abstract"] == "This is the abstract text."
assert raw["full_text"] == "First paragraph of the body. Second paragraph with a ref inline."
def test_parse_article_only_includes_authors_not_editors():
raw = PMCParser()._parse_article(_article())
assert raw["authors"] == [{"last_name": "Ivanov", "first_name": "Ivan"}]
def test_parse_article_without_article_meta_is_empty():
assert PMCParser()._parse_article(ET.fromstring("<article/>")) == {}
def test_transform_maps_to_unified_schema():
raw = PMCParser()._parse_article(_article())
t = PMCParser().transform(raw)
assert t["source"] == "pmc"
assert t["ext_id"] == "pmc:1234567"
assert t["lang"] == "en"
assert t["url"] == "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
assert t["authors"][0]["last_name"] == "Ivanov"
assert t["full_text"].startswith("First paragraph")
def test_transform_empty_without_id():
assert PMCParser().transform({"title": "x"}) == {}
def test_parse_articles_batch():
xml = f"<pmc-articleset>{ARTICLE_XML}{ARTICLE_XML}</pmc-articleset>"
parsed = PMCParser()._parse_articles(xml)
assert len(parsed) == 2
assert all(p["id"] == "1234567" for p in parsed)
def test_parse_articles_malformed_xml_returns_empty():
assert PMCParser()._parse_articles("<not valid xml") == []

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"),
}

33
scripts/run_lint.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Статический анализ Python-кода в изолированном контейнере — гейт CI перед деплоем.
# 1) ruff — линт всего кода (services + scripts), конфиг ruff.toml
# 2) mypy — проверка типов (пока только чистая логика L1/L2 + ГОСТ), конфиг mypy.ini
# область растёт по мере типизации кода; запуск per-service, чтобы
# резолвился локальный пакет `app`.
#
# PIP_INDEX_URL переопределяется при необходимости (по умолчанию Tsinghua-зеркало).
set -euo pipefail
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUFF_VERSION="0.16.2"
MYPY_VERSION="1.13.0"
docker run --rm \
-v "$ROOT":/repo -w /repo \
-e PIP_INDEX_URL="$MIRROR" \
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
python:3.11-slim bash -c "
set -e
pip install --no-cache-dir --timeout 60 -q ruff==$RUFF_VERSION mypy==$MYPY_VERSION
echo '▶ ruff (services/ scripts/)'
ruff check services/ scripts/
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

@@ -10,7 +10,6 @@
import argparse
import logging
import os
import sys
from pathlib import Path
@@ -64,18 +63,33 @@ def run_arxiv(args: argparse.Namespace, output_dir: Path) -> None:
)
def run_pmc(args: argparse.Namespace, output_dir: Path) -> None:
from pmc import PMCParser
parser = PMCParser()
parser.run(
output_dir=output_dir,
query=args.query,
limit=args.limit,
year_from=args.year_from,
year_to=args.year_to,
)
def run_all(args: argparse.Namespace, output_dir: Path) -> None:
"""Запустить все парсеры последовательно."""
logger.info("Запуск всех парсеров...")
run_openalex(args, output_dir)
run_cyberleninka(args, output_dir)
run_arxiv(args, output_dir)
run_pmc(args, output_dir)
PARSERS = {
"openalex": run_openalex,
"cyberleninka": run_cyberleninka,
"arxiv": run_arxiv,
"pmc": run_pmc,
"all": run_all,
}

71
scripts/run_tests.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Прогон юнит-тестов всех сервисов в изолированных python:3.11-slim контейнерах.
#
# Тесты бьют по ЧИСТОЙ логике детекции и форматирования и не требуют внешней
# инфраструктуры (БД/Redis/RabbitMQ/GPU/Ollama) — поэтому гоняются одинаково на
# машине разработчика и в CI. Запуск в контейнере не засоряет хост зависимостями.
#
# Использование:
# scripts/run_tests.sh # все сервисы
# scripts/run_tests.sh worker-gost # только один сервис
#
# PIP_INDEX_URL можно переопределить (по умолчанию — Tsinghua-зеркало, т.к.
# pypi.org из LAN недоступен).
set -euo pipefail
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="python:3.11-slim"
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
SERVICES=(
"services/api:"
"services/worker-indexer:"
"services/worker-gost:"
"services/worker-gpu:libgomp1"
"scripts/parsers:"
)
run_service() {
local dir_rel="$1" apt_pkgs="$2"
local dir="$ROOT/$dir_rel"
local name="${dir_rel##*/}"
if [[ ! -f "$dir/requirements-test.txt" ]]; then
echo "⚠ $name: нет requirements-test.txt — пропуск"
return 0
fi
echo "──────────────────────────────────────────────"
echo "▶ Тесты: $name"
echo "──────────────────────────────────────────────"
docker run --rm \
-v "$dir":/app -w /app \
-e PIP_INDEX_URL="$MIRROR" \
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
"$IMAGE" bash -c "
set -e
${apt_pkgs:+apt-get update -qq && apt-get install -y -qq --no-install-recommends $apt_pkgs >/dev/null && rm -rf /var/lib/apt/lists/*}
pip install --no-cache-dir --timeout 60 -q -r requirements-test.txt
python -m pytest
"
}
FILTER="${1:-}"
failed=0
for entry in "${SERVICES[@]}"; do
dir_rel="${entry%%:*}"
apt="${entry#*:}"
name="${dir_rel##*/}"
[[ -n "$FILTER" && "$FILTER" != "$name" ]] && continue
if ! run_service "$dir_rel" "$apt"; then
failed=1
echo "✗ $name: тесты упали"
fi
done
echo "══════════════════════════════════════════════"
if [[ $failed -eq 0 ]]; then
echo "✅ Все юнит-тесты прошли"
else
echo "❌ Есть упавшие тесты"
exit 1
fi

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Расширение корпуса вширь — новые дисциплины, которых ещё нет в parse_sources.
Продолжение scripts/seed_ru_sources.py: та заливка покрыла 30 базовых русских
дисциплин + был уже собран seed-корпус из ~26 английских тем (OpenAlex) и 4
категорий arXiv. Этот скрипт добавляет НОВЫЕ темы поверх уже сделанного —
углубление RU (специализированные подотрасли права/экономики/IT) и расширение EN
(область права/лингвистики/искусства/наук о Земле и т.д., которых ещё не было),
плюс новые категории arXiv.
Идемпотентно (по name, с префиксами ru2:/oa3:/arxiv2: — не пересекаются с уже
существующими ru:/corpus:/oa2:/arxiv:). Dry-run по умолчанию.
Запуск:
python scripts/seed_broad_corpus.py # план
python scripts/seed_broad_corpus.py --apply # записать в БД
"""
import argparse
import os
from pathlib import Path
# (name_suffix, source_type, query, lang, limit)
RU_DEEP = [ # углубление: специализированные RU-темы, которых не было в первой волне
"семейное право", "административное право", "уголовный процесс",
"гражданский процесс", "международное право", "предпринимательское право",
"налоговое право", "земельное право", "экологическое право",
"информационная безопасность", "искусственный интеллект", "нейронные сети",
"веб-разработка", "архитектура зданий", "дизайн", "музыкальное искусство",
"физическая культура", "спортивная медицина", "туризм", "логистика",
"инновационный менеджмент", "антикризисное управление", "аудит",
"страхование", "банковское дело", "инвестиции",
"внешнеэкономическая деятельность", "региональная экономика", "демография",
]
EN_OPENALEX_NEW = [ # области, которых не было в первой EN-волне
"law", "political science", "linguistics", "literature studies", "history",
"philosophy", "anthropology", "geography", "agriculture",
"business management", "finance", "architecture", "astronomy", "geology",
"pharmacology", "nursing", "veterinary science", "art history",
"music theory", "religious studies",
]
EN_ARXIV_NEW = [ # темы поиска arXiv, не пересекаются с уже сделанными 4
"natural language processing", "reinforcement learning", "quantum physics",
"particle physics", "condensed matter physics", "number theory",
"statistics methodology", "signal processing", "distributed systems",
"software engineering", "bioinformatics", "econometrics", "optimization",
"graph theory", "information theory",
]
def _load_env() -> None:
env = Path(__file__).resolve().parent.parent / ".env"
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 _build_rows(ru_limit: int, en_limit: int, arxiv_limit: int) -> list[tuple]:
rows = []
rows += [(f"ru2:{t}", "cyberleninka", t, "ru", ru_limit) for t in RU_DEEP]
rows += [(f"oa3:{t}", "openalex", t, None, en_limit) for t in EN_OPENALEX_NEW]
rows += [(f"arxiv2:{t}", "arxiv", t, None, arxiv_limit) for t in EN_ARXIV_NEW]
return rows
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("--ru-limit", type=int, default=500)
ap.add_argument("--en-limit", type=int, default=1000)
ap.add_argument("--arxiv-limit", type=int, default=800)
args = ap.parse_args()
_load_env()
rows = _build_rows(args.ru_limit, args.en_limit, args.arxiv_limit)
total_potential = sum(r[4] for r in rows)
print(f"Новых источников: {len(rows)} "
f"(RU углубление {len(RU_DEEP)}, EN OpenAlex {len(EN_OPENALEX_NEW)}, "
f"arXiv {len(EN_ARXIV_NEW)})")
print(f"Потенциальный объём (верхняя граница): ~{total_potential} документов")
if not args.apply:
print("\n[dry-run] запустите с --apply, чтобы записать в parse_sources.")
for name, stype, q, _lang, lim in rows[:10]:
print(f" {name:32} [{stype:12}] limit={lim} → {q!r}")
print(f" ... и ещё {len(rows) - 10}")
return
import psycopg2
conn = psycopg2.connect(**_pg_dsn())
conn.autocommit = True
created, skipped, ids = 0, 0, []
with conn.cursor() as cur:
for name, stype, query, lang, limit in rows:
cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,))
if cur.fetchone():
skipped += 1
continue
cur.execute(
'INSERT INTO parse_sources '
'(source_type, name, query, lang, "limit", enabled, last_status, docs_added) '
"VALUES (%s, %s, %s, %s, %s, TRUE, 'idle', 0) RETURNING id",
(stype, name, query, lang, limit),
)
ids.append(cur.fetchone()[0])
created += 1
conn.close()
print(f"\nСоздано: {created}, пропущено (уже были): {skipped}")
print(f"ID новых источников: {ids}")
if __name__ == "__main__":
main()

132
scripts/seed_ru_sources.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Подготовка русскоязычной заливки корпуса — сидинг parse_sources (CyberLeninka).
Зачем: корпус на ~99% английский (0 русских источников), хотя сервис — для русских
студентов. Этот скрипт создаёт в таблице `parse_sources` набор заданий CyberLeninka
по основным студенческим дисциплинам. Он НЕ запускает заливку — только готовит
задания; их затем триггерят (кнопка «Запустить» в админке или Celery-задача
`index.run_parser(source_id)`).
Идемпотентно: источник с таким `name` повторно не создаётся.
Запуск:
# dry-run — показать план, ничего не писать:
python scripts/seed_ru_sources.py
# создать источники в БД (лимит на каждую дисциплину):
python scripts/seed_ru_sources.py --apply --limit 500
Креды PG берутся из .env (POSTGRES_HOST/PORT/DB/USER/PASSWORD) или окружения.
ВНИМАНИЕ: локальный .env может указывать на старый PG — для прода используйте
POSTGRES_HOST=192.168.1.38 (см. вывод по наполненности базы).
"""
import argparse
import os
import sys
from pathlib import Path
# Студенческие дисциплины → поисковый запрос в CyberLeninka (русскоязычный).
DISCIPLINES = [
"экономический анализ", "менеджмент организации", "бухгалтерский учёт",
"финансы и кредит", "маркетинг", "гражданское право", "уголовное право",
"конституционное право", "трудовое право", "педагогика", "психология личности",
"социология", "философия науки", "история России", "политология",
"информационные технологии", "программирование", "базы данных",
"математический анализ", "физика", "химия", "биология", "медицина",
"экология", "лингвистика", "литературоведение", "государственное управление",
"международные отношения", "журналистика", "культурология",
# Прикладные/техникумовские темы — оригинальный список был вузовски-научным,
# тут реальные дипломы СПО/колледжей, которых там не было вовсе.
"полиграфия и печать", "издательское дело", "дизайн", "строительство",
"архитектура", "машиностроение", "электротехника", "логистика", "туризм",
"гостиничное дело", "сестринское дело", "ветеринария", "агрономия",
"пищевая промышленность", "транспорт", "дорожное строительство",
"сварочное производство", "метрология и стандартизация", "реклама и PR",
"физическая культура и спорт", "дошкольное образование", "дефектология",
"банковское дело", "таможенное дело", "документоведение",
"телекоммуникации", "нефтегазовое дело", "энергетика",
"пожарная безопасность", "социальная работа",
]
def _load_env() -> None:
"""Подтянуть переменные из .env репозитория, если файл есть."""
env = Path(__file__).resolve().parent.parent / ".env"
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 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=500, help="лимит документов на дисциплину")
ap.add_argument("--source-type", default="cyberleninka", help="тип парсера (по умолчанию cyberleninka)")
args = ap.parse_args()
_load_env()
rows = [(f"ru:{d}", args.source_type, d) for d in DISCIPLINES]
print(f"Дисциплин: {len(rows)} | тип: {args.source_type} | лимит/шт: {args.limit}")
print(f"Потенциальный объём: ~{len(rows) * args.limit} документов")
if not args.apply:
print("\n[dry-run] будут созданы источники (name → query):")
for name, _, q in rows:
print(f" {name:38} → {q!r}")
print("\nЗапустите с --apply, чтобы записать в parse_sources.")
return
try:
import psycopg2
except ImportError:
sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте на app-хосте/в контейнере.")
dsn = _pg_dsn()
print(f"\nПодключение к PG {dsn['host']}:{dsn['port']}/{dsn['dbname']} ...")
conn = psycopg2.connect(**dsn)
conn.autocommit = True
created, skipped, ids = 0, 0, []
with conn.cursor() as cur:
for name, stype, query in rows:
cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,))
existing = cur.fetchone()
if existing:
skipped += 1
continue
cur.execute(
'INSERT INTO parse_sources '
'(source_type, name, query, lang, "limit", enabled, last_status, docs_added) '
"VALUES (%s, %s, %s, 'ru', %s, TRUE, 'idle', 0) RETURNING id",
(stype, name, query, args.limit),
)
ids.append(cur.fetchone()[0])
created += 1
conn.close()
print(f"Создано: {created}, пропущено (уже были): {skipped}")
if ids:
print(f"ID новых источников: {ids}")
print("\nЗапуск заливки (после проверки источников):")
print(" • Админка → раздел «Источники» → «Запустить» у каждого, ЛИБО")
print(" • Celery: for i in ids: index.run_parser.delay(i)")
print("Рекомендация: сначала запустить 1-2 для проверки темпа/качества, потом остальные.")
if __name__ == "__main__":
main()

View File

@@ -4,14 +4,15 @@ import asyncio
import os
from logging.config import fileConfig
from alembic import context
import app.models # noqa: F401 — регистрирует все модели
# Импорт всех моделей для автоопределения изменений
from app.database import Base
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
# Импорт всех моделей для автоопределения изменений
from app.database import Base
import app.models # noqa: F401 — регистрирует все модели
from alembic import context
# Alembic Config
config = context.config

View File

@@ -7,9 +7,10 @@ Create Date: 2024-01-01 00:00:00.000000
Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers
revision = "001"
down_revision = None

View File

@@ -7,10 +7,11 @@ Create Date: 2026-05-24
import secrets
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "002"
down_revision = "001"
branch_labels = None

View File

@@ -5,9 +5,10 @@ Revises: 002
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
revision = "003"
down_revision = "002"
branch_labels = None

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

@@ -4,12 +4,18 @@
дополнительно проверяют секретный код сессии (verify_admin_code).
"""
import contextlib
import io
import logging
from datetime import datetime, timezone
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
@@ -18,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
@@ -29,6 +35,9 @@ from app.schemas.admin import (
AdminTaskResponse,
AdminUserResponse,
AdminUserUpdate,
ParseRunDetail,
ParseRunResponse,
ParseSourceBulkCreate,
ParseSourceCreate,
ParseSourceResponse,
ParseSourceUpdate,
@@ -41,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")
# ═══════════════════════════════════════════════════════════════════════════════
# СЕССИЯ ДОСТУПА
@@ -85,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]:
"""Статус всех сервисов инфраструктуры."""
@@ -150,11 +188,11 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one()
rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all()
tasks_by_status = {s: c for s, c in rows}
tasks_by_status = dict(rows)
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all()
docs_by_source = {s: c for s, c in rows}
docs_by_source = dict(rows)
staging_pending = (
await db.execute(
@@ -182,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,
@@ -257,10 +284,8 @@ async def update_user(
await db.commit()
await db.refresh(user)
# Сбросить кэш пользователя
try:
with contextlib.suppress(Exception):
await get_redis().delete(f"user:cache:{user_id}")
except Exception:
pass
return AdminUserResponse.model_validate(user)
@@ -424,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)
@@ -441,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)
@@ -464,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,
},
}
# ═══════════════════════════════════════════════════════════════════════════════
@@ -533,7 +1061,7 @@ async def approve_staging(
obj = get_minio().get_object("staging", sw.text_key)
full_text = obj.read().decode("utf-8", errors="replace")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}")
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}") from e
# Добавить в базу документов через существующую задачу индексатора
doc_data = {

View File

@@ -1,13 +1,16 @@
"""Роутер аутентификации: регистрация, вход, верификация email."""
"""Роутер аутентификации: регистрация, вход, верификация email, OAuth."""
import secrets
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:
@@ -224,7 +236,7 @@ async def resend_verification(
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Не удалось отправить письмо, попробуйте позже",
)
) from e
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
@@ -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="Превышен лимит одновременных задач.",
@@ -116,7 +115,7 @@ async def upload_for_plagiarism_check(
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ошибка сохранения файла. Попробуйте позже.",
)
) from e
# ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────

View File

@@ -1,77 +0,0 @@
"""Роутер для получения отчётов о выполненных задачах."""
import logging
from datetime import datetime
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

@@ -1,7 +1,7 @@
"""Авторизация админ-панели: проверка роли is_admin и секретного кода сессии."""
import secrets
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Path, status
from sqlalchemy import select

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,15 +1,26 @@
"""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 datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.redis_client import get_redis
@@ -74,26 +85,8 @@ 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(timezone.utc)
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,7 +2,8 @@
import json
import logging
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import Depends, HTTPException, Query, WebSocket, status
@@ -29,13 +30,17 @@ 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)
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + (
expire = datetime.now(UTC) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
payload["exp"] = expire
@@ -58,7 +63,29 @@ def _decode_token(token: str) -> int:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Невалидный или просроченный токен",
headers={"WWW-Authenticate": "Bearer"},
)
) 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):
@@ -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

@@ -1,5 +1,6 @@
"""WebSocket менеджер для real-time обновлений статуса задач."""
import contextlib
import json
import logging
from typing import Any
@@ -27,10 +28,8 @@ class ConnectionManager:
def disconnect(self, task_id: str, websocket: WebSocket) -> None:
"""Удалить соединение из реестра."""
if task_id in self._connections:
try:
with contextlib.suppress(ValueError):
self._connections[task_id].remove(websocket)
except ValueError:
pass
if not self._connections[task_id]:
del self._connections[task_id]
logger.info(f"WebSocket отключён от задачи {task_id!r}")

View File

@@ -1,14 +1,15 @@
"""Точка входа FastAPI приложения — Академический помощник."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
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
@@ -77,9 +78,13 @@ 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 ───────────────────────────────────────────────────────
# HTTP-метрики (кол-во/латентность запросов по хендлерам) на /metrics.
# Prometheus скрейпит их (см. infra/prometheus/prometheus.yml, профиль observability).
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
# ─── WebSocket ────────────────────────────────────────────────────────────────
@app.websocket("/ws/tasks/{public_id}")
@@ -98,6 +103,7 @@ async def websocket_task_updates(
ownership проверяется до установки соединения.
"""
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.task import Task

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

@@ -3,14 +3,14 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import func, String
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.task import Task
from app.models.document import UsageLog
from app.models.task import Task
class User(Base):
@@ -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

@@ -16,3 +16,4 @@ minio==7.2.7
httpx==0.27.0
aiofiles==23.2.1
websockets==12.0
prometheus-fastapi-instrumentator==7.0.0 # /metrics для Prometheus (профиль observability)

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">
@@ -66,6 +70,12 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
<p className="text-sm text-gray-600 mt-1">
Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments}
</p>
{!!data.cited_fragments && (
<p className="text-sm text-gray-500 mt-0.5">
из них корректно процитировано: {data.cited_fragments} · без указания источника:{' '}
{data.uncited_similarity?.toFixed(1)}%
</p>
)}
</div>
</div>
@@ -125,6 +135,14 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
<span className="text-xs text-gray-400 px-2 py-0.5 bg-white rounded-full border border-gray-200">
{METHOD_LABELS[match.method] || match.method}
</span>
{match.cited && (
<span
className="text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-700"
title="Похоже на корректно оформленную цитату (кавычки или ссылка с годом рядом)"
>
Цитата
</span>
)}
</div>
{/* Фрагмент */}
<div className="px-4 py-3">
@@ -159,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

@@ -56,18 +56,41 @@ export interface PlagiarismMatch {
source_title: string;
source_url: string | null;
source_db: string;
// Похоже на корректную цитату (кавычки/ссылка с годом рядом) — эвристика,
// см. app.scoring.is_cited. Опционально: старые задачи в БД посчитаны без
// этого поля.
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;
// Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат.
// Опционально по той же причине, что и cited.
uncited_similarity?: number;
matches: PlagiarismMatch[];
total_fragments: number;
flagged_fragments: number;
cited_fragments?: number;
uncited_fragments?: number;
by_method?: {
exact: number;
fuzzy: number;
semantic_llm: number;
};
// Тематически близкие работы, которые LLM не подтвердила как заимствование —
// не плагиат, но кандидаты для раскрытия/развития темы. Опционально: старые
// задачи в БД посчитаны без этого поля.
recommendations?: PlagiarismRecommendation[];
}
// ─── Библиография ─────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,42 @@
"""Сборка списка литературы по ГОСТ — чистая логика (без Celery/БД).
Сортирует источники (кириллица → латиница), нумерует и форматирует каждый по
выбранному стилю. Вынесено из Celery-задачи для изолированного тестирования
порядка и нумерации — того, что студент видит в готовом списке литературы.
"""
from collections.abc import Callable
from typing import Any
from app.formatters.gost_7_0_5 import GOST705Formatter
from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key
def build_bibliography(docs: list[dict[str, Any]], style: str = "7.1") -> dict[str, Any]:
"""Собрать нумерованный список литературы по ГОСТ.
Args:
docs: словари документов (title/authors/year/...); каждый должен иметь "id"
style: "7.1" (полное описание) или иначе — "7.0.5" (краткая ссылка)
Returns:
dict с полями:
bibliography: list of {"number", "citation", "doc_id"}
style: применённый стиль
total: число записей
"""
cite: Callable[[dict[str, Any]], str] = (
GOST71Formatter().format_full if style == "7.1" else GOST705Formatter().format_short
)
sorted_docs = sorted(docs, key=_get_sort_key)
bibliography = [
{"number": number, "citation": cite(doc), "doc_id": doc["id"]}
for number, doc in enumerate(sorted_docs, 1)
]
return {
"bibliography": bibliography,
"style": style,
"total": len(bibliography),
}

View File

@@ -1,7 +1,7 @@
"""Подключение к PostgreSQL для gost-воркера."""
from collections.abc import Generator
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

View File

@@ -16,7 +16,6 @@
- Место публикации через "/"
"""
import re
def _format_author(author: dict) -> str:
@@ -245,10 +244,7 @@ def _get_sort_key(doc: dict) -> str:
Строка для сортировки
"""
authors = doc.get("authors", [])
if authors:
last_name = authors[0].get("last_name", "")
else:
last_name = doc.get("title", "")
last_name = authors[0].get("last_name", "") if authors else doc.get("title", "")
if not last_name:
return "яяя" # В конец

View File

@@ -1,15 +1,13 @@
"""Celery задача форматирования библиографии по ГОСТ."""
import logging
from typing import Any
from celery.utils.log import get_task_logger
from sqlalchemy import select
from app.bibliography import build_bibliography
from app.celery_app import celery_app
from app.db import db_session
from app.formatters.gost_7_0_5 import GOST705Formatter
from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key
logger = get_task_logger(__name__)
@@ -60,9 +58,6 @@ def format_bibliography(
if not docs:
raise ValueError(f"Документы не найдены: {doc_ids}")
# Выбрать форматтер
formatter = GOST71Formatter() if style == "7.1" else GOST705Formatter()
# Преобразовать ORM объекты в словари для форматирования
docs_dicts = []
for doc in docs:
@@ -81,27 +76,8 @@ def format_bibliography(
"source": doc.source,
})
# Сортировать: кириллица (рус. авторы) → латиница (иностр.)
sorted_docs = sorted(docs_dicts, key=lambda d: _get_sort_key(d))
bibliography = []
for i, doc_dict in enumerate(sorted_docs, 1):
if style == "7.1":
citation = formatter.format_full(doc_dict)
else:
citation = formatter.format_short(doc_dict)
bibliography.append({
"number": i,
"citation": citation,
"doc_id": doc_dict["id"],
})
result = {
"bibliography": bibliography,
"style": style,
"total": len(bibliography),
}
# Сортировка + нумерация + форматирование — чистая логика в app.bibliography
result = build_bibliography(docs_dicts, style)
# Сохранить результат
task = session.get(Task, task_id)
@@ -113,7 +89,7 @@ def format_bibliography(
logger.info(
f"Библиография сформирована для задачи {task_id!r}: "
f"{len(bibliography)} записей по ГОСТ {style}"
f"{result['total']} записей по ГОСТ {style}"
)
# Уведомить пользователя
@@ -139,4 +115,4 @@ def format_bibliography(
except Exception as db_exc:
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
raise self.retry(exc=exc, countdown=30)
raise self.retry(exc=exc, countdown=30) from exc

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика).
pytest==8.2.0

Some files were not shown because too many files have changed in this diff Show More