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:
0
services/worker-gost/app/__init__.py
Normal file
0
services/worker-gost/app/__init__.py
Normal file
27
services/worker-gost/app/celery_app.py
Normal file
27
services/worker-gost/app/celery_app.py
Normal 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,
|
||||
)
|
||||
33
services/worker-gost/app/config.py
Normal file
33
services/worker-gost/app/config.py
Normal 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()
|
||||
32
services/worker-gost/app/db.py
Normal file
32
services/worker-gost/app/db.py
Normal 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()
|
||||
0
services/worker-gost/app/formatters/__init__.py
Normal file
0
services/worker-gost/app/formatters/__init__.py
Normal file
57
services/worker-gost/app/formatters/gost_7_0_5.py
Normal file
57
services/worker-gost/app/formatters/gost_7_0_5.py
Normal 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)
|
||||
267
services/worker-gost/app/formatters/gost_7_1.py
Normal file
267
services/worker-gost/app/formatters/gost_7_1.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""ГОСТ 7.1-2003 — Библиографическая запись. Библиографическое описание.
|
||||
|
||||
Полная запись для списка литературы.
|
||||
|
||||
Формат для статьи в журнале:
|
||||
Автор(ы). Название // Журнал. — Год. — Т. X, № X. — С. X–X.
|
||||
|
||||
Формат для книги:
|
||||
Автор(ы). Название. — Город : Издательство, Год. — 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. — С. X–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()
|
||||
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)
|
||||
0
services/worker-gost/app/tasks/__init__.py
Normal file
0
services/worker-gost/app/tasks/__init__.py
Normal file
142
services/worker-gost/app/tasks/gost.py
Normal file
142
services/worker-gost/app/tasks/gost.py
Normal 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)
|
||||
Reference in New Issue
Block a user