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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user