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

144 lines
8.1 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)
# Позиция продолжения для массовых источников: номер статьи в дампе
# Википедии или токен страницы бакета PMC
resume_token: Mapped[str | None] = mapped_column(Text, 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}>"