Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на 20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery. Теперь это обычные типы источника — wikipedia_ru и pmc_bulk: - заводятся и запускаются из админки, как OpenAlex или КиберЛенинка; - показывают ту же шкалу, журнал и кнопку остановки; - падение воркера больше не теряет прогресс: позиция продолжения хранится в parse_sources.resume_token (номер статьи в дампе / токен страницы бакета), повторный запуск берёт следующую порцию; - укладываются в бюджет времени таска — заливка идёт порциями, а не одним многосуточным процессом. Чего не хватало конвейеру для миллионов и что добавлено: - парсеры отдают генератор, а не список: 2 млн статей в память не влезают; - app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против 6.7 тыс. построчно) с переподключением к базе при обрыве; - эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py). scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер. Проверено на проде: оба парсера отдают документы, прогон через run_parser завершается штатно, позиция продолжения сдвигается (300 → 600), повторный запуск продолжает с неё. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
"""Pydantic-схемы для админ-панели."""
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel, Field, computed_field
|
||
|
||
from app.core.progress import run_percent
|
||
|
||
|
||
# ─── Сессия доступа ───────────────────────────────────────────────────────────
|
||
class AdminSessionResponse(BaseModel):
|
||
code: str
|
||
url: str
|
||
expires_at: datetime
|
||
|
||
|
||
# ─── Клиенты ──────────────────────────────────────────────────────────────────
|
||
class AdminUserResponse(BaseModel):
|
||
id: int
|
||
email: str
|
||
name: str
|
||
plan: str
|
||
is_verified: bool
|
||
is_admin: bool
|
||
created_at: datetime
|
||
tasks_count: int = 0
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class AdminUserUpdate(BaseModel):
|
||
plan: str | None = None
|
||
is_verified: bool | None = None
|
||
is_admin: bool | None = None
|
||
|
||
|
||
# ─── Работы ─────────────────────────────────────────────────────────────────
|
||
class AdminTaskResponse(BaseModel):
|
||
public_id: str
|
||
user_id: int
|
||
type: str
|
||
status: str
|
||
error: str | None = None
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ─── Документы базы ───────────────────────────────────────────────────────────
|
||
class AdminDocumentResponse(BaseModel):
|
||
id: int
|
||
source: str
|
||
ext_id: str
|
||
title: str
|
||
authors: list = Field(default_factory=list)
|
||
year: int | None = None
|
||
lang: str | None = None
|
||
doi: str | None = None
|
||
url: str | None = None
|
||
indexed_at: datetime
|
||
|
||
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 — постановка в очередь, run_started_at — реальный старт работы
|
||
started_at: datetime
|
||
run_started_at: datetime | None = None
|
||
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)
|
||
|
||
@computed_field # type: ignore[prop-decorator]
|
||
@property
|
||
def queued_s(self) -> float | None:
|
||
"""Сколько прогон ждал своей очереди, сек."""
|
||
if self.run_started_at is None:
|
||
return None
|
||
return round((self.run_started_at - self.started_at).total_seconds(), 1)
|
||
|
||
@computed_field # type: ignore[prop-decorator]
|
||
@property
|
||
def duration_s(self) -> float | None:
|
||
"""Сколько прогон реально работал, сек (None — ещё идёт или не начинался).
|
||
|
||
Прогоны до появления run_started_at (миграция 006) остаются без
|
||
длительности: у них известен только момент постановки в очередь.
|
||
"""
|
||
if self.run_started_at is None or self.finished_at is None:
|
||
return None
|
||
return round((self.finished_at - self.run_started_at).total_seconds(), 1)
|
||
|
||
|
||
class ParseRunDetail(ParseRunResponse):
|
||
log: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
# ─── Источники парсинга ───────────────────────────────────────────────────────
|
||
class ParseSourceCreate(BaseModel):
|
||
source_type: str = Field(
|
||
description="openalex / cyberleninka / arxiv / pmc / wikipedia_ru / pmc_bulk"
|
||
)
|
||
name: str = Field(min_length=1, max_length=255)
|
||
query: str | None = None
|
||
lang: str | None = None
|
||
year_from: int | None = None
|
||
year_to: int | None = None
|
||
limit: int = Field(default=1000, ge=1, le=100000)
|
||
enabled: bool = True
|
||
|
||
|
||
class ParseSourceUpdate(BaseModel):
|
||
name: str | None = None
|
||
query: str | None = None
|
||
lang: str | None = None
|
||
year_from: int | None = None
|
||
year_to: int | None = None
|
||
limit: int | None = Field(default=None, ge=1, le=100000)
|
||
enabled: bool | None = None
|
||
|
||
|
||
class ParseSourceResponse(BaseModel):
|
||
id: int
|
||
source_type: str
|
||
name: str
|
||
query: str | None = None
|
||
lang: str | None = None
|
||
year_from: int | None = None
|
||
year_to: int | None = None
|
||
limit: int
|
||
enabled: bool
|
||
last_status: str
|
||
last_error: str | None = None
|
||
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 / wikipedia_ru / pmc_bulk"
|
||
)
|
||
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
|
||
user_id: int | None = None
|
||
task_id: str | None = None
|
||
filename: str | None = None
|
||
title: str | None = None
|
||
authors: list = Field(default_factory=list)
|
||
year: int | None = None
|
||
lang: str | None = None
|
||
word_count: int | None = None
|
||
status: str
|
||
document_id: int | None = None
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class StagedWorkDetail(StagedWorkResponse):
|
||
text_preview: str | None = None
|
||
|
||
|
||
# ─── Дашборд ──────────────────────────────────────────────────────────────────
|
||
class ServiceHealth(BaseModel):
|
||
name: str
|
||
ok: bool
|
||
detail: str | None = None
|
||
|
||
|
||
class AdminStats(BaseModel):
|
||
users_total: int
|
||
tasks_by_status: dict[str, int]
|
||
documents_total: int
|
||
documents_by_source: dict[str, int]
|
||
staging_pending: int
|
||
storage: dict[str, Any]
|
||
queues: dict[str, Any]
|