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

@@ -0,0 +1,138 @@
"""Клиент для Ollama HTTP API — LLM анализ парафраза и суммаризация."""
import json
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class OllamaClient:
"""HTTP клиент для Ollama LLM."""
def __init__(self) -> None:
self.base_url = settings.OLLAMA_URL
self.model = "llama3:8b"
self.timeout = 60.0 # секунд
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
"""
Проверить является ли text_b парафразом text_a с помощью LLM.
Args:
text_a: Исходный текст из базы данных (до 500 символов)
text_b: Проверяемый фрагмент (до 500 символов)
Returns:
dict с полями:
- is_paraphrase: bool
- confidence: float 0.0-1.0
- reason: str — краткое объяснение
"""
prompt = f"""Ты эксперт по академическому плагиату. Определи, является ли Текст B парафразом Текста A.
Текст A (источник): {text_a[:500]}
Текст B (проверяемый): {text_b[:500]}
Критерии парафраза: передача тех же идей другими словами, перефразировка без ссылки на источник.
Ответь ТОЛЬКО валидным JSON без пояснений и markdown:
{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}"""
try:
response = httpx.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": 0.1, # Детерминированный вывод
"num_predict": 200,
},
},
timeout=self.timeout,
)
response.raise_for_status()
result = response.json()
llm_response = result.get("response", "{}")
# Парсим JSON из ответа
parsed = json.loads(llm_response)
return {
"is_paraphrase": bool(parsed.get("is_paraphrase", False)),
"confidence": float(parsed.get("confidence", 0.0)),
"reason": str(parsed.get("reason", "")),
}
except (httpx.TimeoutException, httpx.ConnectError) as e:
logger.warning(f"Ollama недоступна: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"}
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Ошибка парсинга ответа Ollama: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"}
except Exception as e:
logger.error(f"Неожиданная ошибка при обращении к Ollama: {e}")
return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)}
def summarize(self, title: str, abstract: str, lang: str = "ru") -> str:
"""
Сгенерировать краткое академическое изложение статьи.
Args:
title: Название статьи
abstract: Аннотация статьи
lang: Язык ответа (ru/en)
Returns:
Краткое изложение на указанном языке
"""
lang_instruction = "на русском языке" if lang == "ru" else "in English"
prompt = f"""Сделай краткое академическое изложение {lang_instruction}.
Название: {title}
Аннотация: {abstract[:1000]}
Изложение должно:
- Содержать 3-5 предложений
- Передавать основную идею и результаты
- Быть написано академическим стилем
- НЕ копировать текст дословно
Ответ:"""
try:
response = httpx.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3,
"num_predict": 300,
},
},
timeout=self.timeout,
)
response.raise_for_status()
return response.json().get("response", "").strip()
except Exception as e:
logger.error(f"Ошибка суммаризации через Ollama: {e}")
return abstract[:500] if abstract else title
def is_available(self) -> bool:
"""Проверить доступность Ollama сервера."""
try:
response = httpx.get(f"{self.base_url}/api/tags", timeout=5.0)
return response.status_code == 200
except Exception:
return False