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>
Второй 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>
Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились
руками. Покрыта чистая логика детекции и форматирования (без БД/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>
_reverse_map был identity-map (faiss_id == doc_id для IndexIDMap2) —
использовался только как doc.faiss_id = _reverse_map[doc_id], т.е. = doc_id.
Убрал поле, метод _rebuild_reverse_map и его обслуживание в 5 местах;
_use_gpu нигде не читался. -30 строк, функционал тот же.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pypi.org с этой сети отдаёт данные с таймаутами (TCP есть, пакеты почти не
качаются) → CI-сборки падали. pypi.tuna.tsinghua.edu.cn — полное зеркало,
отдаёт стабильно (celery за 13с). Прописал -i зеркало во все 5 Dockerfile.
Сборки перестанут зависеть от флоки-канала до pypi.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add_document: параллельные воркеры проходили проверку existing и оба
вставляли один ext_id → IntegrityError в логах. Теперь ловим её, откатываем
и возвращаем duplicate — чисто, без шумных ошибок.
- Dockerfile: откат --timeout/--retries (он инвалидировал кэш pip-слоя, а
толку от него при недоступном pypi нет). Устойчивость сборки решать зеркалом
когда появится рабочее.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Деплой падал: buildx с холодным кэшем делает полный pip install, а канал до
pypi.org медленный и с таймаутами → сборка не завершалась. Добавил
--timeout 120 --retries 10. Первый успешный build также прогреет buildx-кэш,
дальше пересборки по коду будут быстрыми (только слой COPY).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Корпус набивался с ~8% полного текста — по широким темам мало доступных PDF.
- OpenAlex: параметр open_access_only (is_oa:true), включён по умолчанию для
заливки (INGEST_OPEN_ACCESS_ONLY). A/B: покрытие 20% → 45%.
- arXiv: url теперь прямая ссылка на PDF (был на HTML-страницу аннотации),
теперь enrich_full_text реально качает текст (у arXiv PDF почти у 100%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Три бага, найденные при аудите прода:
- search и documents диспатчили Celery-задачу ДО commit() — быстрый воркер
читал задачу раньше, чем транзакция закоммичена, и падал «задача не найдена»
(поиск не работал вовсе; плагиат спасала латентность скачивания из MinIO).
Теперь коммитим до диспатча.
- notify.send_task_done обращался к user.email/name и task.type ПОСЛЕ закрытия
сессии → DetachedInstanceError, письма о завершении уходили в ретраи.
Значения достаются внутри сессии.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Первый прогон вслепую пересобирал все 5 бэкенд-образов, включая worker-gpu
(torch/faiss ~2 ГБ) — на медленном канале это ~1-2 часа впустую, хотя коммит
менял только CI-файлы. Теперь deploy.sh по git-diff с прошлого деплоя
(маркер .last_deploy_sha) собирает лишь сервисы с изменённым кодом; фронт
пересобирается только при правках services/frontend; правка compose = полная
пересборка. Workflow клонирует репозиторий полностью (нужен лог для diff).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-hosted act_runner в host-режиме на app-хосте 1.32 (label "deploy").
При пуше в main workflow клонирует коммит и запускает scripts/deploy.sh:
синхронизация кода → пересборка/перезапуск бэкенд-сервисов → миграции →
сборка фронтенда → выкладка статики на CT 102 (reverse-proxy) с бэкапом.
Доступ к Proxmox-хосту для CT 102 — через секрет PVE_PASSWORD.
Больше ручных rsync/rebuild — деплой по git push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Раньше LSH-индекс жил in-memory в процессе воркера: терялся при рестарте и
не шарился между воркерами — уровень 2 в проде фактически не работал.
Теперь индекс хранится в общем Redis (тот же REDIS_URL) через нативный
storage_config datasketch:
- переживает рестарт, общий для всех воркеров-индексеров;
- все ключи под префиксом antiplag_lsh — изолированы от кэша/rate-limits и
чужих данных в общей БД; никаких FLUSH (и ACL-юзер их не умеет);
- add_to_lsh теперь upsert (remove+insert) — full-text версия документа
корректно заменяет провизорную по аннотации (был латентный баг: skip-on-
duplicate оставлял абстрактную версию);
- graceful-фолбэк в in-memory, если Redis недоступен, чтобы воркер не падал.
Проверено на боевом Redis через изолированный тестовый префикс: query
находит похожие, все ключи в неймспейсе, точечная чистка вернула БД к
исходному состоянию (чужие данные не затронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tsc падал с TS6133 ('React' is declared but never read) — проект на
automatic JSX runtime, дефолтный импорт React не нужен. Из-за этого не
собирался прод-фронт. Проверено: npm run build проходит.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Бэкенд:
- PATCH /auth/me — изменение имени и/или email. Смена email проверяет
уникальность, сбрасывает is_verified и отправляет новое письмо
подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша
не привязан к сессии), кэш инвалидируется после изменения.
- POST /auth/change-password — смена пароля с подтверждением текущего;
отклоняет неверный текущий и совпадение нового со старым.
Фронтенд:
- Страница /settings: карточка персональных данных (имя, email со статусом
подтверждения и предупреждением о повторной верификации) и карточка смены
пароля с проверкой совпадения.
- Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_current_user кэширует пользователя в Redis на 5 минут. verify_email
не сбрасывал этот кэш после user.is_verified = True — если пользователь
до подтверждения хотя бы раз дёрнул защищённый эндпоинт (закешировался
как неверифицированный), он получал "Необходимо подтвердить email адрес"
ещё до 5 минут после реального подтверждения по ссылке из письма.
Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env),
локально в докере остаётся только Elasticsearch + app-сервисы. Старый
docker-compose.yml (полностью автономный стек) сохранён как
docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в
будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS,
собирающий фронтенд в статику). Makefile переписан под три режима
(dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.