feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
Заливка корпуса была чёрным ящиком: у источника только 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:
10
README.md
10
README.md
@@ -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 |
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
@@ -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. Безопасность
|
||||||
|
|
||||||
|
|||||||
@@ -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 (доступа для этого пока не заводили).
|
||||||
|
|||||||
@@ -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 русских документов на первый заход.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 источника
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
68
scripts/parsers/tests/test_progress_cb.py
Normal file
68
scripts/parsers/tests/test_progress_cb.py
Normal 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
|
||||||
@@ -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) пройден"
|
||||||
|
|||||||
68
services/api/alembic/versions/005_parse_runs.py
Normal file
68
services/api/alembic/versions/005_parse_runs.py
Normal 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")
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
26
services/api/app/core/progress.py
Normal file
26
services/api/app/core/progress.py
Normal 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)
|
||||||
@@ -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):
|
||||||
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.
|
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
41
services/api/tests/test_progress.py
Normal file
41
services/api/tests/test_progress.py
Normal 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
|
||||||
@@ -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 }),
|
||||||
|
|||||||
47
services/frontend/src/components/ProgressBar.tsx
Normal file
47
services/frontend/src/components/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
255
services/frontend/src/pages/admin/Debug.tsx
Normal file
255
services/frontend/src/pages/admin/Debug.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +1,204 @@
|
|||||||
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">
|
||||||
|
<h2 className="font-semibold text-gray-800">Добавить источник</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setBulk({ ...bulk, open: !bulk.open })}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" /> {bulk.open ? 'обычное добавление' : 'пакетно (много тем)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!bulk.open ? (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
<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 })}
|
<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">
|
className="border border-gray-200 rounded-lg px-3 py-2 text-sm">
|
||||||
<option value="openalex">OpenAlex</option>
|
{SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
<option value="cyberleninka">КиберЛенинка</option>
|
|
||||||
<option value="arxiv">arXiv</option>
|
|
||||||
</select>
|
</select>
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Название"
|
<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" />
|
className="border border-gray-200 rounded-lg px-3 py-2 text-sm" />
|
||||||
@@ -74,6 +218,74 @@ export function Sources() {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> Добавить
|
<Plus className="w-4 h-4" /> Добавить
|
||||||
</button>
|
</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 = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" /> {upload.isPending ? 'Загрузка…' : 'Выбрать файлы'}
|
||||||
|
</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);
|
||||||
|
return (
|
||||||
|
<Fragment key={s.id}>
|
||||||
|
<tr className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<button onClick={() => setExpanded(expanded === s.id ? null : s.id)}
|
||||||
|
className="text-gray-400 hover:text-gray-600" title="Журнал прогона">
|
||||||
|
{expanded === s.id ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-800">
|
||||||
|
{s.name}
|
||||||
|
{s.query && <div className="text-xs text-gray-400 truncate max-w-[220px]">{s.query}</div>}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600">{s.source_type}</td>
|
<td className="px-4 py-3 text-gray-600">{s.source_type}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[160px] truncate">{s.query || '—'}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-600">{s.limit}</td>
|
<td className="px-4 py-3 text-gray-600">{s.limit}</td>
|
||||||
<td className="px-4 py-3">
|
<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>
|
{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>
|
||||||
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
|
<td className="px-4 py-3 text-gray-600">{s.docs_added}</td>
|
||||||
<td className="px-4 py-3 flex gap-1">
|
<td className="px-4 py-3">
|
||||||
<button onClick={() => run.mutate(s.id)} disabled={s.last_status === 'running'} title="Запустить"
|
<div className="flex gap-1">
|
||||||
className="p-1.5 text-gray-400 hover:text-emerald-600 rounded disabled:opacity-40"><Play className="w-4 h-4" /></button>
|
{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="Удалить"
|
<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>
|
className="p-1.5 text-gray-400 hover:text-red-500 rounded"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ловят списывание у
|
||||||
# предыдущих потоков именно так). Выключено по умолчанию: без ручной
|
# предыдущих потоков именно так). Выключено по умолчанию: без ручной
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
124
services/worker-indexer/app/progress.py
Normal file
124
services/worker-indexer/app/progress.py
Normal 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),
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
104
services/worker-indexer/tests/test_progress.py
Normal file
104
services/worker-indexer/tests/test_progress.py
Normal 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
|
||||||
Reference in New Issue
Block a user