Files
anti-plagiarism/services/api/app/models/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

141 lines
7.8 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.
"""Модели для админ-панели: источники парсинга, прогоны, отстойник, сессии админа."""
from datetime import datetime
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ParseSource(Base):
"""Источник парсинга для наполнения базы документов.
Админ задаёт тип источника и параметры запроса; задача index.run_parser
запускает парсинг в фоне и наполняет базу.
"""
__tablename__ = "parse_sources"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Тип парсера: openalex / cyberleninka / arxiv
source_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Человекочитаемое имя
name: Mapped[str] = mapped_column(String(255), nullable=False)
query: Mapped[str | None] = mapped_column(nullable=True)
lang: Mapped[str | None] = mapped_column(String(10), nullable=True)
year_from: Mapped[int | None] = mapped_column(nullable=True)
year_to: Mapped[int | None] = mapped_column(nullable=True)
limit: Mapped[int] = mapped_column(default=1000)
enabled: Mapped[bool] = mapped_column(default=True)
# Статус последнего запуска: idle / running / done / error
last_status: Mapped[str] = mapped_column(String(20), default="idle")
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)
# Постановка в очередь (строку создаёт API) и реальный старт выполнения:
# между ними при массовом запуске проходят часы, мешать их нельзя
started_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
run_started_at: Mapped[datetime | None] = mapped_column(nullable=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):
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.
После одобрения работа добавляется в базу документов (index.add_document),
после отклонения — помечается rejected.
"""
__tablename__ = "staged_works"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
# Внутренний UUID задачи проверки (Task.id)
task_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
filename: Mapped[str | None] = mapped_column(nullable=True)
# Ключ исходного файла и извлечённого текста в MinIO
minio_key: Mapped[str | None] = mapped_column(nullable=True)
text_key: Mapped[str | None] = mapped_column(nullable=True)
title: Mapped[str | None] = mapped_column(nullable=True)
authors: Mapped[list] = mapped_column(JSON, default=list)
year: Mapped[int | None] = mapped_column(nullable=True)
lang: Mapped[str | None] = mapped_column(String(10), nullable=True)
word_count: Mapped[int | None] = mapped_column(nullable=True)
# pending / approved / rejected
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
# ID документа в базе после одобрения
document_id: Mapped[int | None] = mapped_column(nullable=True)
reviewed_by: Mapped[int | None] = mapped_column(nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
def __repr__(self) -> str:
return f"<StagedWork id={self.id} status={self.status!r} title={(self.title or '')[:30]!r}>"
class AdminSession(Base):
"""Секретный код доступа к админке (часть ссылки /admin/<code>).
Доступ проверяется по двум факторам: JWT админа (is_admin) И валидный код.
"""
__tablename__ = "admin_sessions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
def __repr__(self) -> str:
return f"<AdminSession id={self.id} user_id={self.user_id}>"