feat: админ-панель, лицензия, тестовый стек на распределённой инфраструктуре

Админ-панель (доступ: is_admin + секретный код сессии в /admin/<code>):
- разделы: дашборд (здоровье сервисов, статистика), клиенты, работы,
  база документов, хранилище MinIO, источники парсинга, отстойник работ
- backend: модели ParseSource/StagedWork/AdminSession, миграция 003,
  core/admin (авторизация), роутер api/admin
- frontend: AdminLayout + 7 вкладок, adminApi, вход из кабинета
- Celery index.run_parser (фоновый парсинг), автосбор проверенных работ
  в отстойник с ручным одобрением → индексация в базу

Исправления:
- openalex parser: тип article (не journal-article), убран is_oa-фильтр
- winnowing: приведение xxh64 к signed int64 (фикс bigint out of range)
- bcrypt пин 4.0.1 (совместимость с passlib 1.7.4)
- alembic: prepend_sys_path + asyncpg-драйвер
- frontend: контракт Task (public_id вместо id), react-dropzone

Инфраструктура:
- docker-compose.test.yml: api+frontend+воркеры локально, БД/кэш/хранилище/
  брокер/LLM — на удалённых серверах через .env
- .gitignore: исправлено ошибочное игнорирование кода services/*/app/models/
- LICENSE: проприетарная

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-30 20:45:53 +05:00
parent 9ee5ccdc3f
commit 9894fa9320
50 changed files with 5680 additions and 58 deletions

View File

@@ -31,9 +31,12 @@ def hash_ngram(ngram: str) -> int:
ngram: n-грамма для хэширования
Returns:
64-битный хэш
Знаковый 64-битный хэш (умещается в PostgreSQL BIGINT)
"""
return xxhash.xxh64(ngram.encode("utf-8")).intdigest()
# xxh64 даёт unsigned 64-bit (до 2^64-1), а PostgreSQL BIGINT — signed
# (макс 2^63-1). Приводим к диапазону signed int64.
unsigned = xxhash.xxh64(ngram.encode("utf-8")).intdigest()
return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned
def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:

View File

@@ -10,6 +10,7 @@ class Settings(BaseSettings):
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# PostgreSQL
@@ -30,6 +31,7 @@ class Settings(BaseSettings):
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "changeme"
MINIO_BUCKET_DOCS: str = "documents"
MINIO_BUCKET_STAGING: str = "staging"
# Elasticsearch
ELASTICSEARCH_URL: str = "http://elasticsearch:9200"

View File

@@ -0,0 +1,103 @@
"""Минимальные модели для индексер-воркера."""
from datetime import datetime
from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255))
name: Mapped[str] = mapped_column(String(255))
plan: Mapped[str] = mapped_column(String(20), default="free")
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
type: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20), default="queued")
celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
input_data: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
queue_position: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime | None] = mapped_column(nullable=True)
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[str] = mapped_column(String(50))
ext_id: Mapped[str] = mapped_column(String(255), unique=True)
doi: Mapped[str | None] = mapped_column(String(255), nullable=True)
title: Mapped[str] = mapped_column(Text)
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)
journal: Mapped[str | None] = mapped_column(Text, nullable=True)
volume: Mapped[str | None] = mapped_column(String(50), nullable=True)
issue: Mapped[str | None] = mapped_column(String(50), nullable=True)
pages: Mapped[str | None] = mapped_column(String(50), nullable=True)
abstract: Mapped[str | None] = mapped_column(Text, nullable=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
minio_key: Mapped[str | None] = mapped_column(Text, nullable=True)
faiss_id: Mapped[int | None] = mapped_column(nullable=True)
indexed_at: Mapped[datetime] = mapped_column(server_default=func.now())
class Fingerprint(Base):
__tablename__ = "fingerprints"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
doc_id: Mapped[int] = mapped_column(ForeignKey("documents.id"))
hash_value: Mapped[int] = mapped_column(BigInteger)
position: Mapped[int] = mapped_column()
class ParseSource(Base):
__tablename__ = "parse_sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_type: Mapped[str] = mapped_column(String(50))
name: Mapped[str] = mapped_column(String(255))
query: Mapped[str | None] = mapped_column(Text, 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)
last_status: Mapped[str] = mapped_column(String(20), default="idle")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
docs_added: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
class StagedWork(Base):
__tablename__ = "staged_works"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(nullable=True)
task_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
filename: Mapped[str | None] = mapped_column(Text, nullable=True)
minio_key: Mapped[str | None] = mapped_column(Text, nullable=True)
text_key: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str | None] = mapped_column(Text, 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)
status: Mapped[str] = mapped_column(String(20), default="pending")
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())
__all__ = ["Base", "User", "Task", "Document", "Fingerprint", "ParseSource", "StagedWork"]

View File

@@ -123,7 +123,13 @@ def extract_and_check(
if not text.strip():
raise ValueError("Не удалось извлечь текст из документа")
logger.info(f"Текст извлечён: {len(text)} символов, {len(text.split())} слов")
word_count = len(text.split())
logger.info(f"Текст извлечён: {len(text)} символов, {word_count} слов")
# ──── Автосбор в отстойник (буфер для пополнения базы) ────────────────
# Сохраняем извлечённый текст и метаданные для последующего ручного
# одобрения админом. Не дублируем в базу источников до решения.
_stage_work(task_id, minio_key, filename, text, word_count)
# Разбить на фрагменты
fragments = _split_into_fragments(
@@ -327,3 +333,145 @@ def add_document(doc_data: dict[str, Any]) -> dict[str, Any]:
)
return {"status": "indexed", "doc_id": doc_id}
def _stage_work(
task_id: str,
minio_key: str,
filename: str,
text: str,
word_count: int,
) -> None:
"""Сохранить проверенную работу в отстойник (StagedWork) + текст в MinIO.
Идемпотентно по task_id: повторный вызов (например при requeue) не дублирует.
Ошибки логируются, но не валят основную проверку плагиата.
"""
try:
from app.models import StagedWork
# Сохранить извлечённый текст в bucket staging
text_key = f"text/{task_id}.txt"
minio = get_minio()
bucket = settings.MINIO_BUCKET_STAGING
if not minio.bucket_exists(bucket):
minio.make_bucket(bucket)
data = text.encode("utf-8")
minio.put_object(
bucket, text_key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8",
)
with db_session() as session:
existing = session.execute(
select(StagedWork).where(StagedWork.task_id == task_id)
).scalar_one_or_none()
if existing:
return
# Достать user_id из задачи
from app.models import Task
task = session.get(Task, task_id)
user_id = task.user_id if task else None
session.add(StagedWork(
user_id=user_id,
task_id=task_id,
filename=filename,
minio_key=minio_key,
text_key=text_key,
title=Path(filename).stem if filename else None,
word_count=word_count,
status="pending",
))
session.commit()
logger.info(f"Работа задачи {task_id!r} добавлена в отстойник")
except Exception as e:
logger.warning(f"Не удалось добавить работу {task_id!r} в отстойник: {e}")
@celery_app.task(name="index.run_parser")
def run_parser(source_id: int) -> dict[str, Any]:
"""Запустить парсинг источника и наполнить базу документов.
Переиспользует парсеры из scripts/parsers (BaseParser.run) и
задачу add_document для каждого полученного документа.
"""
import sys
from datetime import datetime, timezone
from app.models import ParseSource
# Парсеры лежат в /parsers (скопированы в образ)
if "/parsers" not in sys.path:
sys.path.insert(0, "/parsers")
with db_session() as session:
src = session.get(ParseSource, source_id)
if src is None:
return {"status": "error", "reason": "источник не найден"}
cfg = {
"source_type": src.source_type,
"query": src.query,
"lang": src.lang,
"year_from": src.year_from,
"year_to": src.year_to,
"limit": src.limit,
}
src.last_status = "running"
session.commit()
added = 0
error_msg = None
try:
# Выбрать парсер по типу и собрать совместимые с его fetch() аргументы
stype = cfg["source_type"]
if stype == "openalex":
from openalex import OpenAlexParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
"lang": cfg.get("lang"),
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
elif stype == "cyberleninka":
from cyberleninka import CyberLeninkaParser as P
fetch_kwargs = {"query": cfg.get("query") or "", "limit": cfg["limit"]}
elif stype == "arxiv":
from arxiv import ArxivParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
"year_from": cfg.get("year_from"),
}
else:
raise ValueError(f"неизвестный тип источника: {stype}")
parser = P()
# fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs)
for raw in raw_docs:
try:
doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")):
continue
result = add_document(doc)
if result.get("status") == "indexed":
added += 1
except Exception as e:
logger.warning(f"run_parser: ошибка документа: {e}")
except Exception as e:
error_msg = str(e)[:500]
logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True)
with db_session() as session:
src = session.get(ParseSource, source_id)
if src:
src.last_status = "error" if error_msg else "done"
src.last_error = error_msg
src.docs_added = (src.docs_added or 0) + added
src.last_run_at = datetime.now(timezone.utc)
session.commit()
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg}