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,118 @@
"""MinHash LSH для нечёткого поиска похожих документов.
Позволяет быстро находить документы с похожим содержимым
без точного сравнения всех пар.
"""
import logging
from datasketch import MinHash, MinHashLSH
logger = logging.getLogger(__name__)
# Параметры MinHash LSH
LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты
LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память)
# Глобальный LSH индекс (in-memory)
_lsh: MinHashLSH | None = None
def get_lsh() -> MinHashLSH:
"""Получить или создать глобальный LSH индекс."""
global _lsh
if _lsh is None:
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
logger.info("MinHash LSH индекс создан")
return _lsh
def get_shingles(text: str, k: int = 3) -> set[str]:
"""
Получить k-слоговые шинглы из текста.
Args:
text: Исходный текст
k: Размер шингла (количество слов)
Returns:
Множество шинглов
"""
words = text.lower().split()
if len(words) < k:
return {" ".join(words)} if words else set()
return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)}
def text_to_minhash(text: str) -> MinHash:
"""
Создать MinHash подпись для текста.
Args:
text: Исходный текст
Returns:
MinHash объект
"""
m = MinHash(num_perm=LSH_NUM_PERM)
for shingle in get_shingles(text):
m.update(shingle.encode("utf-8"))
return m
def add_to_lsh(doc_key: str, text: str) -> None:
"""
Добавить документ в LSH индекс.
Args:
doc_key: Уникальный ключ документа (например, "doc:{id}")
text: Текст документа
"""
lsh = get_lsh()
m = text_to_minhash(text)
try:
lsh.insert(doc_key, m)
except ValueError:
# Документ уже в индексе — игнорируем
pass
def find_similar(text: str) -> list[str]:
"""
Найти похожие документы в LSH индексе.
Args:
text: Текст для поиска похожих
Returns:
Список ключей похожих документов
"""
lsh = get_lsh()
m = text_to_minhash(text)
try:
return lsh.query(m)
except Exception as e:
logger.warning(f"Ошибка запроса к MinHash LSH: {e}")
return []
def compute_jaccard_minhash(text_a: str, text_b: str) -> float:
"""
Оценить схожесть двух текстов через MinHash Jaccard.
Args:
text_a: Первый текст
text_b: Второй текст
Returns:
Оценка схожести Жаккара через MinHash
"""
m_a = text_to_minhash(text_a)
m_b = text_to_minhash(text_b)
return m_a.jaccard(m_b)
def reset_lsh() -> None:
"""Сбросить LSH индекс (для тестов)."""
global _lsh
_lsh = None

View File

@@ -0,0 +1,120 @@
"""Алгоритм Winnowing для fingerprinting текстов.
Winnowing — алгоритм выбора минимального хэша в скользящем окне.
Используется для нахождения точных и частичных совпадений текстов.
"""
import xxhash
def get_ngrams(tokens: list[str], k: int = 5) -> list[str]:
"""
Сформировать n-граммы из токенов.
Args:
tokens: Список слов
k: Размер n-граммы
Returns:
Список n-грамм в виде строк
"""
if len(tokens) < k:
return []
return [" ".join(tokens[i : i + k]) for i in range(len(tokens) - k + 1)]
def hash_ngram(ngram: str) -> int:
"""
Хэшировать n-грамму через xxHash (быстро, детерминированно).
Args:
ngram: n-грамма для хэширования
Returns:
64-битный хэш
"""
return xxhash.xxh64(ngram.encode("utf-8")).intdigest()
def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:
"""
Алгоритм Winnowing для построения fingerprint документа.
Шаги:
1. Токенизация (lowercase)
2. Построение k-грамм
3. Хэширование k-грамм
4. Скользящее окно — выбор минимального хэша в каждой позиции
Args:
text: Исходный текст
k: Размер k-граммы (n-gram)
window: Размер скользящего окна
Returns:
Множество отобранных хэшей (fingerprint)
"""
tokens = text.lower().split()
if len(tokens) < k:
return set()
ngrams = get_ngrams(tokens, k)
hashes = [hash_ngram(ng) for ng in ngrams]
if not hashes:
return set()
# Скользящее окно — выбираем минимальный хэш в каждом окне
fingerprint: set[int] = set()
prev_min_idx = -1
for i in range(len(hashes) - window + 1):
window_hashes = hashes[i : i + window]
min_val = min(window_hashes)
min_idx = i + window_hashes.index(min_val)
# Добавляем только если это новый минимум или позиция изменилась
if min_idx != prev_min_idx:
fingerprint.add(min_val)
prev_min_idx = min_idx
return fingerprint
def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float:
"""
Коэффициент Жаккара для двух fingerprint'ов.
Args:
fp_a: Fingerprint документа A
fp_b: Fingerprint документа B
Returns:
Коэффициент сходства от 0.0 до 1.0
"""
if not fp_a or not fp_b:
return 0.0
intersection = len(fp_a & fp_b)
union = len(fp_a | fp_b)
return intersection / union if union > 0 else 0.0
def compute_similarity(text_a: str, text_b: str, k: int = 5, window: int = 4) -> float:
"""
Вычислить схожесть двух текстов через Winnowing.
Args:
text_a: Первый текст
text_b: Второй текст
k: Размер k-граммы
window: Размер окна Winnowing
Returns:
Схожесть от 0.0 до 1.0
"""
fp_a = winnow(text_a, k=k, window=window)
fp_b = winnow(text_b, k=k, window=window)
return jaccard_similarity(fp_a, fp_b)