Заливка корпуса была чёрным ящиком: у источника только 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>
Реализовано без 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>
Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает
две дыры относительно Антиплагиат.ру/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>
Сортировка (кириллица→латиница), нумерация и выбор форматтера жили внутри
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>