Commit Graph

88 Commits

Author SHA1 Message Date
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