# Архитектура — Академический помощник Каноничное описание системы. Обновляется вместе с кодом; при расхождении верить коду, а не этому файлу. Визуальная схема — [DIAGRAM.md](DIAGRAM.md). Смежные документы: [DR-HA.md](DR-HA.md) (отказоустойчивость), [INGESTION.md](INGESTION.md) (наполнение корпуса), [../README.md](../README.md) (быстрый старт и команды). ## 1. Назначение Веб-сервис для студентов: ввёл тему → система находит **открытые** академические источники, делает изложения, форматирует список литературы по ГОСТ и проверяет текст на плагиат. Всё **асинхронно**: пользователь отправляет задачу, закрывает браузер и получает результат на email (или в реальном времени через WebSocket, если вкладка открыта). ## 2. Общая схема ``` ┌────────────────────────────┐ │ Frontend (React SPA) │ CT 102 → nginx │ Home/Search/Check/Cabinet/ │ │ Bibliography/Settings/Admin │ └──────────────┬──────────────┘ │ HTTPS (/api, /ws) ┌──────────────▼──────────────┐ │ API Gateway (FastAPI) │ 1.32:8000 │ JWT · rate-limit · WebSocket │ │ /metrics (Prometheus) │ └───┬───────────────────────┬──┘ │ publish (RabbitMQ) │ read/write │ │ ┌────────────────────┼───────────────────┐ │ ▼ ▼ ▼ │ queue.index queue.gpu queue.gost ┌───────────┐ ┌────────────┐ ┌───────────┐ │ worker- │ │ worker-gpu │ │ worker- │ │ indexer │ │ FAISS/Qdr. │ │ gost │ │ L1 Winnow │ │ L3 семант. │ │ ГОСТ 7.1/ │ │ L2 MinHash│ │ L4 LLM │ │ 7.0.5 │ │ PDF/DOCX │ │ эмбеддинги │ │ │ └─────┬─────┘ └──────┬─────┘ └─────┬─────┘ │ │ │ └─────────┬─────────┴───────┬──────────┘ ▼ ▼ queue.notify общие данные ┌────────────┐ ┌──────────────────────────────┐ │ worker- │ │ PostgreSQL 1.38 · Redis 1.35 │ │ notifier │ │ RabbitMQ .82 · MinIO 1.21 │ │ SMTP email │ │ Elasticsearch (local 1.32) │ └────────────┘ │ Ollama 1.40 (bge-m3, эмбед.) │ │ OpenRouter/DeepSeek — LLM (опц.)│ │ [opt] Qdrant · Prometheus/Graf.│ └──────────────────────────────┘ ``` ## 3. Сервисы | Сервис | Технологии | Ответственность | |--------|-----------|-----------------| | **api** | FastAPI, SQLAlchemy async (asyncpg), Redis, Celery-producer | HTTP/WS API, auth (JWT), rate-limits, диспетч задач в очереди, админ-панель | | **worker-indexer** | Celery, PyMuPDF, python-docx, xxhash, datasketch | Извлечение текста (PDF/DOCX/TXT), фрагментация, **L1 Winnowing**, **L2 MinHash LSH**, парсинг источников, обогащение full-text | | **worker-gpu** | Celery, FAISS/Qdrant, httpx→Ollama/OpenRouter | Эмбеддинги (`EMBED_BACKEND`: ollama/sentence_transformers/cloud), **L3** семантический поиск, **L4** LLM-анализ парафраза (`LLM_BACKEND`: ollama/openrouter), семантический поиск источников | | **worker-gost** | Celery | Список литературы по **ГОСТ 7.1-2003 / Р 7.0.5-2008** | | **worker-notifier** | Celery, smtplib | Email: письма-результаты и верификация (jze9mail.ru) | | **frontend** | React 18, Vite, TS, Tailwind, Zustand, React Query | SPA: 11 публичных страниц + админ-панель. Собирается в статику, отдаётся nginx | ## 4. Модель данных (PostgreSQL) - **users** — `email`, `hashed_password` (bcrypt), `name`, `is_verified`, `is_admin`, `plan` (free/student/premium), `verification_token`. - **tasks** — `id` (UUID), `public_id` (внешний), `user_id`, `type` (`TaskType`: search/plagiarism/summarize/gost), `status` (`TaskStatus`: queued→processing→done/failed), `celery_task_id`, `input_data` (JSON), `result` (JSON), `error`, `queue_position`. - **documents** — корпус источников: `source` (openalex/arxiv/cyberleninka/ user_submission), `ext_id`, `doi`, `title`, `authors` (JSON), `year`, `lang`, `journal/volume/issue/pages`, `abstract`, `url`, `minio_key` (full-text в MinIO), `faiss_id`. - **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1. - **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу. - **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус, `last_run_id` — ссылка на последний прогон, `resume_token` — позиция продолжения для массовых источников (номер статьи в дампе, токен страницы бакета). - **parse_runs** — прогоны заливки: стадия, счётчики (`target/fetched/processed/ added/duplicates/skipped/failed`), `cancel_requested`, `heartbeat_at`, журнал событий (JSON). Тайминги раздельные: `started_at` — постановка в очередь, `run_started_at` — реальный старт работы воркером (при массовом запуске между ними часы ожидания), `finished_at` — конец. Из них админка рисует шкалу загрузки, см. §12. - **staged_works** — пользовательские загрузки на модерацию перед добавлением в корпус. - **admin_sessions** — одноразовые коды входа в админку. ## 5. Асинхронный конвейер (Celery + RabbitMQ) Брокер — RabbitMQ, backend результатов — Redis. Маршрутизация по префиксу задачи: | Очередь | Задачи | Воркер | |---------|--------|--------| | `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text`, `index.ingest_upload` | worker-indexer | | `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic`, `gpu.index_stats` | worker-gpu | | `queue.gost` | `gost.format_bibliography` | worker-gost | | `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier | Важно: API **коммитит задачу в БД до** `send_task` (иначе гонка dispatch-before-commit). ## 6. Ключевые потоки **Проверка плагиата.** upload (api, файл→MinIO, Task) → `index.extract_and_check` (извлечь текст → фрагментация → **L1 Winnowing** по fingerprints → **L2 MinHash LSH** в Redis) → передаёт частичные совпадения в `gpu.check_plagiarism` (**L3** FAISS/Qdrant семантика по фрагментам → для подозрительных **L4** LLM-парафраз) → `app.scoring.aggregate_results` (итоговый %) → результат в Task → `notify.send_task_done`. Фронтенд (`HighlightedDocument.tsx`) показывает исходный текст работы с подсветкой совпадающих/процитированных фрагментов поверх обычного списка нарушений (`PlagiarismReport.tsx`). **Поиск источников.** api → `gpu.search_semantic`: эмбеддинг запроса → векторный поиск (FAISS/Qdrant) + Elasticsearch BM25 → объединение → результат. **Список литературы.** api → `gost.format_bibliography`: документы из БД → `app.bibliography.build_bibliography` (сортировка кириллица→латиница, нумерация, формат 7.1/7.0.5) → результат. **Наполнение корпуса.** админка → `index.run_parser` — один конвейер для всех источников, со шкалой, журналом и кнопкой остановки. Внутри два пути записи: - *обычные источники* (OpenAlex/arXiv/PMC/КиберЛенинка, фильтр `is_oa`) — `index.add_document` на каждый документ: дедуп по `ext_id`, fingerprints, MinHash, Elasticsearch, эмбеддинг, затем `index.enrich_full_text` (скачать OA-PDF → MinIO → переиндексация); - *массовые источники* (`wikipedia_ru`, `pmc_bulk`) — парсер отдаёт генератор, запись идёт пачками через `COPY` (`app/bulk_writer.py`), позиция продолжения хранится в `parse_sources.resume_token`. Эмбеддинги там не считаются: они медленнее заливки на порядок и стали бы её узким местом, вектора досчитываются отдельно (§12). Ход заливки в обоих случаях пишется в `parse_runs` (§12). Второй путь наполнения — ручная загрузка файлов админом: api сохраняет их в MinIO (`corpus-upload/`) → `index.ingest_upload` (извлечь текст → `add_document`, `source=manual_upload`). Это не проверка на плагиат: файл сразу становится источником для сравнения, минуя отстойник. ## 7. Детекция плагиата — 4 уровня 1. **L1 Winnowing** (`worker-indexer/app/algorithms/winnowing.py`) — точные/частичные совпадения: k-граммы → xxHash → минимум в скользящем окне → fingerprint; Jaccard. 2. **L2 MinHash LSH** (`.../minhash.py`) — нечёткие совпадения: шинглы → MinHash (128 перм.) → LSH-индекс в **общем Redis** (префикс `antiplag_lsh`, upsert, graceful-фолбэк в память). 3. **L3 семантика** (`worker-gpu`) — эмбеддинги `bge-m3` (1024d, нормированы), бэкенд переключается `EMBED_BACKEND` (`ollama` — дефолт, GGUF через Vulkan на AMD GPU; `sentence_transformers` — локальная загрузка CUDA/CPU; `cloud` — облачный инференс той же модели, без локального GPU) → cosine в FAISS `IndexIDMap2(IndexFlatIP)` **или** Qdrant (`VECTOR_BACKEND`); порог 0.75. 4. **L4 LLM-парафраз** — оценивает пары «источник↔фрагмент» для подозрительных из L3; порог confidence 0.7. Бэкенд переключается `LLM_BACKEND`: `ollama` (локальная модель, нужен GPU-хост) или `openrouter` (облачный DeepSeek — геоблокировку РФ обходит SOCKS5-прокси `singbox-proxy`, докер-сервис на базе sing-box/Trojan). Ни один документ с `source=user_submission` (прошлые проверки пользователей) не участвует в сравнении ни на одном уровне — иначе работа матчилась бы сама с собой. Итог: `scoring.aggregate_results` — доля уникальных помеченных позиций (не выше 100%). Кандидаты, похожие семантически, но не подтверждённые LLM как парафраз, не теряются — попадают в отдельный список «похожие по теме источники» (рекомендации, не нарушения). ## 8. Векторный бэкенд Абстрагирован за `worker-gpu/app/vector_store.get_backend()`; `VECTOR_BACKEND=faiss` (файловый синглтон, дефолт) или `qdrant` (сервис, снимает SPOF/конкурентную запись). Переключение и миграция — см. README, раздел «Векторный бэкенд». ## 9. Инфраструктура и топология | Компонент | Узел | Примечание | |-----------|------|-----------| | api + воркеры + Elasticsearch | 1.32 (app-хост) | docker-compose.prod.yml | | Frontend (nginx, статика + TLS) | CT 102 | деплоится отдельно | | PostgreSQL 16 | 1.38 (выделенный LXC) | UTF8; бэкап→MinIO | | Redis 7 | 1.35 (выделенный LXC) | кэш, rate-limits, LSH | | RabbitMQ | 192.168.20.82 | брокер Celery | | MinIO (S3) | 1.21 | документы, full-text, бэкапы | | Ollama (bge-m3, эмбеддинги) | 192.168.1.40 «embedding-cpu» (VM 210 на хосте pve2 .1.37) | Xeon 4314, AVX-512+VNNI, 8 vCPU; модель всегда в RAM (`OLLAMA_KEEP_ALIVE=-1`), сторожевой таймер раз в 2 мин перезапускает зависшую Ollama. Замер на данных корпуса: 3.6 док/с против 2.2 у прежнего сервера | | ~~Ollama на GPU~~ (выведен 31.08.2026) | 192.168.20.109 «embedding-gpu» | RX580; отказал по amdgpu ring timeout (Vulkan-контекст умирал при живом systemd-юните). Оставлен как есть — откат сводится к возврату `OLLAMA_URL` в Infisical | | OpenRouter (DeepSeek, L4 LLM) | облако | опционально вместо локальной Ollama (`LLM_BACKEND=openrouter`); из РФ доступен только через `singbox-proxy` | | Infisical (секреты) | 192.168.20.111 «VM111» | self-hosted, `infisical.jze9.ru`; единственный источник правды для `.env` на проде | | Qdrant / Prometheus / Grafana | 1.32 | опционально, под compose-профилями | CT 108 (192.168.20.163, GTX1070, qwen2.5:7b) — старая LLM-нода, роль полностью заменена OpenRouter, контейнер остановлен, но не удалён. **Деплой** — Gitea Actions по push в `main`: гейт `test` (ruff+mypy → 113 юнит-тестов), затем `deploy` (`needs: test`) через `scripts/deploy.sh` (умная пересборка изменённых сервисов). PG/Redis/RabbitMQ/MinIO не в compose — общая инфра берётся из `.env`, который на каждом деплое генерируется заново из Infisical (см. §10). ## 10. Конфигурация и секреты Приложение читает `.env` (пример структуры — `.env.example`), но на проде этот файл **не редактируется руками** и не персистентен как источник правды — на каждом деплое `scripts/deploy.sh` логинится в self-hosted **Infisical** (`https://infisical.jze9.ru`, проект `academ`, окружение `prod`) через Machine Identity (Universal Auth) и генерирует `.env` заново (`infisical export`) до синка кода. Правки секретов/конфига — через Infisical, а не через `.env` на сервере (следующий деплой затрёт локальные правки). Если Infisical недоступен или вернул подозрительно мало ключей, деплой прерывается раньше, не трогая рабочий `.env`. Обязательно менять (значения уже в Infisical, не дефолты из `.env.example`): `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Опции: `VECTOR_BACKEND`, `QDRANT_URL`, `EMBED_BACKEND`, `LLM_BACKEND`, `GRAFANA_ADMIN_PASSWORD`. Локальная разработка (`docker-compose.test.yml`) продолжает использовать обычный `.env`, отдельный от прод-потока через Infisical. ## 11. Качество и тесты - **113 юнит-тестов** (pytest, per-service) на чистую логику L1-L4/скоринг/фрагменты/ ГОСТ/библиография; инфра замокана или не нужна. Запуск: `make test`. - **Гейты CI**: ruff (весь Python) + mypy (доменная логика) + тесты — блокируют деплой. `make lint`. Хермет-раннеры в `python:3.11-slim`. - Чистая доменная логика вынесена из Celery-задач в тестируемые модули (`scoring.py`, `fragments.py`, `bibliography.py`). Подробнее — README «Тестирование». ## 12. Наблюдаемость и эксплуатация - **Мониторинг**: [`scripts/ops/antiplag_monitor.py`](../scripts/ops/antiplag_monitor.py) (cron 5 мин на 1.32, email при смене статуса). Адреса берутся из прод-`.env`, а не зашиты в код: прежняя версия месяц проверяла остановленный CT 108 и не видела реального сервера эмбеддингов — постоянный ложный DOWN заглушал настоящие аварии. Проверяются в том числе **воркеры Celery**: живой брокер ещё не значит работающую систему (05.09 воркеры час простаивали при «зелёном» брокере). Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower). - **Бэкапы**: `pg_dump→gzip→MinIO`, cron 03:00, ротация 14. Проверка восстановления — `scripts/ops/pg_restore_verify.sh`. HA/DR — [DR-HA.md](DR-HA.md). - **Шкала загрузки источников** (админка → «Источники»): каждый запуск создаёт строку `parse_runs`, воркер пишет туда прогресс раз в ~2с. Шкала считается по формуле `api/app/core/progress.py`: 0→50% — выборка из источника, 50→100% — индексация в базу. Раскрытая строка показывает журнал прогона (по шагам, с таймингами). Кнопки «Запустить всё» / «Остановить всё» — массовый старт и кооперативная отмена (флаг `cancel_requested`, воркер останавливается сам на ближайшем тике; уже начатый прогон не рвём посреди записи в базу). - **Панель отладки** (админка → «Отладка», `GET /api/admin/debug` + `/health`): один срез — доступность инфраструктуры (PostgreSQL/Redis/MinIO/ES/Ollama/ брокер: этот блок показывается даже когда сам срез не собирается, потому что при аварии первый вопрос — что именно отвалилось), живые воркеры Celery и что именно они крутят, глубина очередей RabbitMQ (в т.ч. `unacked` и число потребителей), покрытие корпуса эмбеддингами, активные и проблемные прогоны, зависшие прогоны (нет heartbeat >10 мин), упавшие проверки за сутки и текущие бэкенды (`EMBED_BACKEND`/`LLM_BACKEND`/ `VECTOR_BACKEND`). - **Бюджет времени прогона** (`PARSER_TIME_BUDGET_S`, по умолчанию 1500с) — защита от краш-лупа по `consumer_timeout` RabbitMQ, см. [DR-HA.md](DR-HA.md) §6. - **Покрытие L3 меряется по индексу, а не по БД.** Колонка `documents.faiss_id` для этого непригодна: отметка остаётся после пересоздания индекса (смена модели/размерности) и после сбоев worker-gpu. Панель отладки спрашивает реальное число векторов задачей `gpu.index_stats` и отдельно предупреждает о ложных отметках; чинит их [`scripts/ops/faiss_reconcile.py`](../scripts/ops/faiss_reconcile.py). - **Добор эмбеддингов** — [`scripts/ops/reembed_missing.py`](../scripts/ops/reembed_missing.py): документ попадает в корпус сразу, а вектор для L3 считает отдельная задача `gpu.embed_documents`; если worker-gpu или Ollama были недоступны, эти задачи теряются и документ остаётся невидимым для семантического поиска. Скрипт находит `faiss_id IS NULL` и переотправляет задачи пачками (dry-run по умолчанию). Покрытие видно в панели отладки. ## 13. Безопасность JWT-аутентификация, bcrypt-хэши паролей, email-верификация, отдельный вход в админку (одноразовые коды, `admin_sessions`). Rate-limits по тарифу — в Redis. CORS — явные origins. Пользователь видит только свои задачи (ownership проверяется, в т.ч. на WebSocket). ## 14. Раскладка репозитория ``` services/ api, worker-{gpu,indexer,notifier,gost}, frontend scripts/ parsers/ (OpenAlex/arXiv/PMC/КиберЛенинка), seed_*.py, ops/, deploy.sh, run_tests.sh, run_lint.sh infra/ nginx/, prometheus/, grafana/, singbox/ (VPN-прокси для OpenRouter) docs/ ARCHITECTURE.md (этот файл), DR-HA.md .gitea/workflows/ deploy.yml (гейт test → deploy) ruff.toml · mypy.ini · Makefile · docker-compose.prod.yml ```