feat: initial microservices project structure

Services:
- api: FastAPI gateway with JWT auth, async endpoints, WebSocket
- worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM
- worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction
- worker-notifier: SMTP email notifications
- worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting

Infrastructure:
- docker-compose.yml (production) + docker-compose.dev.yml (hot reload)
- Nginx reverse proxy + WebSocket support
- PostgreSQL 16 with Alembic migrations
- Elasticsearch 8 with Russian/English analyzers
- MinIO, RabbitMQ, Redis, Ollama

Frontend:
- React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5
- 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register

Scripts:
- Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View File

View File

@@ -0,0 +1,27 @@
"""Celery приложение GOST воркера."""
from celery import Celery
from app.config import settings
celery_app = Celery(
"worker_gost",
broker=settings.RABBITMQ_URL,
backend=settings.REDIS_URL,
include=["app.tasks.gost"],
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Europe/Moscow",
enable_utc=True,
task_track_started=True,
task_routes={
"gost.*": {"queue": "queue.gost"},
"notify.*": {"queue": "queue.notify"},
},
task_acks_late=True,
result_expires=86400,
)

View File

@@ -0,0 +1,33 @@
"""Конфигурация GOST воркера."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "antiplagiator"
POSTGRES_USER: str = "antiplagiator"
POSTGRES_PASSWORD: str = "changeme"
REDIS_URL: str = "redis://redis:6379/0"
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
ENVIRONMENT: str = "development"
DEBUG: bool = False
@property
def database_url_sync(self) -> str:
return (
f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
settings = Settings()

View File

@@ -0,0 +1,32 @@
"""Подключение к PostgreSQL для gost-воркера."""
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
engine = create_engine(
settings.database_url_sync,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
@contextmanager
def db_session() -> Generator[Session, None, None]:
"""Контекстный менеджер для сессии БД."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

View File

@@ -0,0 +1,57 @@
"""ГОСТ Р 7.0.5-2008 — Краткая библиографическая ссылка.
Используется для ссылок внутри текста: [Автор, год]
"""
class GOST705Formatter:
"""Форматтер кратких библиографических ссылок по ГОСТ Р 7.0.5-2008."""
def format_short(self, doc: dict) -> str:
"""
Форматировать краткую ссылку вида [Автор, год].
Args:
doc: Словарь с метаданными документа
Returns:
Форматированная краткая ссылка, например [Иванов, 2023]
"""
authors = doc.get("authors", [])
year = doc.get("year", "б. г.")
if authors:
first_author = authors[0]
last_name = first_author.get("last_name", "")
if last_name:
return f"[{last_name}, {year}]"
# Если нет авторов — используем первые слова названия
title = doc.get("title", "")
if title:
short_title = " ".join(title.split()[:3])
return f"[{short_title}..., {year}]"
return f"[{year}]"
def format_inline(self, doc: dict, page: str | None = None) -> str:
"""
Форматировать ссылку для включения в текст с опциональным номером страницы.
Args:
doc: Словарь с метаданными
page: Номер страницы (опционально)
Returns:
Например: [Иванов, 2023, с. 15] или [Иванов, 2023]
"""
base = self.format_short(doc)
if page:
# Вставить номер страницы перед закрывающей скобкой
return base[:-1] + f", с. {page}]"
return base
def format_short(doc: dict) -> str:
"""Удобная функция для быстрого форматирования."""
return GOST705Formatter().format_short(doc)

View File

@@ -0,0 +1,267 @@
"""ГОСТ 7.1-2003 — Библиографическая запись. Библиографическое описание.
Полная запись для списка литературы.
Формат для статьи в журнале:
Автор(ы). Название // Журнал. — Год. — Т. X, № X. — С. XX.
Формат для книги:
Автор(ы). Название. — Город : Издательство, Год. — X с.
Правила по ГОСТ 7.1-2003:
- До 3 авторов: перечислить всех
- 4+ авторов: первые 3 + "и др." (рус.) / "et al." (англ.)
- Разделитель смысловых частей: ""
- Разделитель авторов: ", "
- Место публикации через "/"
"""
import re
def _format_author(author: dict) -> str:
"""
Форматировать одного автора.
Args:
author: dict с полями last_name, first_name, initials (опционально)
Returns:
Строка вида "Иванов И. И." или "Иванов И."
"""
last_name = author.get("last_name", "").strip()
initials = author.get("initials", "").strip()
first_name = author.get("first_name", "").strip()
if not last_name:
return ""
if initials:
# Нормализовать инициалы: обеспечить точки
if not initials.endswith("."):
initials += "."
return f"{last_name} {initials}"
elif first_name:
# Извлечь инициалы из полного имени
parts = first_name.split()
inits = "".join(p[0].upper() + "." for p in parts if p)
return f"{last_name} {inits}"
else:
return last_name
def _format_authors(authors: list[dict], lang: str = "ru") -> str:
"""
Форматировать список авторов по правилам ГОСТ 7.1-2003.
Args:
authors: Список словарей с авторами
lang: Язык для "и др." / "et al."
Returns:
Строка с отформатированными авторами
"""
if not authors:
return ""
et_al = "и др." if lang == "ru" else "et al."
formatted = [_format_author(a) for a in authors if a.get("last_name")]
formatted = [f for f in formatted if f]
if not formatted:
return ""
if len(formatted) <= 3:
return ", ".join(formatted)
else:
return ", ".join(formatted[:3]) + f", {et_al}"
class GOST71Formatter:
"""Форматтер полных библиографических записей по ГОСТ 7.1-2003."""
def format_full(self, doc: dict) -> str:
"""
Форматировать полную библиографическую запись.
Args:
doc: Словарь с полями документа
Returns:
Отформатированная строка библиографической записи
"""
doc_type = self._detect_type(doc)
if doc_type == "article":
return self._format_article(doc)
elif doc_type == "book":
return self._format_book(doc)
elif doc_type == "web":
return self._format_web(doc)
else:
return self._format_generic(doc)
def _detect_type(self, doc: dict) -> str:
"""Определить тип документа."""
if doc.get("journal"):
return "article"
if doc.get("url") and not doc.get("journal"):
return "web"
return "generic"
def _format_article(self, doc: dict) -> str:
"""
Форматировать статью в журнале.
Формат: Автор(ы). Название // Журнал. — Год. — Т. X, № X. — С. XX.
"""
lang = doc.get("lang", "ru") or "ru"
authors = doc.get("authors", [])
author_str = _format_authors(authors, lang=lang)
title = (doc.get("title") or "").strip()
journal = (doc.get("journal") or "").strip()
year = doc.get("year", "")
volume = doc.get("volume", "")
issue = doc.get("issue", "")
pages = doc.get("pages", "")
doi = doc.get("doi", "")
parts = []
# Авторы и название
if author_str:
parts.append(f"{author_str}. {title}")
else:
parts.append(title)
# Место публикации
location = f"// {journal}"
if year:
location += f". — {year}"
if volume:
location += f". — Т. {volume}"
if issue:
location += f", № {issue}"
if pages:
location += f". — С. {pages}"
parts.append(location)
result = " ".join(parts)
# DOI
if doi:
result += f". — DOI: {doi}"
return result.strip()
def _format_book(self, doc: dict) -> str:
"""
Форматировать книгу / монографию.
Формат: Автор(ы). Название. — Город : Издательство, Год. — X с.
"""
lang = doc.get("lang", "ru") or "ru"
authors = doc.get("authors", [])
author_str = _format_authors(authors, lang=lang)
title = (doc.get("title") or "").strip()
year = doc.get("year", "б. г.")
pages = doc.get("pages", "")
parts = []
if author_str:
parts.append(f"{author_str}.")
parts.append(f"{title}.")
parts.append(f"{year}.")
if pages:
# Предполагаем, что pages содержит количество страниц
parts.append(f"{pages} с.")
return " ".join(parts).strip()
def _format_web(self, doc: dict) -> str:
"""
Форматировать электронный ресурс.
Формат: Автор(ы). Название [Электронный ресурс]. — URL: ... (дата обращения: ...)
"""
lang = doc.get("lang", "ru") or "ru"
authors = doc.get("authors", [])
author_str = _format_authors(authors, lang=lang)
title = (doc.get("title") or "").strip()
url = (doc.get("url") or "").strip()
year = doc.get("year", "")
parts = []
if author_str:
parts.append(f"{author_str}.")
# Для ГОСТ — обозначение электронного ресурса
parts.append(f"{title} [Электронный ресурс].")
if year:
parts.append(f"{year}.")
if url:
parts.append(f"— URL: {url}")
return " ".join(parts).strip()
def _format_generic(self, doc: dict) -> str:
"""Базовый форматтер для неопределённых типов."""
lang = doc.get("lang", "ru") or "ru"
author_str = _format_authors(doc.get("authors", []), lang=lang)
title = (doc.get("title") or "").strip()
year = doc.get("year", "")
result = ""
if author_str:
result += f"{author_str}. "
result += title
if year:
result += f". — {year}"
return result.strip()
def _get_sort_key(doc: dict) -> str:
"""
Получить ключ сортировки для документа.
Кириллица идёт перед латиницей (для русских традиций):
сначала русскоязычные источники, затем иностранные.
Args:
doc: Словарь с метаданными
Returns:
Строка для сортировки
"""
authors = doc.get("authors", [])
if authors:
last_name = authors[0].get("last_name", "")
else:
last_name = doc.get("title", "")
if not last_name:
return "яяя" # В конец
# Кириллица < латиница (традиция: русские источники первыми)
first_char = last_name[0].lower()
is_latin = ord(first_char) < 256 and first_char.isalpha()
# Префикс для сортировки: 0 для кириллицы, 1 для латиницы
prefix = "1" if is_latin else "0"
return f"{prefix}{last_name.lower()}"
def format_full(doc: dict) -> str:
"""Удобная функция для быстрого форматирования."""
return GOST71Formatter().format_full(doc)

View File

@@ -0,0 +1,142 @@
"""Celery задача форматирования библиографии по ГОСТ."""
import logging
from typing import Any
from celery.utils.log import get_task_logger
from sqlalchemy import select
from app.celery_app import celery_app
from app.db import db_session
from app.formatters.gost_7_0_5 import GOST705Formatter
from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key
logger = get_task_logger(__name__)
@celery_app.task(
name="gost.format_bibliography",
bind=True,
max_retries=3,
default_retry_delay=30,
)
def format_bibliography(
self,
task_id: str,
doc_ids: list[int],
style: str = "7.1",
) -> dict[str, Any]:
"""
Форматировать список литературы по ГОСТ для переданных doc_ids.
Args:
task_id: ID задачи в PostgreSQL
doc_ids: Список ID документов из PostgreSQL
style: Стиль форматирования — "7.1" (полная) или "7.0.5" (краткая)
Returns:
dict с отформатированной библиографией
"""
from app.models import Document, Task
logger.info(
f"Форматирование библиографии для задачи {task_id!r}: "
f"{len(doc_ids)} документов, стиль ГОСТ {style}"
)
try:
with db_session() as session:
# Обновить статус
task = session.get(Task, task_id)
if task:
task.status = "processing"
session.commit()
# Получить документы
docs = session.execute(
select(Document).where(Document.id.in_(doc_ids))
).scalars().all()
if not docs:
raise ValueError(f"Документы не найдены: {doc_ids}")
# Выбрать форматтер
formatter = GOST71Formatter() if style == "7.1" else GOST705Formatter()
# Преобразовать ORM объекты в словари для форматирования
docs_dicts = []
for doc in docs:
docs_dicts.append({
"id": doc.id,
"title": doc.title,
"authors": doc.authors or [],
"year": doc.year,
"journal": doc.journal,
"volume": doc.volume,
"issue": doc.issue,
"pages": doc.pages,
"doi": doc.doi,
"url": doc.url,
"lang": doc.lang or "ru",
"source": doc.source,
})
# Сортировать: кириллица (рус. авторы) → латиница (иностр.)
sorted_docs = sorted(docs_dicts, key=lambda d: _get_sort_key(d))
bibliography = []
for i, doc_dict in enumerate(sorted_docs, 1):
if style == "7.1":
citation = formatter.format_full(doc_dict)
else:
citation = formatter.format_short(doc_dict)
bibliography.append({
"number": i,
"citation": citation,
"doc_id": doc_dict["id"],
})
result = {
"bibliography": bibliography,
"style": style,
"total": len(bibliography),
}
# Сохранить результат
task = session.get(Task, task_id)
if task:
task.result = result
task.status = "done"
task.queue_position = None
session.commit()
logger.info(
f"Библиография сформирована для задачи {task_id!r}: "
f"{len(bibliography)} записей по ГОСТ {style}"
)
# Уведомить пользователя
celery_app.send_task(
"notify.send_task_done",
args=[task_id],
queue="queue.notify",
)
return result
except Exception as exc:
logger.error(f"Ошибка при форматировании библиографии для задачи {task_id!r}: {exc}", exc_info=True)
try:
from app.models import Task
with db_session() as session:
task = session.get(Task, task_id)
if task:
task.status = "failed"
task.error = str(exc)
session.commit()
except Exception as db_exc:
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
raise self.retry(exc=exc, countdown=30)