feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
All checks were successful
Deploy / test (push) Successful in 3m56s
Deploy / deploy (push) Successful in 31s

Заливка корпуса была чёрным ящиком: у источника только last_status
(idle/running/done/error), без «сколько из скольки», без причины падения и без
способа остановить начатое. Теперь каждый запуск создаёт строку parse_runs,
куда воркер раз в ~2с пишет стадию, счётчики и журнал событий.

Админка:
- шкала загрузки у каждого источника (0→50% выборка, 50→100% индексация),
  раскрытая строка — журнал прогона по шагам с таймингами;
- «Запустить всё» / «Остановить всё» и остановка по одному источнику
  (кооперативная отмена: воркер останавливается сам, не рвя запись в базу);
- пакетное добавление источников (тип + список тем), тип pmc в форме;
- загрузка PDF/DOCX/TXT прямо в базу сравнения (index.ingest_upload);
- страница «Отладка»: воркеры Celery и их текущие таски, очереди RabbitMQ,
  покрытие корпуса эмбеддингами, зависшие и упавшие прогоны, конфиг бэкендов.

Защита от краш-лупа по consumer_timeout RabbitMQ (docs/DR-HA.md §6), без неё
массовый запуск 170+ источников гарантированно ронял воркер:
- PARSER_TIME_BUDGET_S (1500с) — прогон закругляется сам и помечается partial;
- worker_prefetch_multiplier=1 — таймаут считается от ДОСТАВКИ сообщения, и с
  дефолтным префетчем очередь долгих run_parser убивала канал на задачах,
  которые ещё не начинались.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-27 17:40:23 +05:00
parent 95d2903766
commit 78e27806b9
29 changed files with 2118 additions and 168 deletions

View File

@@ -232,9 +232,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика). 1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini). Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
2. **Юнит-тесты** — `pytest` по сервисам: 113 тестов на ядро детекции, скоринга, 2. **Юнит-тесты** — `pytest` по сервисам: 130 тестов на ядро детекции, скоринга,
парсеров, форматирования и OAuth, без внешней инфры (БД/Redis/GPU/Ollama парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
замоканы либо не нужны). (БД/Redis/GPU/Ollama замоканы либо не нужны).
```bash ```bash
make lint # ruff + mypy в изолированном контейнере make lint # ruff + mypy в изолированном контейнере
@@ -262,8 +262,10 @@ make test-one SVC=worker-gost # тесты одного сервиса
| Итоговый % плагиата + цитаты | `worker-gpu/app/scoring.py` | 18 | | Итоговый % плагиата + цитаты | `worker-gpu/app/scoring.py` | 18 |
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 | | ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
| Список литературы | `worker-gost/app/bibliography.py` | 7 | | Список литературы | `worker-gost/app/bibliography.py` | 7 |
| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 | | Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | `scripts/parsers/` | 17 |
| OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 | | OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 |
| Прогресс заливки (счётчики, бюджет) | `worker-indexer/app/progress.py` | 7 |
| Шкала загрузки источников | `api/app/core/progress.py` | 7 |
## Лицензия ## Лицензия

View File

@@ -77,7 +77,11 @@
`faiss_id`. `faiss_id`.
- **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1. - **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1.
- **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу. - **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу.
- **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус. - **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус,
`last_run_id` — ссылка на последний прогон.
- **parse_runs** — прогоны заливки: стадия, счётчики (`target/fetched/processed/
added/duplicates/skipped/failed`), `cancel_requested`, `heartbeat_at`, журнал
событий (JSON). Из них админка рисует шкалу загрузки, см. §12.
- **staged_works** — пользовательские загрузки на модерацию перед добавлением в корпус. - **staged_works** — пользовательские загрузки на модерацию перед добавлением в корпус.
- **admin_sessions** — одноразовые коды входа в админку. - **admin_sessions** — одноразовые коды входа в админку.
@@ -87,7 +91,7 @@
| Очередь | Задачи | Воркер | | Очередь | Задачи | Воркер |
|---------|--------|--------| |---------|--------|--------|
| `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text` | worker-indexer | | `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` | worker-gpu | | `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic` | worker-gpu |
| `queue.gost` | `gost.format_bibliography` | worker-gost | | `queue.gost` | `gost.format_bibliography` | worker-gost |
| `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier | | `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier |
@@ -115,7 +119,11 @@
**Наполнение корпуса.** админка/CLI → `index.run_parser` (OpenAlex/arXiv/PMC/ **Наполнение корпуса.** админка/CLI → `index.run_parser` (OpenAlex/arXiv/PMC/
КиберЛенинка, фильтр `is_oa`) → `index.add_document` (дедуп по `ext_id`, КиберЛенинка, фильтр `is_oa`) → `index.add_document` (дедуп по `ext_id`,
fingerprints, MinHash, эмбеддинг) → `index.enrich_full_text` (скачать OA-PDF → fingerprints, MinHash, эмбеддинг) → `index.enrich_full_text` (скачать OA-PDF →
MinIO → переиндексация). MinIO → переиндексация). Ход заливки пишется в `parse_runs` (§12).
Второй путь наполнения — ручная загрузка файлов админом: api сохраняет их в
MinIO (`corpus-upload/`) → `index.ingest_upload` (извлечь текст → `add_document`,
`source=manual_upload`). Это не проверка на плагиат: файл сразу становится
источником для сравнения, минуя отстойник.
## 7. Детекция плагиата — 4 уровня ## 7. Детекция плагиата — 4 уровня
@@ -203,6 +211,21 @@ Identity (Universal Auth) и генерирует `.env` заново (`infisica
статуса). Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower). статуса). Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower).
- **Бэкапы**: `pg_dump→gzip→MinIO`, cron 03:00, ротация 14. Проверка восстановления — - **Бэкапы**: `pg_dump→gzip→MinIO`, cron 03:00, ротация 14. Проверка восстановления —
`scripts/ops/pg_restore_verify.sh`. HA/DR — [DR-HA.md](DR-HA.md). `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`): один срез —
живые воркеры 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.
## 13. Безопасность ## 13. Безопасность

View File

@@ -66,8 +66,22 @@ Redis у нас — кэш/rate-limits/LSH-индекс (префикс `antipla
(`restart: unless-stopped`) поднимает его заново, недоставленное сообщение (`restart: unless-stopped`) поднимает его заново, недоставленное сообщение
передоставляется — и цикл повторяется бесконечно, монополизируя весь пул воркера передоставляется — и цикл повторяется бесконечно, монополизируя весь пул воркера
(было и на `worker-indexer` из-за backoff `scripts/parsers/openalex.py`, и на (было и на `worker-indexer` из-за backoff `scripts/parsers/openalex.py`, и на
`worker-gpu` из-за зависшей Ollama на embedding-gpu). Текущая митигация — держать `worker-gpu` из-за зависшей Ollama на embedding-gpu).
retry-бэкоффы заведомо короче 1800с (пример: `MAX_RATE_LIMIT_RETRIES` в
`openalex.py`). Более глубокий фикс — поднять `consumer_timeout` на самом RabbitMQ Митигации (2026-08-27, сделано для `worker-indexer`):
(доступа для этого пока не заводили) — актуально при добавлении любых новых
долгих retry-циклов в Celery-тасках. 1. **Бюджет времени прогона** — `PARSER_TIME_BUDGET_S` (1500с) в
`worker-indexer/app/config.py`: `index.run_parser` сам останавливается раньше
дедлайна и помечает прогон `partial` вместо того, чтобы довести воркер до
падения. Остаток дозаливается повторным запуском источника.
2. **`worker_prefetch_multiplier=1`** (`worker-indexer/app/celery_app.py`) —
ключевое при массовой заливке. Таймаут отсчитывается от **доставки**
сообщения, а не от начала выполнения: с дефолтным префетчем (4×concurrency)
сотни поставленных в очередь долгих `run_parser` висят unacked и убивают
канал на задачах, которые ещё даже не начинались.
3. Retry-бэкоффы держим заведомо короче 1800с (`MAX_RATE_LIMIT_RETRIES` в
`openalex.py`).
`worker-gpu` этих защит пока не имеет (там нет своих долгих retry-циклов, но
есть зависание внешней Ollama — см. выше). Более глубокий общий фикс — поднять
`consumer_timeout` на самом RabbitMQ (доступа для этого пока не заводили).

View File

@@ -14,11 +14,9 @@
(русскоязычные, CyberLeninka/OpenAlex `lang=ru`) и **`scripts/seed_broad_corpus.py`** (русскоязычные, CyberLeninka/OpenAlex `lang=ru`) и **`scripts/seed_broad_corpus.py`**
(англоязычные — OpenAlex/arXiv/PMC по широкому списку дисциплин, добавлен позже (англоязычные — OpenAlex/arXiv/PMC по широкому списку дисциплин, добавлен позже
первой волны). первой волны).
- Известный операционный риск при массовой докачке: retry-бэкофф на 429 от OpenAlex - Операционный риск массовой докачки (`consumer_timeout` RabbitMQ vs долгие таски)
не должен по сумме ожиданий превышать `consumer_timeout` RabbitMQ (по умолчанию закрыт бюджетом времени прогона и `worker_prefetch_multiplier=1` — подробности
1800с) — иначе брокер рвёт канал до того, как таск успевает сдаться, и и почему префетч тут главный, см. [DR-HA.md](DR-HA.md) §6.
`worker-indexer` уходит в бесконечный краш-луп на редоставленном сообщении
(см. `scripts/parsers/openalex.py`, `MAX_RATE_LIMIT_RETRIES`).
## Что подготовлено ## Что подготовлено
@@ -42,10 +40,31 @@ python scripts/seed_ru_sources.py
python scripts/seed_ru_sources.py --apply --limit 500 python scripts/seed_ru_sources.py --apply --limit 500
# 2. Проверить пару источников на темпе/качестве, затем запустить заливку: # 2. Проверить пару источников на темпе/качестве, затем запустить заливку:
# • Админ-панель → «Источники» → «Запустить», ЛИБО # • Админ-панель → «Источники» → «Запустить» (или «Запустить всё»), ЛИБО
# • Celery: index.run_parser.delay(source_id) по каждому id # • Celery: index.run_parser.delay(source_id) по каждому id
``` ```
## Управление заливкой из админки
Всё, что ниже, доступно на странице «Источники» — CLI для этого больше не нужен:
- **Шкала загрузки** у каждого источника: стадия (выборка → индексация), сколько
получено из скольки, сколько добавлено/дублей/ошибок. Раскрытая строка —
журнал прогона по шагам с таймингами (таблица `parse_runs`).
- **«Запустить всё»** — прогон по всем включённым источникам; уже идущие
пропускаются. **«Остановить всё»** и остановка по одному — кооперативная
отмена: воркер останавливается сам на ближайшем тике, не обрывая запись в базу.
- **Пакетное добавление** — один тип источника + список тем (по строке),
опционально с немедленным запуском. Заменяет `seed_*.py` для разовых расширений.
- **Загрузка работ в базу** — PDF/DOCX/TXT прямо в корпус сравнения
(`index.ingest_upload`, `source=manual_upload`), минуя проверку и отстойник.
- **Страница «Отладка»** — очереди, воркеры, покрытие эмбеддингами, зависшие и
упавшие прогоны (см. ARCHITECTURE.md §12).
Прогон со статусом `partial` — это не ошибка: сработал бюджет времени
(`PARSER_TIME_BUDGET_S`, 1500с), заливка остановилась раньше `consumer_timeout`
RabbitMQ. Остаток добирается повторным запуском источника.
Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`, Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`,
fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем × fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем ×
500 ≈ 15K русских документов на первый заход. 500 ≈ 15K русских документов на первый заход.

View File

@@ -12,7 +12,7 @@ import xml.etree.ElementTree as ET
from typing import Any from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,6 +46,7 @@ class ArxivParser(BaseParser):
limit: int = 500, limit: int = 500,
categories: list[str] | None = None, categories: list[str] | None = None,
year_from: int | None = None, year_from: int | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Получить препринты из arXiv. Получить препринты из arXiv.
@@ -55,6 +56,7 @@ class ArxivParser(BaseParser):
limit: Максимальное количество документов limit: Максимальное количество документов
categories: Список категорий arXiv (cs.AI, math.ST и т.д.) categories: Список категорий arXiv (cs.AI, math.ST и т.д.)
year_from: Год публикации от year_from: Год публикации от
progress_cb: см. base.ProgressCallback
Returns: Returns:
Список сырых словарей Список сырых словарей
@@ -94,6 +96,10 @@ class ArxivParser(BaseParser):
results.extend(entries) results.extend(entries)
start += len(entries) start += len(entries)
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(f"arXiv: выборка остановлена по запросу (получено {len(results)})")
break
if len(entries) < max_results: if len(entries) < max_results:
break break

View File

@@ -6,11 +6,19 @@
import json import json
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Колбэк прогресса выборки: вызывается после каждой страницы результатов с
# накопленным количеством документов. Возврат False — просьба остановиться
# (кооперативная отмена из админки или исчерпанный бюджет времени таска,
# см. worker-indexer/app/progress.py). Парсер обязан вернуть уже собранное,
# а не бросать исключение: частичная выборка — валидный результат.
ProgressCallback = Callable[[int], bool]
# Унифицированный формат документа # Унифицированный формат документа
UNIFIED_SCHEMA = { UNIFIED_SCHEMA = {
@@ -45,7 +53,10 @@ class BaseParser(ABC):
Получить сырые документы из источника. Получить сырые документы из источника.
Args: Args:
**kwargs: Специфичные для источника параметры (query, limit и т.д.) **kwargs: Специфичные для источника параметры (query, limit и т.д.).
Все парсеры принимают ещё и `progress_cb` (ProgressCallback) —
отчёт о прогрессе после каждой страницы и точка кооперативной
остановки.
Returns: Returns:
Список сырых словарей из API источника Список сырых словарей из API источника

View File

@@ -16,7 +16,7 @@ import time
from typing import Any from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,6 +47,7 @@ class CyberLeninkaParser(BaseParser):
query: str = "", query: str = "",
limit: int = 500, limit: int = 500,
subject: str | None = None, subject: str | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Получить статьи из КиберЛенинки. Получить статьи из КиберЛенинки.
@@ -55,6 +56,7 @@ class CyberLeninkaParser(BaseParser):
query: Поисковый запрос query: Поисковый запрос
limit: Максимальное количество статей limit: Максимальное количество статей
subject: Предметная область (опционально) subject: Предметная область (опционально)
progress_cb: см. base.ProgressCallback
Returns: Returns:
Список сырых словарей статей Список сырых словарей статей
@@ -85,6 +87,12 @@ class CyberLeninkaParser(BaseParser):
results.extend(items) results.extend(items)
page += 1 page += 1
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(
f"КиберЛенинка: выборка остановлена по запросу (получено {len(results)})"
)
break
if len(items) < 10: if len(items) < 10:
break break

View File

@@ -15,7 +15,7 @@ from collections.abc import Generator
from typing import Any from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,6 +45,7 @@ class OpenAlexParser(BaseParser):
year_to: int | None = None, year_to: int | None = None,
type_filter: str = "article", type_filter: str = "article",
open_access_only: bool = False, open_access_only: bool = False,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Получить документы из OpenAlex. Получить документы из OpenAlex.
@@ -58,6 +59,7 @@ class OpenAlexParser(BaseParser):
type_filter: Тип документа (journal-article, book, и т.д.) type_filter: Тип документа (journal-article, book, и т.д.)
open_access_only: только open-access работы (is_oa:true) — резко open_access_only: только open-access работы (is_oa:true) — резко
повышает долю статей с доступным PDF (для наполнения корпуса) повышает долю статей с доступным PDF (для наполнения корпуса)
progress_cb: см. base.ProgressCallback
Returns: Returns:
Список сырых словарей из OpenAlex API Список сырых словарей из OpenAlex API
@@ -74,6 +76,9 @@ class OpenAlexParser(BaseParser):
open_access_only=open_access_only, open_access_only=open_access_only,
): ):
results.extend(page) results.extend(page)
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info("OpenAlex: выборка остановлена по запросу (получено %d)", len(results))
break
if len(results) >= limit: if len(results) >= limit:
break break

View File

@@ -17,7 +17,7 @@ import xml.etree.ElementTree as ET
from typing import Any from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser, ProgressCallback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,6 +52,7 @@ class PMCParser(BaseParser):
limit: int = 500, limit: int = 500,
year_from: int | None = None, year_from: int | None = None,
year_to: int | None = None, year_to: int | None = None,
progress_cb: ProgressCallback | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Получить статьи из PMC Open Access Subset. Получить статьи из PMC Open Access Subset.
@@ -61,6 +62,7 @@ class PMCParser(BaseParser):
limit: Максимальное количество документов limit: Максимальное количество документов
year_from: Год публикации от year_from: Год публикации от
year_to: Год публикации до year_to: Год публикации до
progress_cb: см. base.ProgressCallback
Returns: Returns:
Список сырых словарей (уже с извлечёнными метаданными + full_text) Список сырых словарей (уже с извлечёнными метаданными + full_text)
@@ -85,6 +87,9 @@ class PMCParser(BaseParser):
) )
response.raise_for_status() response.raise_for_status()
results.extend(self._parse_articles(response.text)) results.extend(self._parse_articles(response.text))
if progress_cb and not progress_cb(min(len(results), limit)):
logger.info(f"PMC: выборка остановлена по запросу (получено {len(results)})")
break
time.sleep(RATE_LIMIT_DELAY) time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"PMC efetch HTTP ошибка: {e.response.status_code}") logger.error(f"PMC efetch HTTP ошибка: {e.response.status_code}")

View File

@@ -0,0 +1,68 @@
"""Контракт progress_cb у парсеров: отчёт по страницам и остановка по запросу.
Сеть не трогаем — подменяем HTTP-клиент парсера заглушкой. Проверяем то, на что
опирается заливка: воркер видит рост выборки и может остановить парсер, не
дожидаясь конца (отмена из админки, исчерпанный бюджет времени таска).
"""
from typing import Any
from cyberleninka import CyberLeninkaParser
class FakeResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return self._payload
class FakeClient:
"""Отдаёт бесконечные полные страницы по 10 статей — как большая выдача."""
def __init__(self) -> None:
self.calls = 0
def post(self, url: str, json: dict[str, Any]) -> FakeResponse:
self.calls += 1
start = json["from"]
return FakeResponse({
"articles": [{"id": start + i, "name": f"статья {start + i}"} for i in range(10)]
})
def _parser(monkeypatch) -> CyberLeninkaParser:
p = CyberLeninkaParser()
p.client = FakeClient() # type: ignore[assignment]
monkeypatch.setattr("cyberleninka.RATE_LIMIT_DELAY", 0)
return p
def test_progress_cb_reports_growing_count(monkeypatch):
p = _parser(monkeypatch)
seen: list[int] = []
docs = p.fetch(query="x", limit=30, progress_cb=lambda n: seen.append(n) or True)
assert len(docs) == 30
assert seen == [10, 20, 30]
def test_progress_cb_false_stops_fetch_early(monkeypatch):
p = _parser(monkeypatch)
# Останавливаем после второй страницы — как отмена прогона из админки
docs = p.fetch(query="x", limit=1000, progress_cb=lambda n: n < 20)
assert len(docs) == 20
assert p.client.calls == 2 # type: ignore[attr-defined]
def test_fetch_works_without_callback(monkeypatch):
"""Скрипты заливки зовут парсеры без progress_cb — поведение прежнее."""
p = _parser(monkeypatch)
assert len(p.fetch(query="x", limit=20)) == 20

View File

@@ -24,10 +24,10 @@ docker run --rm \
echo '▶ ruff (services/ scripts/)' echo '▶ ruff (services/ scripts/)'
ruff check services/ scripts/ ruff check services/ scripts/
echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг + OAuth)' echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг + OAuth + прогресс заливки)'
( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py ) ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py app/progress.py )
( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py ) ( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py )
( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py ) ( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py )
( cd services/api && mypy --config-file /repo/mypy.ini app/core/oauth.py ) ( cd services/api && mypy --config-file /repo/mypy.ini app/core/oauth.py app/core/progress.py )
" "
echo "✅ Линт (ruff + mypy) пройден" echo "✅ Линт (ruff + mypy) пройден"

View File

@@ -0,0 +1,68 @@
"""Журнал прогонов парсинга (parse_runs) + ссылка на последний прогон источника.
Revision ID: 005
Revises: 004
Create Date: 2026-08-27
Прогресс заливки раньше был не виден: у источника был только last_status
(idle/running/done/error) без «сколько из скольки». Таблица parse_runs хранит
счётчики и структурный журнал каждого прогона — по ним админка рисует шкалу
загрузки и показывает, на чём именно споткнулся источник.
"""
import sqlalchemy as sa
from alembic import op
revision = "005"
down_revision = "004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"parse_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"source_id",
sa.Integer(),
sa.ForeignKey("parse_sources.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("celery_task_id", sa.String(64), nullable=True),
# queued / running / done / partial / error / cancelled
sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
# queued / fetch / index / finished
sa.Column("stage", sa.String(20), nullable=False, server_default="queued"),
sa.Column("target", sa.Integer(), nullable=False, server_default="0"),
sa.Column("fetched", sa.Integer(), nullable=False, server_default="0"),
sa.Column("processed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("added", sa.Integer(), nullable=False, server_default="0"),
sa.Column("duplicates", sa.Integer(), nullable=False, server_default="0"),
# Мусор от источника (нет title/ext_id) — считаем отдельно от ошибок
sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
# Флаг кооперативной отмены: воркер проверяет его на каждом тике прогресса
sa.Column("cancel_requested", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("log", sa.JSON(), nullable=True),
sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_parse_runs_source_id", "parse_runs", ["source_id"])
op.create_index("ix_parse_runs_status", "parse_runs", ["status"])
op.create_index("ix_parse_runs_started_at", "parse_runs", ["started_at"])
# Последний (он же текущий, пока идёт) прогон источника — чтобы список
# источников отдавался одним джойном, без подзапроса «максимальный id».
op.add_column("parse_sources", sa.Column("last_run_id", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("parse_sources", "last_run_id")
op.drop_index("ix_parse_runs_started_at", table_name="parse_runs")
op.drop_index("ix_parse_runs_status", table_name="parse_runs")
op.drop_index("ix_parse_runs_source_id", table_name="parse_runs")
op.drop_table("parse_runs")

View File

@@ -5,12 +5,17 @@
""" """
import contextlib import contextlib
import io
import logging import logging
from datetime import datetime import os
import uuid
from datetime import datetime, timedelta
from pathlib import Path
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from sqlalchemy import delete, func, select from fastapi.concurrency import run_in_threadpool
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -19,7 +24,7 @@ from app.core.celery_app import celery_app
from app.core.minio_client import get_minio from app.core.minio_client import get_minio
from app.core.redis_client import get_redis from app.core.redis_client import get_redis
from app.database import get_db from app.database import get_db
from app.models.admin import ParseSource, StagedWork from app.models.admin import ParseRun, ParseSource, StagedWork
from app.models.document import Document, Fingerprint from app.models.document import Document, Fingerprint
from app.models.task import Task from app.models.task import Task
from app.models.user import User from app.models.user import User
@@ -30,6 +35,9 @@ from app.schemas.admin import (
AdminTaskResponse, AdminTaskResponse,
AdminUserResponse, AdminUserResponse,
AdminUserUpdate, AdminUserUpdate,
ParseRunDetail,
ParseRunResponse,
ParseSourceBulkCreate,
ParseSourceCreate, ParseSourceCreate,
ParseSourceResponse, ParseSourceResponse,
ParseSourceUpdate, ParseSourceUpdate,
@@ -42,6 +50,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)]) router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)])
# Типы источников, для которых в worker-indexer есть парсер (index.run_parser)
SOURCE_TYPES = ("openalex", "cyberleninka", "arxiv", "pmc")
# Прогон в этих статусах ещё может двигаться — повторно запускать источник нельзя
RUN_ACTIVE_STATUSES = ("queued", "running")
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# СЕССИЯ ДОСТУПА # СЕССИЯ ДОСТУПА
@@ -86,6 +99,28 @@ async def verify_session(
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# ДАШБОРД / СЕРВИСЫ # ДАШБОРД / СЕРВИСЫ
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
async def _rabbitmq_queues() -> dict:
"""Очереди queue.* из management API RabbitMQ: {имя: сырой словарь очереди}.
При недоступности брокера/плагина management возвращает {"error": ...} —
это не повод валить весь дашборд.
"""
try:
# amqp://user:pass@host:port/
creds, _, hostpart = settings.RABBITMQ_URL.split("//", 1)[1].rpartition("@")
rmq_user, _, rmq_pass = creds.partition(":")
rmq_host = hostpart.split(":")[0].split("/")[0]
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(f"http://{rmq_host}:15672/api/queues", auth=(rmq_user, rmq_pass))
if resp.status_code != 200:
return {"error": f"management API вернул {resp.status_code}"}
return {
q["name"]: q for q in resp.json() if str(q.get("name", "")).startswith("queue.")
}
except Exception as e:
return {"error": str(e)[:120]}
@router.get("/health", response_model=list[ServiceHealth]) @router.get("/health", response_model=list[ServiceHealth])
async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]: async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]:
"""Статус всех сервисов инфраструктуры.""" """Статус всех сервисов инфраструктуры."""
@@ -183,23 +218,12 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
storage = {"error": str(e)[:200]} storage = {"error": str(e)[:200]}
# Длины очередей через RabbitMQ management API (если доступно) # Длины очередей через RabbitMQ management API (если доступно)
queues: dict = {} rmq = await _rabbitmq_queues()
try: queues: dict = (
rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0] {"error": rmq["error"]}
rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0] if "error" in rmq
rmq_host = settings.RABBITMQ_URL.split("@")[1].split(":")[0] else {name: q.get("messages", 0) for name, q in rmq.items()}
async with httpx.AsyncClient(timeout=5) as c: )
resp = await c.get(
f"http://{rmq_host}:15672/api/queues",
auth=(rmq_user, rmq_pass),
)
if resp.status_code == 200:
for q in resp.json():
name = q.get("name", "")
if name.startswith("queue."):
queues[name] = q.get("messages", 0)
except Exception as e:
queues = {"error": str(e)[:120]}
return AdminStats( return AdminStats(
users_total=users_total, users_total=users_total,
@@ -423,15 +447,63 @@ async def storage_info() -> dict:
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# ИСТОЧНИКИ ПАРСИНГА # ИСТОЧНИКИ ПАРСИНГА
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
async def _attach_runs(
sources: list[ParseSource], db: AsyncSession
) -> list[ParseSourceResponse]:
"""Подтянуть последние прогоны одним запросом (иначе N+1 на 170+ источников)."""
run_ids = [s.last_run_id for s in sources if s.last_run_id]
runs: dict[int, ParseRun] = {}
if run_ids:
rows = (await db.execute(select(ParseRun).where(ParseRun.id.in_(run_ids)))).scalars().all()
runs = {r.id: r for r in rows}
result = []
for s in sources:
item = ParseSourceResponse.model_validate(s)
run = runs.get(s.last_run_id) if s.last_run_id else None
item.last_run = ParseRunResponse.model_validate(run) if run else None
result.append(item)
return result
async def _start_run(src: ParseSource, db: AsyncSession) -> ParseRun:
"""Создать строку прогона и отправить таск парсера.
Строку создаёт API, а не воркер: при массовом запуске 170+ источников
очередь разбирается минутами, и без неё в админке не было бы видно, что
источник уже поставлен в очередь (шкала висела бы на прошлом прогоне).
"""
run = ParseRun(source_id=src.id, status="queued", stage="queued", target=src.limit, log=[])
db.add(run)
await db.flush()
src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.utcnow()
src.last_run_id = run.id
await db.commit()
await db.refresh(run)
celery_result = celery_app.send_task(
"index.run_parser", args=[src.id, run.id], queue="queue.index"
)
run.celery_task_id = celery_result.id
await db.commit()
await db.refresh(run)
return run
@router.get("/sources", response_model=list[ParseSourceResponse]) @router.get("/sources", response_model=list[ParseSourceResponse])
async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]: async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]:
rows = (await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))).scalars().all() rows = (
return [ParseSourceResponse.model_validate(s) for s in rows] await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))
).scalars().all()
return await _attach_runs(list(rows), db)
@router.post("/sources", response_model=ParseSourceResponse, status_code=201) @router.post("/sources", response_model=ParseSourceResponse, status_code=201)
async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse: async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse:
if data.source_type not in ("openalex", "cyberleninka", "arxiv"): if data.source_type not in SOURCE_TYPES:
raise HTTPException(status_code=400, detail="Недопустимый тип источника") raise HTTPException(status_code=400, detail="Недопустимый тип источника")
src = ParseSource(**data.model_dump()) src = ParseSource(**data.model_dump())
db.add(src) db.add(src)
@@ -440,6 +512,44 @@ async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_
return ParseSourceResponse.model_validate(src) return ParseSourceResponse.model_validate(src)
@router.post("/sources/bulk", status_code=201)
async def create_sources_bulk(
data: ParseSourceBulkCreate, db: AsyncSession = Depends(get_db)
) -> dict:
"""Добавить пачку источников одного типа — по одному на каждый запрос-тему."""
if data.source_type not in SOURCE_TYPES:
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
queries = [q.strip() for q in data.queries if q.strip()]
if not queries:
raise HTTPException(status_code=400, detail="Пустой список запросов")
prefix = data.name_prefix or data.source_type
created: list[ParseSource] = []
for q in queries:
src = ParseSource(
source_type=data.source_type,
name=f"{prefix}:{q}"[:255],
query=q,
lang=data.lang,
year_from=data.year_from,
year_to=data.year_to,
limit=data.limit,
)
db.add(src)
created.append(src)
await db.commit()
started = 0
if data.run_now:
for src in created:
await db.refresh(src)
await _start_run(src, db)
started += 1
return {"created": len(created), "started": started}
@router.patch("/sources/{source_id}", response_model=ParseSourceResponse) @router.patch("/sources/{source_id}", response_model=ParseSourceResponse)
async def update_source( async def update_source(
source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db) source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db)
@@ -463,19 +573,386 @@ async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)) -> N
await db.commit() await db.commit()
@router.post("/sources/{source_id}/run") @router.post("/sources/run-all")
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict: async def run_all_sources(
source_type: str | None = None,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Запустить заливку по всем включённым источникам (кроме уже идущих)."""
stmt = select(ParseSource).where(ParseSource.enabled.is_(True))
if source_type:
stmt = stmt.where(ParseSource.source_type == source_type)
sources = (await db.execute(stmt.order_by(ParseSource.id))).scalars().all()
active_ids = set(
(
await db.execute(
select(ParseRun.source_id).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
)
).scalars().all()
)
started, skipped = 0, 0
for src in sources:
if src.id in active_ids:
skipped += 1
continue
await _start_run(src, db)
started += 1
logger.info("Массовый запуск источников: старт %d, пропущено %d", started, skipped)
return {"started": started, "skipped_active": skipped, "total": len(sources)}
@router.post("/sources/stop-all")
async def stop_all_sources(db: AsyncSession = Depends(get_db)) -> dict:
"""Отменить все активные прогоны (кооперативно — воркер увидит на своём тике)."""
runs = (
await db.execute(select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)))
).scalars().all()
for run in runs:
await _cancel_run(run, db)
await db.commit()
return {"cancelled": len(runs)}
@router.post("/sources/{source_id}/run", response_model=ParseRunResponse)
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunResponse:
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none() src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
if src is None: if src is None:
raise HTTPException(status_code=404, detail="Источник не найден") raise HTTPException(status_code=404, detail="Источник не найден")
if src.last_status == "running":
active = (
await db.execute(
select(ParseRun).where(
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
)
)
).scalars().first()
if active is not None:
raise HTTPException(status_code=409, detail="Источник уже парсится") raise HTTPException(status_code=409, detail="Источник уже парсится")
src.last_status = "running"
src.last_error = None run = await _start_run(src, db)
src.last_run_at = datetime.utcnow() return ParseRunResponse.model_validate(run)
async def _cancel_run(run: ParseRun, db: AsyncSession) -> None:
"""Отменить прогон: флаг для воркера + revoke, если таск ещё не начали.
Пока таск в очереди — revoke снимает его, и никто уже не переведёт прогон
из queued, поэтому закрываем строку прямо здесь. Начатый прогон трогать
жёстко нельзя (оборвётся посреди записи в базу) — ставим флаг, воркер
остановится сам на ближайшем тике прогресса.
"""
run.cancel_requested = True
if run.status == "queued":
if run.celery_task_id:
with contextlib.suppress(Exception):
celery_app.control.revoke(run.celery_task_id)
run.status = "cancelled"
run.stage = "finished"
run.finished_at = datetime.utcnow()
src = await db.get(ParseSource, run.source_id)
if src and src.last_run_id == run.id:
src.last_status = "cancelled"
@router.post("/sources/{source_id}/cancel")
async def cancel_source_run(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
runs = (
await db.execute(
select(ParseRun).where(
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
)
)
).scalars().all()
if not runs:
raise HTTPException(status_code=404, detail="Активных прогонов нет")
for run in runs:
await _cancel_run(run, db)
await db.commit() await db.commit()
celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index") return {"cancelled": len(runs), "source_id": source_id}
return {"status": "started", "source_id": source_id}
@router.get("/sources/{source_id}/runs", response_model=list[ParseRunResponse])
async def list_source_runs(
source_id: int,
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
) -> list[ParseRunResponse]:
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.source_id == source_id)
.order_by(ParseRun.started_at.desc())
.limit(limit)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/active", response_model=list[ParseRunResponse])
async def list_active_runs(db: AsyncSession = Depends(get_db)) -> list[ParseRunResponse]:
"""Все прогоны в работе — для панели отладки и общей шкалы заливки."""
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
.order_by(ParseRun.started_at)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/{run_id}", response_model=ParseRunDetail)
async def get_run(run_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunDetail:
run = await db.get(ParseRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Прогон не найден")
detail = ParseRunDetail.model_validate(run)
detail.log = run.log or []
return detail
# ═══════════════════════════════════════════════════════════════════════════════
# ЗАГРУЗКА РАБОТ В КОРПУС
# ═══════════════════════════════════════════════════════════════════════════════
UPLOAD_EXTENSIONS = {".pdf", ".docx", ".txt"}
UPLOAD_MAX_BYTES = 100 * 1024 * 1024
UPLOAD_CONTENT_TYPES = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
}
@router.post("/documents/upload", status_code=202)
async def upload_documents(files: list[UploadFile]) -> dict:
"""Залить готовые работы прямо в базу сравнения (минуя проверку и отстойник).
Отличие от пользовательской загрузки (/documents/check): там файл проверяют
на плагиат, здесь он сам становится источником, с которым будут сравнивать.
"""
if not files:
raise HTTPException(status_code=400, detail="Файлы не переданы")
accepted: list[dict] = []
rejected: list[dict] = []
minio = get_minio()
for file in files:
name = file.filename or "без имени"
ext = Path(name).suffix.lower()
if ext not in UPLOAD_EXTENSIONS:
rejected.append({"filename": name, "reason": f"формат {ext or '—'} не поддержан"})
continue
data = await file.read()
if not data:
rejected.append({"filename": name, "reason": "пустой файл"})
continue
if len(data) > UPLOAD_MAX_BYTES:
rejected.append({"filename": name, "reason": "больше 100 МБ"})
continue
minio_key = f"corpus-upload/{uuid.uuid4()}{ext}"
try:
minio.put_object(
bucket_name=settings.MINIO_BUCKET_DOCS,
object_name=minio_key,
data=io.BytesIO(data),
length=len(data),
content_type=UPLOAD_CONTENT_TYPES.get(ext, "application/octet-stream"),
)
except Exception as e:
logger.error("Загрузка %s в MinIO не удалась: %s", name, e)
rejected.append({"filename": name, "reason": "ошибка сохранения в хранилище"})
continue
celery_app.send_task(
"index.ingest_upload",
args=[minio_key, name],
kwargs={"meta": {"title": Path(name).stem}},
queue="queue.index",
)
accepted.append({"filename": name, "minio_key": minio_key, "bytes": len(data)})
logger.info("Админ залил в корпус: принято %d, отклонено %d", len(accepted), len(rejected))
return {"accepted": accepted, "rejected": rejected}
# ═══════════════════════════════════════════════════════════════════════════════
# ОТЛАДКА
# ═══════════════════════════════════════════════════════════════════════════════
def _celery_snapshot() -> dict:
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
try:
insp = celery_app.control.inspect(timeout=4)
ping = insp.ping() or {}
active = insp.active() or {}
reserved = insp.reserved() or {}
stats = insp.stats() or {}
except Exception as e:
return {"error": str(e)[:200], "workers": []}
workers = []
for name in sorted(set(ping) | set(stats)):
wstats = stats.get(name, {})
workers.append({
"name": name,
"concurrency": (wstats.get("pool") or {}).get("max-concurrency"),
"reserved": len(reserved.get(name, [])),
"active": [
{
"name": t.get("name"),
"id": t.get("id"),
# args целиком не отдаём: в них бывает текст работы целиком
"args": str(t.get("args"))[:120],
"started_ago_s": (
round(datetime.utcnow().timestamp() - t["time_start"])
if t.get("time_start") else None
),
}
for t in active.get(name, [])
],
})
return {"workers": workers}
@router.get("/debug")
async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
"""Полный срез состояния системы для отладки заливки и проверок.
Один запрос вместо похода по пяти вкладкам: кто из воркеров жив и что
именно сейчас крутит, что копится в очередях, как наполняется корпус,
какие прогоны идут и на чём падали последние.
"""
# Воркеры — блокирующий Celery inspect, в пул потоков, чтобы не вешать луп
celery_state = await run_in_threadpool(_celery_snapshot)
rmq = await _rabbitmq_queues()
queues = (
{"error": rmq["error"]}
if "error" in rmq
else {
name: {
"messages": q.get("messages", 0),
"unacked": q.get("messages_unacknowledged", 0),
"consumers": q.get("consumers", 0),
}
for name, q in sorted(rmq.items())
}
)
# ── Корпус ────────────────────────────────────────────────────────────────
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
docs_embedded = (
await db.execute(
select(func.count()).select_from(Document).where(Document.faiss_id.isnot(None))
)
).scalar_one()
# count(*) по fingerprints — это десятки миллионов строк и секунды ожидания;
# для панели отладки достаточно оценки планировщика
fingerprints_est = (
await db.execute(text("SELECT reltuples::bigint FROM pg_class WHERE relname='fingerprints'"))
).scalar() or 0
es_docs = None
try:
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(f"{settings.ELASTICSEARCH_URL}/documents/_count")
if resp.status_code == 200:
es_docs = resp.json().get("count")
except Exception:
es_docs = None
# ── Источники и прогоны ───────────────────────────────────────────────────
src_rows = (
await db.execute(select(ParseSource.last_status, func.count()).group_by(ParseSource.last_status))
).all()
active_runs = (
await db.execute(
select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)).order_by(ParseRun.id)
)
).scalars().all()
recent_failed_runs = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(("error", "partial")))
.order_by(ParseRun.started_at.desc())
.limit(10)
)
).scalars().all()
# Зависший прогон: числится в работе, но воркер давно не отчитывался
stale_cutoff = datetime.utcnow() - timedelta(minutes=10)
stale_runs = [
r.id for r in active_runs
if r.status == "running" and (r.heartbeat_at is None or r.heartbeat_at < stale_cutoff)
]
# ── Задачи пользователей ──────────────────────────────────────────────────
day_ago = datetime.utcnow() - timedelta(hours=24)
tasks_24h = dict(
(
await db.execute(
select(Task.status, func.count()).where(Task.created_at >= day_ago).group_by(Task.status)
)
).all()
)
failed_tasks = (
await db.execute(
select(Task)
.where(Task.status == "failed")
.order_by(Task.created_at.desc())
.limit(10)
)
).scalars().all()
return {
"generated_at": datetime.utcnow().isoformat(timespec="seconds"),
"celery": celery_state,
"queues": queues,
"corpus": {
"documents": docs_total,
"documents_embedded": docs_embedded,
"documents_without_embedding": max(docs_total - docs_embedded, 0),
"fingerprints_estimate": int(fingerprints_est),
"elasticsearch_documents": es_docs,
},
"sources": {
"by_status": dict(src_rows),
"active_runs": [ParseRunResponse.model_validate(r).model_dump() for r in active_runs],
"stale_run_ids": stale_runs,
"recent_problem_runs": [
ParseRunResponse.model_validate(r).model_dump() for r in recent_failed_runs
],
},
"tasks_24h": tasks_24h,
"recent_failed_tasks": [
{
"public_id": t.public_id,
"type": t.type,
"error": (t.error or "")[:200],
"created_at": t.created_at,
}
for t in failed_tasks
],
# Ключи бэкендов задаются в .env (генерируется из Infisical) и влияют на
# поведение воркеров — при разборе «почему не так считает» нужны первыми
"config": {
"environment": settings.ENVIRONMENT,
"embed_backend": os.environ.get("EMBED_BACKEND", "ollama"),
"llm_backend": os.environ.get("LLM_BACKEND", "ollama"),
"vector_backend": os.environ.get("VECTOR_BACKEND", "faiss"),
"embed_model": os.environ.get("EMBED_MODEL", "—"),
"fetch_full_text": os.environ.get("FETCH_FULL_TEXT", "false"),
"auto_approve_submissions": os.environ.get("AUTO_APPROVE_SUBMISSIONS", "false"),
"parser_time_budget_s": os.environ.get("PARSER_TIME_BUDGET_S", "1500"),
"ollama_url": settings.OLLAMA_URL,
"elasticsearch_url": settings.ELASTICSEARCH_URL,
},
}
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,26 @@
"""Пересчёт состояния прогона парсинга в шкалу загрузки — чистая логика.
Стадии прогона несопоставимы по единицам (получено из API источника vs.
записано в базу), а шкала в админке нужна одна. Формула живёт здесь одна на
всех, чтобы не разъезжаться между списком источников, карточкой прогона и
панелью отладки.
"""
# Прогоны, после которых двигаться уже некуда
TERMINAL_STATUSES = frozenset({"done", "partial", "error", "cancelled"})
def run_percent(status: str, stage: str, target: int, fetched: int, processed: int) -> float:
"""Процент готовности: 0→50% — выборка из источника, 50→100% — индексация.
Завершённый прогон — всегда 100%, каким бы ни был исход: шкала показывает
«работа окончена», а исход — статус рядом с ней. Пока цель неизвестна
(target=0), выборка не даёт прогресса — рисуем 0.
"""
if status in TERMINAL_STATUSES:
return 100.0
fetch_part = min(fetched / target, 1.0) * 50 if target > 0 else 0.0
if stage != "index":
return round(fetch_part, 1)
index_part = min(processed / fetched, 1.0) * 50 if fetched > 0 else 0.0
return round(fetch_part + index_part, 1)

View File

@@ -1,8 +1,8 @@
"""Модели для админ-панели: источники парсинга, отстойник работ, сессии админа.""" """Модели для админ-панели: источники парсинга, прогоны, отстойник, сессии админа."""
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, ForeignKey, String, func from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
@@ -33,12 +33,57 @@ class ParseSource(Base):
last_error: Mapped[str | None] = mapped_column(nullable=True) last_error: Mapped[str | None] = mapped_column(nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True) last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
docs_added: Mapped[int] = mapped_column(default=0) docs_added: Mapped[int] = mapped_column(default=0)
# Последний (пока идёт — текущий) прогон, см. ParseRun
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now()) created_at: Mapped[datetime] = mapped_column(server_default=func.now())
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<ParseSource id={self.id} type={self.source_type!r} name={self.name!r}>" return f"<ParseSource id={self.id} type={self.source_type!r} name={self.name!r}>"
class ParseRun(Base):
"""Один прогон парсинга источника: счётчики прогресса + журнал событий.
Пишется воркером (index.run_parser) на каждом тике прогресса, читается
админкой для шкалы загрузки и отладки — по нему видно не только «упало»,
но и где именно: на какой стадии, сколько получено/проиндексировано,
сколько дублей и ошибок отдельных документов.
"""
__tablename__ = "parse_runs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("parse_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
celery_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# queued / running / done / partial / error / cancelled
status: Mapped[str] = mapped_column(String(20), default="queued", index=True)
# queued / fetch / index / finished
stage: Mapped[str] = mapped_column(String(20), default="queued")
# Цель прогона (limit источника) и фактические счётчики
target: Mapped[int] = mapped_column(default=0)
fetched: Mapped[int] = mapped_column(default=0)
processed: Mapped[int] = mapped_column(default=0)
added: Mapped[int] = mapped_column(default=0)
duplicates: Mapped[int] = mapped_column(default=0)
# Мусор от источника (без title/ext_id) — не то же самое, что ошибка
skipped: Mapped[int] = mapped_column(default=0)
failed: Mapped[int] = mapped_column(default=0)
# Кооперативная отмена: воркер сам проверяет флаг между документами
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{"ts": ISO8601, "level": "info|warning|error", "msg": str}]
log: Mapped[list | None] = mapped_column(JSON, default=list)
started_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
# Последний признак жизни: по нему видно зависший прогон (running, но тишина)
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<ParseRun id={self.id} source={self.source_id} status={self.status!r}>"
class StagedWork(Base): class StagedWork(Base):
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа. """Отстойник: проверенная пользовательская работа, ожидающая решения админа.

View File

@@ -3,7 +3,9 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field
from app.core.progress import run_percent
# ─── Сессия доступа ─────────────────────────────────────────────────────────── # ─── Сессия доступа ───────────────────────────────────────────────────────────
@@ -61,9 +63,42 @@ class AdminDocumentResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ─── Прогоны парсинга ─────────────────────────────────────────────────────────
class ParseRunResponse(BaseModel):
"""Состояние прогона для шкалы загрузки (без журнала — он в ParseRunDetail)."""
id: int
source_id: int
status: str
stage: str
target: int
fetched: int
processed: int
added: int
duplicates: int
skipped: int
failed: int
cancel_requested: bool = False
error: str | None = None
started_at: datetime
heartbeat_at: datetime | None = None
finished_at: datetime | None = None
model_config = {"from_attributes": True}
@computed_field # type: ignore[prop-decorator]
@property
def percent(self) -> float:
return run_percent(self.status, self.stage, self.target, self.fetched, self.processed)
class ParseRunDetail(ParseRunResponse):
log: list[dict[str, Any]] = Field(default_factory=list)
# ─── Источники парсинга ─────────────────────────────────────────────────────── # ─── Источники парсинга ───────────────────────────────────────────────────────
class ParseSourceCreate(BaseModel): class ParseSourceCreate(BaseModel):
source_type: str = Field(description="openalex / cyberleninka / arxiv") source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
query: str | None = None query: str | None = None
lang: str | None = None lang: str | None = None
@@ -98,10 +133,25 @@ class ParseSourceResponse(BaseModel):
last_run_at: datetime | None = None last_run_at: datetime | None = None
docs_added: int docs_added: int
created_at: datetime created_at: datetime
# Последний (или текущий) прогон — источник данных для шкалы в списке
last_run: ParseRunResponse | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class ParseSourceBulkCreate(BaseModel):
"""Пакетное добавление: один тип/лимит, много запросов (по строке на тему)."""
source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
queries: list[str] = Field(min_length=1, max_length=500)
name_prefix: str = ""
lang: str | None = None
year_from: int | None = None
year_to: int | None = None
limit: int = Field(default=1000, ge=1, le=100000)
run_now: bool = False
# ─── Отстойник ──────────────────────────────────────────────────────────────── # ─── Отстойник ────────────────────────────────────────────────────────────────
class StagedWorkResponse(BaseModel): class StagedWorkResponse(BaseModel):
id: int id: int

View File

@@ -0,0 +1,41 @@
"""Юнит-тесты шкалы загрузки источников (app.core.progress) — чистая логика."""
from app.core.progress import run_percent
def test_queued_run_shows_nothing_done():
assert run_percent("queued", "queued", target=100, fetched=0, processed=0) == 0.0
def test_fetch_stage_fills_first_half():
assert run_percent("running", "fetch", target=100, fetched=50, processed=0) == 25.0
assert run_percent("running", "fetch", target=100, fetched=100, processed=0) == 50.0
def test_index_stage_fills_second_half():
# Выборка закончена (50%), проиндексирована половина полученного → 75%
assert run_percent("running", "index", target=100, fetched=100, processed=50) == 75.0
assert run_percent("running", "index", target=100, fetched=100, processed=100) == 100.0
def test_partial_fetch_does_not_inflate_index_progress():
"""Источник отдал меньше лимита: выборка даёт свои 20%, индексация — свои."""
percent = run_percent("running", "index", target=100, fetched=40, processed=20)
assert percent == 45.0 # 20% за выборку + 25% за половину индексации
def test_unknown_target_gives_zero_instead_of_division_error():
assert run_percent("running", "fetch", target=0, fetched=17, processed=0) == 0.0
assert run_percent("running", "index", target=0, fetched=0, processed=0) == 0.0
def test_overshoot_is_clamped():
"""Источник может вернуть больше лимита — шкала не должна уезжать за 100%."""
assert run_percent("running", "fetch", target=10, fetched=99, processed=0) == 50.0
assert run_percent("running", "index", target=10, fetched=10, processed=99) == 100.0
def test_finished_runs_are_always_full():
"""Шкала показывает «работа окончена», исход виден по статусу рядом."""
for status in ("done", "partial", "error", "cancelled"):
assert run_percent(status, "finished", target=100, fetched=3, processed=1) == 100.0

View File

@@ -114,9 +114,28 @@ export const adminApi = {
sources: () => api.get('/admin/sources'), sources: () => api.get('/admin/sources'),
createSource: (data: Record<string, unknown>) => api.post('/admin/sources', data), createSource: (data: Record<string, unknown>) => api.post('/admin/sources', data),
createSourcesBulk: (data: Record<string, unknown>) => api.post('/admin/sources/bulk', data),
updateSource: (id: number, data: Record<string, unknown>) => api.patch(`/admin/sources/${id}`, data), updateSource: (id: number, data: Record<string, unknown>) => api.patch(`/admin/sources/${id}`, data),
deleteSource: (id: number) => api.delete(`/admin/sources/${id}`), deleteSource: (id: number) => api.delete(`/admin/sources/${id}`),
runSource: (id: number) => api.post(`/admin/sources/${id}/run`), runSource: (id: number) => api.post(`/admin/sources/${id}/run`),
cancelSource: (id: number) => api.post(`/admin/sources/${id}/cancel`),
runAllSources: (sourceType?: string) =>
api.post('/admin/sources/run-all', null, { params: sourceType ? { source_type: sourceType } : undefined }),
stopAllSources: () => api.post('/admin/sources/stop-all'),
sourceRuns: (id: number) => api.get(`/admin/sources/${id}/runs`),
activeRuns: () => api.get('/admin/runs/active'),
run: (runId: number) => api.get(`/admin/runs/${runId}`),
debug: () => api.get('/admin/debug'),
uploadDocuments: (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append('files', f));
return api.post('/admin/documents/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // пачка файлов грузится дольше одиночной проверки
});
},
staging: (params?: { status?: string; limit?: number; offset?: number }) => staging: (params?: { status?: string; limit?: number; offset?: number }) =>
api.get('/admin/staging', { params }), api.get('/admin/staging', { params }),

View File

@@ -0,0 +1,47 @@
import { clsx } from 'clsx';
/** Цвет шкалы = исход прогона: серый пока ждёт, синий в работе, дальше по итогу. */
const BAR_COLORS: Record<string, string> = {
queued: 'bg-gray-300',
running: 'bg-brand-500',
done: 'bg-emerald-500',
partial: 'bg-amber-500',
cancelled: 'bg-gray-400',
error: 'bg-red-500',
};
interface ProgressBarProps {
percent: number;
status?: string;
/** Подпись слева под шкалой (что именно сейчас происходит) */
label?: string;
/** Показывать процент справа */
showPercent?: boolean;
className?: string;
}
export function ProgressBar({
percent,
status = 'running',
label,
showPercent = true,
className,
}: ProgressBarProps) {
const value = Math.max(0, Math.min(100, percent));
return (
<div className={clsx('w-full', className)}>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-[width] duration-500', BAR_COLORS[status] || 'bg-brand-500')}
style={{ width: `${value}%` }}
/>
</div>
{(label || showPercent) && (
<div className="flex justify-between mt-1 text-[11px] text-gray-500">
<span className="truncate">{label}</span>
{showPercent && <span className="tabular-nums shrink-0">{value.toFixed(0)}%</span>}
</div>
)}
</div>
);
}

View File

@@ -25,6 +25,7 @@ import { Documents as AdminDocuments } from './pages/admin/Documents';
import { Storage as AdminStorage } from './pages/admin/Storage'; import { Storage as AdminStorage } from './pages/admin/Storage';
import { Sources as AdminSources } from './pages/admin/Sources'; import { Sources as AdminSources } from './pages/admin/Sources';
import { Staging as AdminStaging } from './pages/admin/Staging'; import { Staging as AdminStaging } from './pages/admin/Staging';
import { Debug as AdminDebug } from './pages/admin/Debug';
import './index.css'; import './index.css';
@@ -77,6 +78,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<Route path="storage" element={<AdminStorage />} /> <Route path="storage" element={<AdminStorage />} />
<Route path="sources" element={<AdminSources />} /> <Route path="sources" element={<AdminSources />} />
<Route path="staging" element={<AdminStaging />} /> <Route path="staging" element={<AdminStaging />} />
<Route path="debug" element={<AdminDebug />} />
<Route path="*" element={<Dashboard />} /> <Route path="*" element={<Dashboard />} />
</Routes> </Routes>
</AdminLayout> </AdminLayout>

View File

@@ -3,6 +3,7 @@ import { NavLink, useParams, useNavigate, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
LayoutDashboard, Users, FileText, Database, HardDrive, Download, Inbox, LogOut, ShieldAlert, LayoutDashboard, Users, FileText, Database, HardDrive, Download, Inbox, LogOut, ShieldAlert,
Bug,
} from 'lucide-react'; } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';
@@ -16,6 +17,7 @@ const NAV = [
{ to: 'storage', label: 'Хранилище', icon: HardDrive }, { to: 'storage', label: 'Хранилище', icon: HardDrive },
{ to: 'sources', label: 'Источники', icon: Download }, { to: 'sources', label: 'Источники', icon: Download },
{ to: 'staging', label: 'Отстойник', icon: Inbox }, { to: 'staging', label: 'Отстойник', icon: Inbox },
{ to: 'debug', label: 'Отладка', icon: Bug },
]; ];
interface AdminLayoutProps { interface AdminLayoutProps {

View File

@@ -0,0 +1,255 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Activity, AlertTriangle, Cpu, Database, Layers, RefreshCw, Settings2, ListTree,
} from 'lucide-react';
import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
interface ActiveTask { name: string; id: string; args: string; started_ago_s: number | null }
interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] }
interface Run {
id: number; source_id: number; status: string; stage: string; target: number;
fetched: number; processed: number; added: number; duplicates: number;
skipped: number; failed: number; error: string | null; percent: number;
started_at: string; heartbeat_at: string | null;
}
interface DebugData {
generated_at: string;
celery: { workers: Worker[]; error?: string };
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
corpus: {
documents: number; documents_embedded: number; documents_without_embedding: number;
fingerprints_estimate: number; elasticsearch_documents: number | null;
};
sources: {
by_status: Record<string, number>;
active_runs: Run[];
stale_run_ids: number[];
recent_problem_runs: Run[];
};
tasks_24h: Record<string, number>;
recent_failed_tasks: { public_id: string; type: string; error: string; created_at: string }[];
config: Record<string, string>;
}
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди', fetch: 'выборка', index: 'индексация', finished: 'завершено',
};
function fmtNum(n: number | null | undefined): string {
return n == null ? '—' : n.toLocaleString('ru-RU');
}
export function Debug() {
const { data, isFetching, error } = useQuery({
queryKey: ['admin-debug'],
queryFn: () => adminApi.debug().then((r) => r.data as DebugData),
refetchInterval: 5000,
});
if (error) {
return <div className="text-red-600 text-sm">Не удалось получить срез состояния: {String(error)}</div>;
}
if (!data) {
return <div className="text-gray-400 text-sm">Сбор данных…</div>;
}
const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
const queues = queuesErr ? {} : (data.queues as Record<string, { messages: number; unacked: number; consumers: number }>);
const embedPercent = data.corpus.documents
? (data.corpus.documents_embedded / data.corpus.documents) * 100
: 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
Отладка
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
</h1>
<span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span>
</div>
{/* Активные прогоны заливки */}
<Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}>
{data.sources.stale_run_ids.length > 0 && (
<div className="mb-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
Прогоны без признаков жизни больше 10 минут: {data.sources.stale_run_ids.join(', ')}.
Обычно это упавший или перезапущенный worker-indexer — проверьте его логи и очередь queue.index.
</span>
</div>
)}
{data.sources.active_runs.length ? (
<div className="space-y-3">
{data.sources.active_runs.map((r) => (
<div key={r.id} className="flex items-center gap-4">
<div className="w-40 shrink-0 text-sm text-gray-600 truncate">
#{r.id} · источник {r.source_id}
</div>
<ProgressBar
percent={r.percent}
status={r.status}
label={`${STAGE_LABELS[r.stage] || r.stage} · получено ${r.fetched}/${r.target} · +${r.added}`}
/>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Сейчас ничего не заливается.</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(data.sources.by_status).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
</div>
</Card>
{/* Воркеры */}
<Card icon={Cpu} title="Воркеры Celery">
{data.celery.error && <p className="text-sm text-red-600 mb-2">{data.celery.error}</p>}
{data.celery.workers.length ? (
<div className="space-y-4">
{data.celery.workers.map((w) => (
<div key={w.name}>
<div className="flex items-baseline justify-between mb-1">
<span className="font-medium text-gray-800 text-sm">{w.name}</span>
<span className="text-xs text-gray-500">
параллельно: {w.concurrency ?? '—'} · в работе: {w.active.length} · зарезервировано: {w.reserved}
</span>
</div>
{w.active.length ? (
<div className="border border-gray-100 rounded-lg divide-y divide-gray-50 text-xs font-mono">
{w.active.map((t) => (
<div key={t.id} className="px-3 py-1.5 flex gap-3">
<span className="text-gray-400 tabular-nums shrink-0">
{t.started_ago_s != null ? `${t.started_ago_s}с` : '—'}
</span>
<span className="text-gray-800 shrink-0">{t.name}</span>
<span className="text-gray-400 truncate">{t.args}</span>
</div>
))}
</div>
) : <p className="text-xs text-gray-400">простаивает</p>}
</div>
))}
</div>
) : <p className="text-sm text-gray-400">Воркеры не отвечают на ping.</p>}
</Card>
{/* Очереди */}
<Card icon={Activity} title="Очереди RabbitMQ">
{queuesErr ? (
<p className="text-sm text-red-600">{queuesErr}</p>
) : (
<table className="w-full text-sm">
<thead className="text-gray-500 text-left">
<tr><th className="py-1">Очередь</th><th className="py-1">Ждут</th><th className="py-1">В работе</th><th className="py-1">Потребителей</th></tr>
</thead>
<tbody className="divide-y divide-gray-50">
{Object.entries(queues).map(([name, q]) => (
<tr key={name}>
<td className="py-1.5 text-gray-800">{name}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.messages)}</td>
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.unacked)}</td>
<td className={`py-1.5 tabular-nums ${q.consumers ? 'text-gray-600' : 'text-red-600'}`}>{q.consumers}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* Корпус */}
<Card icon={Database} title="Корпус сравнения">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Metric label="Документов" value={fmtNum(data.corpus.documents)} />
<Metric label="С эмбеддингом" value={fmtNum(data.corpus.documents_embedded)} />
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
</div>
<ProgressBar
percent={embedPercent}
status={data.corpus.documents_without_embedding > 0 ? 'partial' : 'done'}
label={`покрытие эмбеддингами (L3): без вектора ${fmtNum(data.corpus.documents_without_embedding)} документов`}
/>
</Card>
{/* Проблемные прогоны */}
{data.sources.recent_problem_runs.length > 0 && (
<Card icon={AlertTriangle} title="Последние проблемные прогоны">
<div className="space-y-2 text-sm">
{data.sources.recent_problem_runs.map((r) => (
<div key={r.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-gray-50 pb-2 last:border-0">
<span className="text-gray-800">#{r.id}</span>
<span className="text-gray-500">источник {r.source_id}</span>
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
<span className="text-gray-500">
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
</span>
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
</div>
))}
</div>
</Card>
)}
{/* Задачи пользователей */}
<Card icon={ListTree} title="Проверки за 24 часа">
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(data.tasks_24h).map(([s, c]) => (
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
))}
{!Object.keys(data.tasks_24h).length && <span className="text-sm text-gray-400">задач не было</span>}
</div>
{data.recent_failed_tasks.length > 0 && (
<div className="text-xs space-y-1">
<div className="text-gray-500 mb-1">Последние упавшие:</div>
{data.recent_failed_tasks.map((t) => (
<div key={t.public_id} className="flex gap-3">
<span className="text-gray-400 shrink-0">{new Date(t.created_at).toLocaleString('ru-RU')}</span>
<span className="text-gray-700 shrink-0">{t.type}</span>
<span className="text-red-600 font-mono truncate">{t.error || '—'}</span>
</div>
))}
</div>
)}
</Card>
{/* Конфигурация */}
<Card icon={Settings2} title="Конфигурация бэкендов">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 text-sm">
{Object.entries(data.config).map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 border-b border-gray-50 py-1">
<span className="text-gray-500">{k}</span>
<span className="text-gray-800 font-mono truncate" title={v}>{v}</span>
</div>
))}
</div>
</Card>
</div>
);
}
function Card({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-400" /> {title}
</h2>
{children}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xl font-bold text-gray-900 tabular-nums">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}

View File

@@ -1,78 +1,290 @@
import { useState } from 'react'; import { Fragment, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react'; import {
Play, Trash2, Plus, Square, PlayCircle, StopCircle, ChevronDown, ChevronRight,
Upload, Layers, RefreshCw,
} from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar';
interface Run {
id: number; source_id: number; status: string; stage: string;
target: number; fetched: number; processed: number;
added: number; duplicates: number; skipped: number; failed: number;
cancel_requested: boolean; error: string | null;
started_at: string; heartbeat_at: string | null; finished_at: string | null;
percent: number;
}
interface LogEntry { ts: string; elapsed: number; level: string; msg: string }
interface RunDetail extends Run { log: LogEntry[] }
interface Source { interface Source {
id: number; source_type: string; name: string; query: string | null; id: number; source_type: string; name: string; query: string | null;
lang: string | null; year_from: number | null; year_to: number | null; lang: string | null; year_from: number | null; year_to: number | null;
limit: number; enabled: boolean; last_status: string; last_error: string | null; limit: number; enabled: boolean; last_status: string; last_error: string | null;
last_run_at: string | null; docs_added: number; last_run_at: string | null; docs_added: number; last_run: Run | null;
} }
const SOURCE_TYPES = [
{ value: 'openalex', label: 'OpenAlex' },
{ value: 'cyberleninka', label: 'КиберЛенинка' },
{ value: 'arxiv', label: 'arXiv' },
{ value: 'pmc', label: 'PubMed Central' },
];
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
idle: 'bg-gray-100 text-gray-600', idle: 'bg-gray-100 text-gray-600',
queued: 'bg-gray-100 text-gray-600',
running: 'bg-blue-100 text-blue-700', running: 'bg-blue-100 text-blue-700',
done: 'bg-emerald-100 text-emerald-700', done: 'bg-emerald-100 text-emerald-700',
partial: 'bg-amber-100 text-amber-700',
cancelled: 'bg-gray-200 text-gray-600',
error: 'bg-red-100 text-red-700', error: 'bg-red-100 text-red-700',
}; };
const STAGE_LABELS: Record<string, string> = {
queued: 'в очереди',
fetch: 'выборка из источника',
index: 'индексация в базу',
finished: 'завершено',
};
const ACTIVE = ['queued', 'running'];
/** Что происходит с источником прямо сейчас — подпись под шкалой. */
function runLabel(run: Run): string {
if (run.stage === 'fetch') return `${STAGE_LABELS.fetch}: ${run.fetched}/${run.target}`;
if (run.stage === 'index') return `${STAGE_LABELS.index}: ${run.processed}/${run.fetched}`;
return `+${run.added} новых · ${run.duplicates} дублей${run.failed ? ` · ${run.failed} ошибок` : ''}`;
}
export function Sources() { export function Sources() {
const qc = useQueryClient(); const qc = useQueryClient();
const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 1000 });
const { data: sources } = useQuery({ const [bulk, setBulk] = useState({ open: false, source_type: 'openalex', queries: '', lang: '', limit: 1000, run_now: false });
const [expanded, setExpanded] = useState<number | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { data: sources, isFetching } = useQuery({
queryKey: ['admin-sources'], queryKey: ['admin-sources'],
queryFn: () => adminApi.sources().then((r) => r.data as Source[]), queryFn: () => adminApi.sources().then((r) => r.data as Source[]),
refetchInterval: 5000, refetchInterval: 3000,
}); });
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin-sources'] });
const fail = (e: any) => toast.error(e.response?.data?.detail || 'Ошибка');
const create = useMutation({ const create = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSource(data), mutationFn: (data: Record<string, unknown>) => adminApi.createSource(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Источник добавлен'); setForm({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); }, onSuccess: () => {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), invalidate();
toast.success('Источник добавлен');
setForm({ source_type: form.source_type, name: '', query: '', lang: '', limit: form.limit });
},
onError: fail,
});
const createBulk = useMutation({
mutationFn: (data: Record<string, unknown>) => adminApi.createSourcesBulk(data),
onSuccess: (r) => {
invalidate();
toast.success(`Добавлено источников: ${r.data.created}${r.data.started ? `, запущено ${r.data.started}` : ''}`);
setBulk({ ...bulk, queries: '' });
},
onError: fail,
}); });
const run = useMutation({ const run = useMutation({
mutationFn: (id: number) => adminApi.runSource(id), mutationFn: (id: number) => adminApi.runSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Парсинг запущен'); }, onSuccess: () => { invalidate(); toast.success('Парсинг запущен'); },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), onError: fail,
});
const cancel = useMutation({
mutationFn: (id: number) => adminApi.cancelSource(id),
onSuccess: () => { invalidate(); toast.success('Остановка запрошена'); },
onError: fail,
});
const runAll = useMutation({
mutationFn: () => adminApi.runAllSources(),
onSuccess: (r) => {
invalidate();
toast.success(`Запущено ${r.data.started} из ${r.data.total} (уже шли: ${r.data.skipped_active})`);
},
onError: fail,
});
const stopAll = useMutation({
mutationFn: () => adminApi.stopAllSources(),
onSuccess: (r) => { invalidate(); toast.success(`Остановка ${r.data.cancelled} прогонов запрошена`); },
onError: fail,
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (id: number) => adminApi.deleteSource(id), mutationFn: (id: number) => adminApi.deleteSource(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Удалён'); }, onSuccess: () => { invalidate(); toast.success('Удалён'); },
onError: fail,
}); });
const upload = useMutation({
mutationFn: (files: File[]) => adminApi.uploadDocuments(files),
onSuccess: (r) => {
const { accepted, rejected } = r.data;
toast.success(`Принято файлов: ${accepted.length}${rejected.length ? `, отклонено ${rejected.length}` : ''}`);
rejected.forEach((x: { filename: string; reason: string }) => toast.error(`${x.filename}: ${x.reason}`));
},
onError: fail,
});
const list = sources || [];
const active = list.filter((s) => s.last_run && ACTIVE.includes(s.last_run.status));
const activeAdded = active.reduce((sum, s) => sum + (s.last_run?.added || 0), 0);
// Общая шкала = средняя готовность идущих прогонов: столько заливки осталось
const overall = active.length
? active.reduce((sum, s) => sum + (s.last_run?.percent || 0), 0) / active.length
: 0;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<h1 className="text-2xl font-bold text-gray-900">Источники парсинга</h1> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
Источники парсинга
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
</h1>
<div className="flex gap-2">
<button
onClick={() => { if (confirm(`Запустить заливку по всем включённым источникам (${list.length})?`)) runAll.mutate(); }}
disabled={runAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<PlayCircle className="w-4 h-4" /> Запустить всё
</button>
<button
onClick={() => { if (confirm('Остановить все идущие прогоны?')) stopAll.mutate(); }}
disabled={!active.length || stopAll.isPending}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-40"
>
<StopCircle className="w-4 h-4" /> Остановить всё
</button>
</div>
</div>
{/* Общий прогресс заливки */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="font-semibold text-gray-800">Заливка корпуса</h2>
<span className="text-sm text-gray-500">
активных источников: <b className="text-gray-800">{active.length}</b> из {list.length}
{active.length > 0 && <> · добавлено в этом заходе: <b className="text-gray-800">{activeAdded}</b></>}
</span>
</div>
<ProgressBar
percent={overall}
status={active.length ? 'running' : 'done'}
label={active.length ? `${active.length} прогонов в работе` : 'все прогоны завершены'}
/>
</div>
{/* Форма добавления */} {/* Форма добавления */}
<div className="bg-white rounded-xl border border-gray-100 p-5"> <div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-3">Добавить источник</h2> <div className="flex items-center justify-between mb-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3"> <h2 className="font-semibold text-gray-800">Добавить источник</h2>
<select value={form.source_type} onChange={(e) => setForm({ ...form, source_type: e.target.value })} <button
className="border border-gray-200 rounded-lg px-3 py-2 text-sm"> onClick={() => setBulk({ ...bulk, open: !bulk.open })}
<option value="openalex">OpenAlex</option> className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
<option value="cyberleninka">КиберЛенинка</option> >
<option value="arxiv">arXiv</option> <Layers className="w-4 h-4" /> {bulk.open ? 'обычное добавление' : 'пакетно (много тем)'}
</select> </button>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="Запрос"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.lang} onChange={(e) => setForm({ ...form, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={form.limit} onChange={(e) => setForm({ ...form, limit: Number(e.target.value) })} placeholder="Лимит"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
</div> </div>
<button
onClick={() => { {!bulk.open ? (
if (!form.name) { toast.error('Укажите название'); return; } <>
create.mutate({ ...form, lang: form.lang || null, query: form.query || null }); <div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<select value={form.source_type} onChange={(e) => setForm({ ...form, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="Запрос"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input value={form.lang} onChange={(e) => setForm({ ...form, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={form.limit} onChange={(e) => setForm({ ...form, limit: Number(e.target.value) })} placeholder="Лимит"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
</div>
<button
onClick={() => {
if (!form.name) { toast.error('Укажите название'); return; }
create.mutate({ ...form, lang: form.lang || null, query: form.query || null });
}}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
>
<Plus className="w-4 h-4" /> Добавить
</button>
</>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<select value={bulk.source_type} onChange={(e) => setBulk({ ...bulk, source_type: e.target.value })}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input value={bulk.lang} onChange={(e) => setBulk({ ...bulk, lang: e.target.value })} placeholder="Язык (ru/en)"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<input type="number" value={bulk.limit} onChange={(e) => setBulk({ ...bulk, limit: Number(e.target.value) })} placeholder="Лимит на тему"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" checked={bulk.run_now} onChange={(e) => setBulk({ ...bulk, run_now: e.target.checked })} />
запустить сразу
</label>
</div>
<textarea
value={bulk.queries}
onChange={(e) => setBulk({ ...bulk, queries: e.target.value })}
placeholder={'Одна тема на строку:\nмашинное обучение\nэкономика труда\nгражданское право'}
rows={5}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono"
/>
<button
onClick={() => {
const queries = bulk.queries.split('\n').map((q) => q.trim()).filter(Boolean);
if (!queries.length) { toast.error('Добавьте хотя бы одну тему'); return; }
createBulk.mutate({
source_type: bulk.source_type, queries, lang: bulk.lang || null,
limit: bulk.limit, run_now: bulk.run_now,
});
}}
disabled={createBulk.isPending}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
<Plus className="w-4 h-4" /> Добавить пачкой
</button>
</>
)}
</div>
{/* Загрузка готовых работ в базу сравнения */}
<div className="bg-white rounded-xl border border-gray-100 p-5">
<h2 className="font-semibold text-gray-800 mb-1">Загрузить работы в базу</h2>
<p className="text-sm text-gray-500 mb-3">
Файлы (PDF, DOCX, TXT) попадают прямо в базу сравнения — с ними будут сверяться проверяемые работы.
Это не проверка на плагиат.
</p>
<input
ref={fileInput}
type="file"
multiple
accept=".pdf,.docx,.txt"
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length) upload.mutate(files);
e.target.value = '';
}} }}
className="mt-3 flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700" />
<button
onClick={() => fileInput.current?.click()}
disabled={upload.isPending}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
> >
<Plus className="w-4 h-4" /> Добавить <Upload className="w-4 h-4" /> {upload.isPending ? 'Загрузка…' : 'Выбрать файлы'}
</button> </button>
</div> </div>
@@ -81,34 +293,128 @@ export function Sources() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left"> <thead className="bg-gray-50 text-gray-500 text-left">
<tr> <tr>
<th className="px-3 py-3 w-8"></th>
<th className="px-4 py-3">Название</th><th className="px-4 py-3">Тип</th> <th className="px-4 py-3">Название</th><th className="px-4 py-3">Тип</th>
<th className="px-4 py-3">Запрос</th><th className="px-4 py-3">Лимит</th> <th className="px-4 py-3">Лимит</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Добавлено</th><th className="px-4 py-3"></th> <th className="px-4 py-3 min-w-[220px]">Прогресс</th>
<th className="px-4 py-3">Статус</th><th className="px-4 py-3">Всего</th><th className="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{sources?.map((s) => ( {list.map((s) => {
<tr key={s.id} className="hover:bg-gray-50"> const r = s.last_run;
<td className="px-4 py-3 text-gray-800">{s.name}</td> const isActive = !!r && ACTIVE.includes(r.status);
<td className="px-4 py-3 text-gray-600">{s.source_type}</td> return (
<td className="px-4 py-3 text-gray-500 max-w-[160px] truncate">{s.query || '—'}</td> <Fragment key={s.id}>
<td className="px-4 py-3 text-gray-600">{s.limit}</td> <tr className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-3 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`} title={s.last_error || ''}>{s.last_status}</span> <button onClick={() => setExpanded(expanded === s.id ? null : s.id)}
</td> className="text-gray-400 hover:text-gray-600" title="Журнал прогона">
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td> {expanded === s.id ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<td className="px-4 py-3 flex gap-1"> </button>
<button onClick={() => run.mutate(s.id)} disabled={s.last_status === 'running'} title="Запустить" </td>
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded disabled:opacity-40"><Play className="w-4 h-4" /></button> <td className="px-4 py-3 text-gray-800">
<button onClick={() => { if (confirm('Удалить источник?')) del.mutate(s.id); }} title="Удалить" {s.name}
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button> {s.query && <div className="text-xs text-gray-400 truncate max-w-[220px]">{s.query}</div>}
</td> </td>
</tr> <td className="px-4 py-3 text-gray-600">{s.source_type}</td>
))} <td className="px-4 py-3 text-gray-600">{s.limit}</td>
<td className="px-4 py-3">
{r ? <ProgressBar percent={r.percent} status={r.status} label={runLabel(r)} />
: <span className="text-xs text-gray-400">не запускался</span>}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[s.last_status] || 'bg-gray-100'}`}
title={s.last_error || ''}>{s.last_status}</span>
</td>
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{isActive ? (
<button onClick={() => cancel.mutate(s.id)} title="Остановить"
className="p-1.5 text-gray-400 hover:text-amber-600 rounded"><Square className="w-4 h-4" /></button>
) : (
<button onClick={() => run.mutate(s.id)} title="Запустить"
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded"><Play className="w-4 h-4" /></button>
)}
<button onClick={() => { if (confirm('Удалить источник?')) del.mutate(s.id); }} title="Удалить"
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
{expanded === s.id && (
<tr>
<td colSpan={8} className="bg-gray-50 px-6 py-4">
<RunLog runId={r?.id} live={isActive} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody> </tbody>
</table> </table>
{!sources?.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>} {!list.length && <div className="p-6 text-center text-gray-400 text-sm">Нет источников</div>}
</div> </div>
</div> </div>
); );
} }
const LOG_COLORS: Record<string, string> = {
error: 'text-red-600',
warning: 'text-amber-600',
info: 'text-gray-600',
};
/** Журнал последнего прогона источника — что именно происходило по шагам. */
function RunLog({ runId, live }: { runId?: number; live: boolean }) {
const { data, isLoading } = useQuery({
queryKey: ['admin-run', runId],
queryFn: () => adminApi.run(runId!).then((r) => r.data as RunDetail),
enabled: !!runId,
refetchInterval: live ? 3000 : false,
});
if (!runId) return <div className="text-sm text-gray-400">Источник ещё не запускался.</div>;
if (isLoading || !data) return <div className="text-sm text-gray-400">Загрузка журнала…</div>;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<Chip label="прогон" value={`#${data.id}`} />
<Chip label="стадия" value={STAGE_LABELS[data.stage] || data.stage} />
<Chip label="получено" value={`${data.fetched}/${data.target}`} />
<Chip label="обработано" value={String(data.processed)} />
<Chip label="добавлено" value={String(data.added)} />
<Chip label="дублей" value={String(data.duplicates)} />
{data.skipped > 0 && <Chip label="без метаданных" value={String(data.skipped)} />}
{data.failed > 0 && <Chip label="ошибок" value={String(data.failed)} />}
<Chip label="начат" value={new Date(data.started_at).toLocaleString('ru-RU')} />
{data.finished_at && <Chip label="завершён" value={new Date(data.finished_at).toLocaleString('ru-RU')} />}
</div>
{data.error && (
<div className="text-xs text-red-700 bg-red-50 border border-red-100 rounded-lg p-3 font-mono break-all">
{data.error}
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg max-h-64 overflow-y-auto font-mono text-xs">
{data.log?.length ? data.log.map((e, i) => (
<div key={i} className="px-3 py-1 border-b border-gray-50 last:border-0 flex gap-3">
<span className="text-gray-400 shrink-0 tabular-nums">+{e.elapsed.toFixed(0)}с</span>
<span className={`${LOG_COLORS[e.level] || 'text-gray-600'} break-all`}>{e.msg}</span>
</div>
)) : <div className="px-3 py-2 text-gray-400">Записей нет.</div>}
</div>
</div>
);
}
function Chip({ label, value }: { label: string; value: string }) {
return (
<span className="px-2 py-1 bg-white border border-gray-200 rounded-lg text-gray-600">
{label}: <b className="text-gray-800">{value}</b>
</span>
);
}

View File

@@ -26,5 +26,12 @@ celery_app.conf.update(
}, },
task_acks_late=True, task_acks_late=True,
task_reject_on_worker_lost=True, task_reject_on_worker_lost=True,
# Один неподтверждённый месседж на процесс пула. При acks_late=True всё
# предвыбранное висит unacked, а RabbitMQ рвёт канал по consumer_timeout
# (1800с) с момента ДОСТАВКИ, а не начала выполнения. С дефолтным префетчем
# (4×concurrency) массовая заливка — сотни долгих run_parser в очереди —
# гарантированно роняет воркер на задачах, которые ещё даже не начинались,
# и он уходит в краш-луп на передоставленных сообщениях (docs/DR-HA.md §6).
worker_prefetch_multiplier=1,
result_expires=86400, result_expires=86400,
) )

View File

@@ -59,6 +59,13 @@ class Settings(BaseSettings):
FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным
EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов
# Бюджет времени одного прогона index.run_parser. RabbitMQ рвёт канал
# consumer'а, не сделавшего ack за consumer_timeout (по умолчанию 1800с):
# воркер падает, сообщение передоставляется, таск начинается заново —
# бесконечный краш-луп (docs/DR-HA.md §6). Прогон закругляется раньше и
# честно помечается partial: остаток дозаливается повторным запуском.
PARSER_TIME_BUDGET_S: float = 1500.0
# Автоматически добавлять проверенные работы студентов в базу для сравнения # Автоматически добавлять проверенные работы студентов в базу для сравнения
# (как в коммерческих системах — Антиплагиат.ру/Turnitin ловят списывание у # (как в коммерческих системах — Антиплагиат.ру/Turnitin ловят списывание у
# предыдущих потоков именно так). Выключено по умолчанию: без ручной # предыдущих потоков именно так). Выключено по умолчанию: без ручной

View File

@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func from sqlalchemy import JSON, BigInteger, Boolean, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -77,9 +77,34 @@ class ParseSource(Base):
last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True) last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
docs_added: Mapped[int] = mapped_column(default=0) docs_added: Mapped[int] = mapped_column(default=0)
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now()) created_at: Mapped[datetime] = mapped_column(server_default=func.now())
class ParseRun(Base):
"""Прогон парсинга: счётчики прогресса и журнал (пишет run_parser)."""
__tablename__ = "parse_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(ForeignKey("parse_sources.id"))
celery_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="queued")
stage: Mapped[str] = mapped_column(String(20), default="queued")
target: Mapped[int] = mapped_column(default=0)
fetched: Mapped[int] = mapped_column(default=0)
processed: Mapped[int] = mapped_column(default=0)
added: Mapped[int] = mapped_column(default=0)
duplicates: Mapped[int] = mapped_column(default=0)
skipped: Mapped[int] = mapped_column(default=0)
failed: Mapped[int] = mapped_column(default=0)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
log: Mapped[list | None] = mapped_column(JSON, default=list)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
class StagedWork(Base): class StagedWork(Base):
__tablename__ = "staged_works" __tablename__ = "staged_works"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -100,4 +125,13 @@ class StagedWork(Base):
created_at: Mapped[datetime] = mapped_column(server_default=func.now()) created_at: Mapped[datetime] = mapped_column(server_default=func.now())
__all__ = ["Base", "User", "Task", "Document", "Fingerprint", "ParseSource", "StagedWork"] __all__ = [
"Base",
"User",
"Task",
"Document",
"Fingerprint",
"ParseSource",
"ParseRun",
"StagedWork",
]

View File

@@ -0,0 +1,124 @@
"""Счётчики прогресса прогона парсинга — чистая логика, без БД и Celery.
Отделено от tasks/index.py по той же причине, что и staging.py: здесь живут
решения, которые легко ломаются молча (как часто писать в БД, когда пора
закругляться по бюджету времени, сколько строк журнала хранить) — их надо
тестировать изолированно, без RabbitMQ/PostgreSQL.
Бюджет времени — не оптимизация, а защита: RabbitMQ рвёт канал consumer'а,
не сделавшего ack за `consumer_timeout` (по умолчанию 1800с), Celery-процесс
падает, сообщение передоставляется и таск начинается заново — бесконечный
краш-луп, который уже дважды съедал весь пул воркера (см. docs/DR-HA.md §6).
Поэтому прогон сам останавливается раньше дедлайна и честно помечается
`partial`, а не доводит воркер до падения.
"""
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
# Значения по умолчанию: бюджет заметно меньше consumer_timeout (1800с),
# чтобы после остановки успеть дописать статус в БД и сдаться брокеру.
DEFAULT_BUDGET_S = 1500.0
DEFAULT_FLUSH_INTERVAL_S = 2.0
LOG_TAIL_LIMIT = 200
class RunProgress:
"""Состояние одного прогона: счётчики, журнал, тайминги.
Воркер дёргает `count_*`/`log`, а по `should_flush()` сбрасывает
`snapshot()` в таблицу parse_runs.
"""
def __init__(
self,
target: int,
budget_s: float = DEFAULT_BUDGET_S,
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
log_limit: int = LOG_TAIL_LIMIT,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.target = max(0, target)
self.budget_s = budget_s
self.flush_interval_s = flush_interval_s
self.log_limit = log_limit
self._clock = clock
self._started = clock()
self._last_flush = self._started
self.stage = "queued"
self.fetched = 0
self.processed = 0
self.added = 0
self.duplicates = 0
self.skipped = 0
self.failed = 0
self.entries: list[dict[str, Any]] = []
# ── тайминги ──────────────────────────────────────────────────────────────
@property
def elapsed(self) -> float:
return self._clock() - self._started
@property
def over_budget(self) -> bool:
"""Пора закругляться, пока брокер не оборвал канал."""
return self.elapsed >= self.budget_s
def should_flush(self) -> bool:
"""Прошло ли достаточно времени с прошлой записи в БД."""
return (self._clock() - self._last_flush) >= self.flush_interval_s
def mark_flushed(self) -> None:
self._last_flush = self._clock()
# ── счётчики ──────────────────────────────────────────────────────────────
def count_fetched(self, total: int) -> None:
"""Сколько сырых документов получено от источника (нарастающим итогом)."""
self.fetched = total
def count_result(self, status: str) -> None:
"""Учесть результат обработки одного документа.
indexed / duplicate / skipped (мусор от источника: нет title или
ext_id) считаются отдельно от failed — иначе панель отладки показывала
бы тысячи «ошибок» там, где источник просто отдал неполные записи.
"""
self.processed += 1
if status == "indexed":
self.added += 1
elif status == "duplicate":
self.duplicates += 1
elif status == "skipped":
self.skipped += 1
else:
self.failed += 1
# ── журнал ────────────────────────────────────────────────────────────────
def log(self, level: str, msg: str) -> None:
"""Добавить запись журнала, храня только хвост последних log_limit строк."""
self.entries.append({
"ts": datetime.now(UTC).isoformat(timespec="seconds"),
"elapsed": round(self.elapsed, 1),
"level": level,
"msg": msg[:500],
})
if len(self.entries) > self.log_limit:
del self.entries[: len(self.entries) - self.log_limit]
# ── выгрузка ──────────────────────────────────────────────────────────────
def snapshot(self) -> dict[str, Any]:
"""Поля для UPDATE parse_runs."""
return {
"stage": self.stage,
"target": self.target,
"fetched": self.fetched,
"processed": self.processed,
"added": self.added,
"duplicates": self.duplicates,
"skipped": self.skipped,
"failed": self.failed,
"log": list(self.entries),
}

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any from typing import Any
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.algorithms.minhash import add_to_lsh, find_similar from app.algorithms.minhash import add_to_lsh, find_similar
@@ -517,19 +517,83 @@ def _stage_work(
logger.warning(f"Не удалось добавить работу {task_id!r} в отстойник: {e}") logger.warning(f"Не удалось добавить работу {task_id!r} в отстойник: {e}")
@celery_app.task(name="index.run_parser") def _parser_for(source_type: str, cfg: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
def run_parser(source_id: int) -> dict[str, Any]: """Инстанс парсера и совместимые с его fetch() аргументы по типу источника."""
limit = cfg["limit"]
query = cfg.get("query") or ""
if source_type == "openalex":
from openalex import OpenAlexParser as P
kwargs = {
"query": query,
"limit": limit,
"lang": cfg.get("lang"),
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
"open_access_only": settings.INGEST_OPEN_ACCESS_ONLY,
}
elif source_type == "cyberleninka":
from cyberleninka import CyberLeninkaParser as P
kwargs = {"query": query, "limit": limit}
elif source_type == "arxiv":
from arxiv import ArxivParser as P
kwargs = {"query": query, "limit": limit, "year_from": cfg.get("year_from")}
elif source_type == "pmc":
from pmc import PMCParser as P
kwargs = {
"query": query,
"limit": limit,
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
else:
raise ValueError(f"неизвестный тип источника: {source_type}")
return P(), kwargs
def _write_run(run_id: int, **fields: Any) -> bool:
"""Записать прогресс прогона и вернуть True, если запрошена отмена.
Отмена кооперативная: админка ставит cancel_requested, воркер узнаёт о ней
на ближайшем тике прогресса и останавливается сам. Так прогон завершается
с осмысленным статусом и не оставляет источник висеть в running (что было
бы при жёстком revoke уже начатого таска).
"""
from app.models import ParseRun
with db_session() as session:
row = session.execute(
update(ParseRun)
.where(ParseRun.id == run_id)
.values(heartbeat_at=datetime.now(UTC), **fields)
.returning(ParseRun.cancel_requested)
).first()
return bool(row and row[0])
@celery_app.task(name="index.run_parser", bind=True)
def run_parser(self, source_id: int, run_id: int | None = None) -> dict[str, Any]:
"""Запустить парсинг источника и наполнить базу документов. """Запустить парсинг источника и наполнить базу документов.
Переиспользует парсеры из scripts/parsers (BaseParser.run) и Переиспользует парсеры из scripts/parsers и задачу add_document для
задачу add_document для каждого полученного документа. каждого полученного документа. Ход заливки пишется в parse_runs (шкала
загрузки и журнал в админке); прогон можно отменить из админки и он сам
закругляется по бюджету времени, не доводя воркер до падения по
consumer_timeout RabbitMQ (см. app/progress.py).
Args:
source_id: ID источника в parse_sources
run_id: ID заранее созданной строки parse_runs (её создаёт админка,
чтобы прогон был виден в очереди ещё до старта). Без него строка
создаётся здесь — для запусков из скриптов.
""" """
import sys import sys
from datetime import datetime
from app.models import ParseSource from app.models import ParseRun, ParseSource
from app.progress import RunProgress
# Парсеры лежат в /parsers (скопированы в образ) # Парсеры лежат в /parsers (bind-mount scripts/parsers, см. compose)
if "/parsers" not in sys.path: if "/parsers" not in sys.path:
sys.path.insert(0, "/parsers") sys.path.insert(0, "/parsers")
@@ -546,47 +610,54 @@ def run_parser(source_id: int) -> dict[str, Any]:
"limit": src.limit, "limit": src.limit,
} }
src.last_status = "running" src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.now(UTC)
if run_id is None:
run = ParseRun(source_id=source_id, status="running", stage="fetch")
session.add(run)
session.flush()
run_id = run.id
src.last_run_id = run_id
session.commit() session.commit()
added = 0 prog = RunProgress(target=cfg["limit"], budget_s=settings.PARSER_TIME_BUDGET_S)
error_msg = None prog.stage = "fetch"
try: prog.log("info", f"старт: {cfg['source_type']} q={cfg.get('query') or '—'} limit={cfg['limit']}")
# Выбрать парсер по типу и собрать совместимые с его fetch() аргументы _write_run(
stype = cfg["source_type"] run_id, status="running", celery_task_id=self.request.id, error=None, **prog.snapshot()
if stype == "openalex": )
from openalex import OpenAlexParser as P
fetch_kwargs = { cancelled = False
"query": cfg.get("query") or "", exhausted = False # остановлены бюджетом времени, а не концом выдачи
"limit": cfg["limit"], error_msg = None
"lang": cfg.get("lang"),
"year_from": cfg.get("year_from"), try:
"year_to": cfg.get("year_to"), parser, fetch_kwargs = _parser_for(cfg["source_type"], cfg)
"open_access_only": settings.INGEST_OPEN_ACCESS_ONLY,
} def on_fetch_progress(fetched: int) -> bool:
elif stype == "cyberleninka": """Тик прогресса выборки; False — парсеру пора остановиться."""
from cyberleninka import CyberLeninkaParser as P nonlocal cancelled, exhausted
fetch_kwargs = {"query": cfg.get("query") or "", "limit": cfg["limit"]} prog.count_fetched(fetched)
elif stype == "arxiv": if prog.over_budget:
from arxiv import ArxivParser as P exhausted = True
fetch_kwargs = { prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на выборке")
"query": cfg.get("query") or "", return False
"limit": cfg["limit"], if prog.should_flush():
"year_from": cfg.get("year_from"), if _write_run(run_id, **prog.snapshot()):
} cancelled = True
elif stype == "pmc": prog.log("warning", "отмена по запросу из админки")
from pmc import PMCParser as P return False
fetch_kwargs = { prog.mark_flushed()
"query": cfg.get("query") or "", return True
"limit": cfg["limit"],
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
else:
raise ValueError(f"неизвестный тип источника: {stype}")
parser = P()
# fetch+transform без записи в JSONL — работаем in-memory # fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs) raw_docs = parser.fetch(**fetch_kwargs, progress_cb=on_fetch_progress)
prog.stage = "index"
prog.count_fetched(len(raw_docs))
prog.log("info", f"получено {len(raw_docs)} документов, индексация")
_write_run(run_id, **prog.snapshot())
# Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu # Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu
# кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений, # кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений,
@@ -602,31 +673,135 @@ def run_parser(source_id: int) -> dict[str, Any]:
embed_batch.clear() embed_batch.clear()
for raw in raw_docs: for raw in raw_docs:
if not cancelled and prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на индексации")
if cancelled or exhausted:
break
try: try:
doc = parser.transform(raw) doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")): if not (doc and doc.get("title") and doc.get("ext_id")):
prog.count_result("skipped")
continue continue
result = add_document(doc, dispatch_embed=False) result = add_document(doc, dispatch_embed=False)
prog.count_result(result.get("status", "error"))
if result.get("status") == "indexed": if result.get("status") == "indexed":
added += 1
embed_batch.append(result["doc_id"]) embed_batch.append(result["doc_id"])
if len(embed_batch) >= batch_size: if len(embed_batch) >= batch_size:
_flush_embed() _flush_embed()
except Exception as e: except Exception as e:
prog.count_result("error")
prog.log("warning", f"документ пропущен: {e}")
logger.warning(f"run_parser: ошибка документа: {e}") logger.warning(f"run_parser: ошибка документа: {e}")
if prog.should_flush():
cancelled = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
if cancelled:
prog.log("warning", "отмена по запросу из админки")
_flush_embed() _flush_embed()
except Exception as e: except Exception as e:
error_msg = str(e)[:500] error_msg = str(e)[:500]
prog.log("error", f"прогон упал: {error_msg}")
logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True) logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True)
if error_msg:
status = "error"
elif cancelled:
status = "cancelled"
elif exhausted:
status = "partial"
else:
status = "done"
prog.stage = "finished"
prog.log(
"info" if status in ("done", "partial") else "warning",
f"итог: {status}, добавлено {prog.added}, дублей {prog.duplicates}, "
f"ошибок {prog.failed}, за {prog.elapsed:.0f}с",
)
_write_run(
run_id,
status=status,
error=error_msg,
finished_at=datetime.now(UTC),
**prog.snapshot(),
)
with db_session() as session: with db_session() as session:
src = session.get(ParseSource, source_id) src = session.get(ParseSource, source_id)
if src: if src:
src.last_status = "error" if error_msg else "done" src.last_status = status
src.last_error = error_msg src.last_error = error_msg
src.docs_added = (src.docs_added or 0) + added src.docs_added = (src.docs_added or 0) + prog.added
src.last_run_at = datetime.now(UTC) src.last_run_at = datetime.now(UTC)
session.commit() session.commit()
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg} return {
"status": status,
"run_id": run_id,
"added": prog.added,
"duplicates": prog.duplicates,
"failed": prog.failed,
"error": error_msg,
}
@celery_app.task(name="index.ingest_upload", bind=True, max_retries=2, default_retry_delay=60)
def ingest_upload(
self,
minio_key: str,
filename: str,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Добавить загруженный админом файл в базу документов (корпус для сравнения).
Это не проверка плагиата: файл сразу становится источником, с которым
сравниваются работы студентов. Текст извлекается тем же кодом, что и в
extract_and_check, дальше — обычный add_document (дедуп, fingerprints,
LSH, Elasticsearch, эмбеддинги).
"""
meta = meta or {}
try:
minio = get_minio()
response = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
file_data = response.read()
response.close()
response.release_conn()
ext = Path(filename).suffix.lower()
if ext == ".pdf":
text = extract_text_from_pdf(file_data)
elif ext == ".docx":
text = extract_text_from_docx(file_data)
else:
text = extract_text_from_txt(file_data)
if not text.strip():
raise ValueError("не удалось извлечь текст")
except ValueError as exc:
logger.warning(f"ingest_upload {minio_key}: {exc}")
return {"status": "failed", "minio_key": minio_key, "error": str(exc)}
except Exception as exc:
logger.error(f"ingest_upload {minio_key}: {exc}", exc_info=True)
raise self.retry(exc=exc, countdown=60) from exc
doc_data = {
"source": meta.get("source") or "manual_upload",
# Ключ MinIO уникален (UUID в имени) — годится как ext_id для дедупа
"ext_id": f"upload:{minio_key}",
"title": meta.get("title") or Path(filename).stem,
"authors": meta.get("authors") or [],
"year": meta.get("year"),
"lang": meta.get("lang"),
"abstract": text[:2000],
"full_text": text,
"minio_key": minio_key,
}
result = add_document(doc_data)
logger.info(
f"ingest_upload: {filename!r} → {result.get('status')} "
f"(doc_id={result.get('doc_id')}, {len(text)} симв.)"
)
return {"status": result.get("status"), "doc_id": result.get("doc_id"), "filename": filename}

View File

@@ -0,0 +1,104 @@
"""Юнит-тесты счётчиков прогона парсинга (app.progress) — без БД и Celery."""
from app.progress import RunProgress
class FakeClock:
"""Управляемое время: тесты бюджета не должны ничего ждать по-настоящему."""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def test_counts_split_by_result_status():
prog = RunProgress(target=10, clock=FakeClock())
for status in ("indexed", "indexed", "duplicate", "skipped", "boom"):
prog.count_result(status)
assert prog.processed == 5
assert prog.added == 2
assert prog.duplicates == 1
# Мусор от источника не должен смешиваться с реальными ошибками:
# иначе панель отладки показывает ошибки там, где их нет
assert prog.skipped == 1
assert prog.failed == 1
def test_over_budget_only_after_deadline():
clock = FakeClock()
prog = RunProgress(target=100, budget_s=1500, clock=clock)
assert not prog.over_budget
clock.advance(1499)
assert not prog.over_budget
clock.advance(2)
assert prog.over_budget
def test_flush_is_throttled_by_interval():
clock = FakeClock()
prog = RunProgress(target=100, flush_interval_s=2.0, clock=clock)
assert not prog.should_flush()
clock.advance(2.5)
assert prog.should_flush()
prog.mark_flushed()
assert not prog.should_flush()
clock.advance(2.5)
assert prog.should_flush()
def test_log_keeps_only_tail():
prog = RunProgress(target=1, log_limit=3, clock=FakeClock())
for i in range(10):
prog.log("info", f"строка {i}")
assert len(prog.entries) == 3
assert [e["msg"] for e in prog.entries] == ["строка 7", "строка 8", "строка 9"]
def test_log_entry_shape():
clock = FakeClock()
prog = RunProgress(target=1, clock=clock)
clock.advance(12.34)
prog.log("warning", "x" * 900)
entry = prog.entries[0]
assert entry["level"] == "warning"
assert entry["elapsed"] == 12.3
# Длинные сообщения режем: журнал целиком лежит в одной JSON-колонке
assert len(entry["msg"]) == 500
assert entry["ts"]
def test_snapshot_carries_all_counters():
prog = RunProgress(target=42, clock=FakeClock())
prog.stage = "index"
prog.count_fetched(30)
prog.count_result("indexed")
prog.log("info", "поехали")
snap = prog.snapshot()
assert snap["stage"] == "index"
assert snap["target"] == 42
assert snap["fetched"] == 30
assert snap["processed"] == 1
assert snap["added"] == 1
assert snap["log"][0]["msg"] == "поехали"
def test_snapshot_log_is_detached_copy():
"""Снимок уходит в БД как есть — последующие записи не должны его менять."""
prog = RunProgress(target=1, clock=FakeClock())
prog.log("info", "первая")
snap = prog.snapshot()
prog.log("info", "вторая")
assert len(snap["log"]) == 1