Проверка глубины вскрыла главную слабость корпуса: 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>
Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу:
показывала 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>
Сегодняшняя авария (упал гипервизор — вместе с ним брокер, Ollama, прокси)
проявлялась в отладке косвенно: пустой список воркеров и ошибка очередей.
Прямого ответа «что именно недоступно» страница не давала, хотя эндпоинт
/admin/health его уже знает.
Блок статусов рисуется и тогда, когда сам срез не собрался — именно в аварии
он нужен больше всего, а собирается в этот момент дольше обычного.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тесты на queued_s/duration_s фиксируют ровно ту путаницу, из-за которой
метрика и разъехалась: ожидание в очереди и время работы — разные величины,
а у прогонов до миграции 006 длительности просто нет (вместо неё раньше
показывалось время в очереди).
Панель отладки теперь показывает, сколько идущий прогон уже работает —
по этому и виден застрявший, а не только по отсутствию heartbeat.
README: фактические числа тестов (150, проверено прогоном run_tests.sh).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона»
в 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>
Массовый запуск показал: лимит вежливого пула 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>
Заливка корпуса была чёрным ящиком: у источника только 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>
Обнаруженный на практике 26.08 краш-луп (долгий retry в Celery-таске дольше
1800с consumer_timeout → брокер рвёт канал → падение → бесконечный повтор)
стоит знать при добавлении новых долгих retry-циклов. Заодно поправлена
инструкция failover PostgreSQL — POSTGRES_HOST теперь меняется в Infisical,
не в .env на сервере (перезапишется следующим деплоем).
Документация сильно разошлась с кодом за последние недели работы — привёл в
соответствие 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 нигде не строится, эти поля ничего не делали.
Реверс-прокси на CT102 (nginx, /etc/nginx/sites-available/academic)
проксирует /health, /api/, /ws/ на 192.168.1.32:8000 напрямую — а у сервиса
api в docker-compose.prod.yml не было ports:, порт нигде не публиковался на
хост. Статика (/ ) отдавалась нормально (CT102 сам её раздаёт с диска), но
любой запрос к бэкенду падал с 502. Применено вручную на проде и подтверждено
живьём перед коммитом — прод не будет ждать полного деплоя, чтобы починиться.
deploy.sh теперь на каждом запуске логинится в Infisical (Machine Identity
Universal Auth) и генерирует .env из окружения prod перед синком кода и
поднятием контейнеров. Если Infisical недоступен или вернул подозрительно
мало ключей — деплой падает раньше, не трогая рабочий .env на сервере.
Добавлен шаг compose up -d без --build для всех бэкенд-сервисов после
сборки изменившихся — иначе правка секрета без изменения кода не попадала
бы в уже запущенные контейнеры (docker compose пересоздаёт только то, чей
эффективный конфиг реально изменился, остальное не трогает).
5 попыток (1860с суммарно) были чуть больше дефолтного consumer_timeout
RabbitMQ (1800с) — брокер рвал канал раньше, чем таск успевал сдаться и
подтвердиться, воркер падал, docker его перезапускал, сообщение
редоставлялось и весь цикл забега начинался заново с попытки 1 —
бесконечный краш-луп, блокирующий весь пул воркера (concurrency=4).
Ollama обрабатывает тексты в пачке последовательно (один "slot" в
llama.cpp, не параллельно) — 64 реальных документа в одном HTTP-запросе
регулярно не укладывались в 120с, весь батч падал с ReadTimeout и
терялся без ретрая (embed_documents была без bind/max_retries).
Теперь: пачка 16, таймаут 300с, задача ретраится до 3 раз при сбое.
OpenRouter отдаёт 403 с российских IP (подтверждено вживую). Добавлен
sing-box как отдельный сервис (VLESS+WS+TLS до ноды вне РФ) — только для
исходящих запросов к OpenRouter из ollama_client.py, остальной трафик
(Postgres/RabbitMQ/MinIO/Ollama) идёт напрямую. Реальный конфиг с UUID —
только на проде (см. config.json.example), в git не попадает.
EMBED_BACKEND=cloud — эмбеддинги через DeepInfra (OpenAI-совместимый API),
модель та же BAAI/bge-m3, что уже льётся на VM109 — переключение не рвёт
совместимость с уже пересчитанным FAISS-индексом (тот же вектор 1024).
Не активно по умолчанию (EMBED_BACKEND всё ещё "ollama").
LLM_BACKEND=ollama (дефолт, поведение не меняется) | openrouter (облачный
API, дешёвая модель — для да/нет+уверенность "ум" не критичен, зато
не нужен локальный GPU-хост вообще). Общая _complete() прячет разницу
форматов запроса/ответа за check_paraphrase/summarize.
Было: на 429 плоское ожидание 60с и retry без счётчика — при нескольких
параллельных источниках (4 воркера) каждый продлевал общий rate limit
сам, получался самоподдерживающийся затык без единого успешного запроса
по 20+ минут. Теперь: до 5 попыток с экспоненциальным backoff
(60/120/240/480/960с), после — источник сдаётся с тем, что успел
собрать, вместо вечного retry.
Раньше эмбеддинг-модель (уровень 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" оставляло враньё в докстринге.
Критично: L1/L2/L3 сравнивали фрагменты со ВСЕЙ базой documents, включая
source=user_submission — работы, которые auto_approve_submission сам же
затаскивал в корпус после проверки. Итог: студент, перепроверивший тот
же файл дважды, получал 100% "точное совпадение" по всем фрагментам —
против собственной же более ранней загрузки.
Фикс: все три уровня исключают source=user_submission из кандидатов на
совпадение. AUTO_APPROVE_SUBMISSIONS выключен по умолчанию — раньше рос
корпус для будущего сравнения, но без защиты от self/cross-match это
опаснее, чем полезно.
add_document слал index.enrich_full_text для КАЖДОГО документа с url,
даже когда full_text уже пришёл от парсера (у КиберЛенинки — OCR-фрагмент
прямо в ответе поиска). Страница статьи там HTML, не PDF — enrich
гарантированно ничего не находит, только тратит запрос впустую. При
массовой заливке (десятки источников параллельно) это давало шквал
запросов к чужому сайту и 503 в ответ — саму заливку не ломало, но
съедало время и нагружало источник без всякой пользы.
Исходный список из 29 дисциплин был чисто вузовски-научным (экономика,
право, психология и т.д.) — ни одной темы уровня СПО/колледжей
(полиграфия, строительство, сварка, туризм и т.п.), хотя реальные
дипломы студентов именно на них. +29 новых дисциплин, идемпотентно
(старые 30 не трогает).
Раньше удалить задачу мог только админ через свою панель — обычный
пользователь с зависшей (например, навечно застрявшей в processing)
задачей не мог убрать её из кабинета вообще. Кнопка удаления на
карточке + снято искусственное ограничение "нельзя удалить, пока
processing" (все воркеры уже корректно проверяют task is None перед
записью, удаление во время реальной обработки безопасно).
Раскрыт по умолчанию — свёрнутое состояние читалось как «ничего нет».
Плюс явное сообщение, когда подсвечивать действительно нечего (у задачи
нет ни совпадений, ни похожих по теме источников), вместо молчаливой
голой стены текста.
Добавлен GET /tasks/{public_id}/text, читает извлечённый текст из
staged_works.text_key (тот же текст, по которому считались
position_start/position_end) — тот же MinIO-объект, что уже читает
админка для превью. Фронтенд: сворачиваемый блок с текстом работы,
где заимствования/цитаты/тематически близкие фрагменты подсвечены
разными цветами с тултипом на источник.
Кандидаты уровня 3 (FAISS), которые прошли порог семантической схожести,
но LLM не подтвердила заимствование, раньше молча отбрасывались. Теперь
это отдельный блок "recommendations" в отчёте — не плагиат, но источники,
полезные для раскрытия темы.
Живой репорт: юзер получил "Превышен месячный лимит проверок (тариф '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>
Живой репорт: проверка плагиата упала с "Не удалось открыть 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>
Живая проверка: юзер сделал один поиск (давно завершился, 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>
Живая проверка после первого реального 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>
Живая проверка 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>
Реализовано без 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>
Найден реальный 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>
Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает
две дыры относительно Антиплагиат.ру/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>
Оставался незакоммиченным с предыдущего шага (докачка 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>
Отвечает на "неужели больше нет библиотек" — добавлен четвёртый источник.
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>
Поисковый 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>
Продолжение 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>
Диаграмма компонентов и потоков данных (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>
Прод-воркер падал на 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>
Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских
студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP
405) и ни разу не наполнял базу.
- cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка
(API отдаёт список, не строку); чистка <b>-подсветки и HTML-сущностей (").
Проверено вживую: 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>
Каноничная документация всей системы: назначение, схема, 6 сервисов и их зоны
ответственности, модель данных (все таблицы), Celery-очереди/задачи, ключевые
потоки (проверка плагиата, поиск, библиография, наполнение корпуса), 4 уровня
детекции, векторный бэкенд, инфра-топология (узлы/адреса), конфигурация, гейты
качества, наблюдаемость/эксплуатация, безопасность, раскладка репозитория.
README ссылается на ARCHITECTURE.md и DR-HA.md. Данные сверены с кодом (модели,
TaskType/TaskStatus, источники, тарифы, маршрутизация задач).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрыта слепая зона «бэкапы есть, но восстановление не проверялось».
- 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>
Раньше видимости в прод не было — только 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>
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>
Сортировка (кириллица→латиница), нумерация и выбор форматтера жили внутри
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>
_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>
Итоговый процент схожести (тот, что видит студент) и дедупликация совпадений
жили внутри 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>
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>