Files
anti-plagiarism/services/api/app/schemas/admin.py
jze9 26de1b9de1
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s
refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
Заливку Википедии и 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>
2026-09-05 20:32:53 +05:00

218 lines
7.5 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 / 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]