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

@@ -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}