Files
anti-plagiarism/services/api/app/schemas/admin.py
jze9 99bf14fe6a feat(admin): честная длительность прогона + добор эмбеддингов
Разбор итогов массовой заливки показал в отладке «среднюю длительность прогона»
в 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>
2026-08-28 16:51:25 +05:00

214 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]