Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона» в 2.8 часа там, где парсинг занимал 50 секунд: started_at пишется в момент постановки в очередь, а очередь из 173 источников разбирается часами. Теперь момент реального старта пишется отдельно (run_started_at, миграция 006), а схема отдаёт обе величины — сколько ждал очереди и сколько работал. Плюс scripts/ops/reembed_missing.py: документ попадает в корпус сразу, а вектор для L3 считает отдельная задача; когда worker-gpu или Ollama недоступны, эти задачи теряются и документ остаётся невидимым для семантического поиска. Скрипт находит faiss_id IS NULL и переотправляет пачками (dry-run по умолчанию) — сейчас таких 23 101 из 177 147. Документация: актуальные цифры корпуса, дубли при повторном прогоне, лимит OpenAlex, и главное — гипервизор .254 зафиксирован в DR-HA как самая широкая единая точка отказа (брокер, эмбеддинги, секреты и прокси на одном железе; подтверждено аварией 28.08). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.4 KiB
Python
214 lines
7.4 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")
|
||
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")
|
||
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]
|