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:
@@ -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]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
103
services/worker-indexer/app/models/__init__.py
Normal file
103
services/worker-indexer/app/models/__init__.py
Normal 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"]
|
||||
@@ -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