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

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

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

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

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

View File

@@ -5,12 +5,17 @@
"""
import contextlib
import io
import logging
from datetime import datetime
import os
import uuid
from datetime import datetime, timedelta
from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, select
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
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.redis_client import get_redis
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.task import Task
from app.models.user import User
@@ -30,6 +35,9 @@ from app.schemas.admin import (
AdminTaskResponse,
AdminUserResponse,
AdminUserUpdate,
ParseRunDetail,
ParseRunResponse,
ParseSourceBulkCreate,
ParseSourceCreate,
ParseSourceResponse,
ParseSourceUpdate,
@@ -42,6 +50,11 @@ logger = logging.getLogger(__name__)
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])
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]}
# Длины очередей через RabbitMQ management API (если доступно)
queues: dict = {}
try:
rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0]
rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0]
rmq_host = settings.RABBITMQ_URL.split("@")[1].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:
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]}
rmq = await _rabbitmq_queues()
queues: dict = (
{"error": rmq["error"]}
if "error" in rmq
else {name: q.get("messages", 0) for name, q in rmq.items()}
)
return AdminStats(
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])
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()
return [ParseSourceResponse.model_validate(s) for s in rows]
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)
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="Недопустимый тип источника")
src = ParseSource(**data.model_dump())
db.add(src)
@@ -440,6 +512,44 @@ async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_
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)
async def update_source(
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()
@router.post("/sources/{source_id}/run")
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
@router.post("/sources/run-all")
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()
if src is None:
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="Источник уже парсится")
src.last_status = "running"
src.last_error = None
src.last_run_at = datetime.utcnow()
run = await _start_run(src, db)
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()
celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index")
return {"status": "started", "source_id": source_id}
return {"cancelled": len(runs), "source_id": source_id}
@router.get("/sources/{source_id}/runs", response_model=list[ParseRunResponse])
async def list_source_runs(
source_id: int,
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
) -> list[ParseRunResponse]:
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.source_id == source_id)
.order_by(ParseRun.started_at.desc())
.limit(limit)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/active", response_model=list[ParseRunResponse])
async def list_active_runs(db: AsyncSession = Depends(get_db)) -> list[ParseRunResponse]:
"""Все прогоны в работе — для панели отладки и общей шкалы заливки."""
rows = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
.order_by(ParseRun.started_at)
)
).scalars().all()
return [ParseRunResponse.model_validate(r) for r in rows]
@router.get("/runs/{run_id}", response_model=ParseRunDetail)
async def get_run(run_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunDetail:
run = await db.get(ParseRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Прогон не найден")
detail = ParseRunDetail.model_validate(run)
detail.log = run.log or []
return detail
# ═══════════════════════════════════════════════════════════════════════════════
# ЗАГРУЗКА РАБОТ В КОРПУС
# ═══════════════════════════════════════════════════════════════════════════════
UPLOAD_EXTENSIONS = {".pdf", ".docx", ".txt"}
UPLOAD_MAX_BYTES = 100 * 1024 * 1024
UPLOAD_CONTENT_TYPES = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
}
@router.post("/documents/upload", status_code=202)
async def upload_documents(files: list[UploadFile]) -> dict:
"""Залить готовые работы прямо в базу сравнения (минуя проверку и отстойник).
Отличие от пользовательской загрузки (/documents/check): там файл проверяют
на плагиат, здесь он сам становится источником, с которым будут сравнивать.
"""
if not files:
raise HTTPException(status_code=400, detail="Файлы не переданы")
accepted: list[dict] = []
rejected: list[dict] = []
minio = get_minio()
for file in files:
name = file.filename or "без имени"
ext = Path(name).suffix.lower()
if ext not in UPLOAD_EXTENSIONS:
rejected.append({"filename": name, "reason": f"формат {ext or '—'} не поддержан"})
continue
data = await file.read()
if not data:
rejected.append({"filename": name, "reason": "пустой файл"})
continue
if len(data) > UPLOAD_MAX_BYTES:
rejected.append({"filename": name, "reason": "больше 100 МБ"})
continue
minio_key = f"corpus-upload/{uuid.uuid4()}{ext}"
try:
minio.put_object(
bucket_name=settings.MINIO_BUCKET_DOCS,
object_name=minio_key,
data=io.BytesIO(data),
length=len(data),
content_type=UPLOAD_CONTENT_TYPES.get(ext, "application/octet-stream"),
)
except Exception as e:
logger.error("Загрузка %s в MinIO не удалась: %s", name, e)
rejected.append({"filename": name, "reason": "ошибка сохранения в хранилище"})
continue
celery_app.send_task(
"index.ingest_upload",
args=[minio_key, name],
kwargs={"meta": {"title": Path(name).stem}},
queue="queue.index",
)
accepted.append({"filename": name, "minio_key": minio_key, "bytes": len(data)})
logger.info("Админ залил в корпус: принято %d, отклонено %d", len(accepted), len(rejected))
return {"accepted": accepted, "rejected": rejected}
# ═══════════════════════════════════════════════════════════════════════════════
# ОТЛАДКА
# ═══════════════════════════════════════════════════════════════════════════════
def _celery_snapshot() -> dict:
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
try:
insp = celery_app.control.inspect(timeout=4)
ping = insp.ping() or {}
active = insp.active() or {}
reserved = insp.reserved() or {}
stats = insp.stats() or {}
except Exception as e:
return {"error": str(e)[:200], "workers": []}
workers = []
for name in sorted(set(ping) | set(stats)):
wstats = stats.get(name, {})
workers.append({
"name": name,
"concurrency": (wstats.get("pool") or {}).get("max-concurrency"),
"reserved": len(reserved.get(name, [])),
"active": [
{
"name": t.get("name"),
"id": t.get("id"),
# args целиком не отдаём: в них бывает текст работы целиком
"args": str(t.get("args"))[:120],
"started_ago_s": (
round(datetime.utcnow().timestamp() - t["time_start"])
if t.get("time_start") else None
),
}
for t in active.get(name, [])
],
})
return {"workers": workers}
@router.get("/debug")
async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
"""Полный срез состояния системы для отладки заливки и проверок.
Один запрос вместо похода по пяти вкладкам: кто из воркеров жив и что
именно сейчас крутит, что копится в очередях, как наполняется корпус,
какие прогоны идут и на чём падали последние.
"""
# Воркеры — блокирующий Celery inspect, в пул потоков, чтобы не вешать луп
celery_state = await run_in_threadpool(_celery_snapshot)
rmq = await _rabbitmq_queues()
queues = (
{"error": rmq["error"]}
if "error" in rmq
else {
name: {
"messages": q.get("messages", 0),
"unacked": q.get("messages_unacknowledged", 0),
"consumers": q.get("consumers", 0),
}
for name, q in sorted(rmq.items())
}
)
# ── Корпус ────────────────────────────────────────────────────────────────
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
docs_embedded = (
await db.execute(
select(func.count()).select_from(Document).where(Document.faiss_id.isnot(None))
)
).scalar_one()
# count(*) по fingerprints — это десятки миллионов строк и секунды ожидания;
# для панели отладки достаточно оценки планировщика
fingerprints_est = (
await db.execute(text("SELECT reltuples::bigint FROM pg_class WHERE relname='fingerprints'"))
).scalar() or 0
es_docs = None
try:
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get(f"{settings.ELASTICSEARCH_URL}/documents/_count")
if resp.status_code == 200:
es_docs = resp.json().get("count")
except Exception:
es_docs = None
# ── Источники и прогоны ───────────────────────────────────────────────────
src_rows = (
await db.execute(select(ParseSource.last_status, func.count()).group_by(ParseSource.last_status))
).all()
active_runs = (
await db.execute(
select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)).order_by(ParseRun.id)
)
).scalars().all()
recent_failed_runs = (
await db.execute(
select(ParseRun)
.where(ParseRun.status.in_(("error", "partial")))
.order_by(ParseRun.started_at.desc())
.limit(10)
)
).scalars().all()
# Зависший прогон: числится в работе, но воркер давно не отчитывался
stale_cutoff = datetime.utcnow() - timedelta(minutes=10)
stale_runs = [
r.id for r in active_runs
if r.status == "running" and (r.heartbeat_at is None or r.heartbeat_at < stale_cutoff)
]
# ── Задачи пользователей ──────────────────────────────────────────────────
day_ago = datetime.utcnow() - timedelta(hours=24)
tasks_24h = dict(
(
await db.execute(
select(Task.status, func.count()).where(Task.created_at >= day_ago).group_by(Task.status)
)
).all()
)
failed_tasks = (
await db.execute(
select(Task)
.where(Task.status == "failed")
.order_by(Task.created_at.desc())
.limit(10)
)
).scalars().all()
return {
"generated_at": datetime.utcnow().isoformat(timespec="seconds"),
"celery": celery_state,
"queues": queues,
"corpus": {
"documents": docs_total,
"documents_embedded": docs_embedded,
"documents_without_embedding": max(docs_total - docs_embedded, 0),
"fingerprints_estimate": int(fingerprints_est),
"elasticsearch_documents": es_docs,
},
"sources": {
"by_status": dict(src_rows),
"active_runs": [ParseRunResponse.model_validate(r).model_dump() for r in active_runs],
"stale_run_ids": stale_runs,
"recent_problem_runs": [
ParseRunResponse.model_validate(r).model_dump() for r in recent_failed_runs
],
},
"tasks_24h": tasks_24h,
"recent_failed_tasks": [
{
"public_id": t.public_id,
"type": t.type,
"error": (t.error or "")[:200],
"created_at": t.created_at,
}
for t in failed_tasks
],
# Ключи бэкендов задаются в .env (генерируется из Infisical) и влияют на
# поведение воркеров — при разборе «почему не так считает» нужны первыми
"config": {
"environment": settings.ENVIRONMENT,
"embed_backend": os.environ.get("EMBED_BACKEND", "ollama"),
"llm_backend": os.environ.get("LLM_BACKEND", "ollama"),
"vector_backend": os.environ.get("VECTOR_BACKEND", "faiss"),
"embed_model": os.environ.get("EMBED_MODEL", "—"),
"fetch_full_text": os.environ.get("FETCH_FULL_TEXT", "false"),
"auto_approve_submissions": os.environ.get("AUTO_APPROVE_SUBMISSIONS", "false"),
"parser_time_budget_s": os.environ.get("PARSER_TIME_BUDGET_S", "1500"),
"ollama_url": settings.OLLAMA_URL,
"elasticsearch_url": settings.ELASTICSEARCH_URL,
},
}
# ═══════════════════════════════════════════════════════════════════════════════

View File

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

View File

@@ -1,8 +1,8 @@
"""Модели для админ-панели: источники парсинга, отстойник работ, сессии админа."""
"""Модели для админ-панели: источники парсинга, прогоны, отстойник, сессии админа."""
from datetime import datetime
from sqlalchemy import JSON, ForeignKey, String, func
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -33,12 +33,57 @@ class ParseSource(Base):
last_error: Mapped[str | None] = mapped_column(nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
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())
def __repr__(self) -> str:
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):
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.

View File

@@ -3,7 +3,9 @@
from datetime import datetime
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}
# ─── Прогоны парсинга ─────────────────────────────────────────────────────────
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):
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)
query: str | None = None
lang: str | None = None
@@ -98,10 +133,25 @@ class ParseSourceResponse(BaseModel):
last_run_at: datetime | None = None
docs_added: int
created_at: datetime
# Последний (или текущий) прогон — источник данных для шкалы в списке
last_run: ParseRunResponse | None = None
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):
id: int