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,20 @@
FROM python:3.11-slim
# Системные зависимости для PDF обработки
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libpq-dev \
libmupdf-dev \
mupdf-tools \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.index", "-c", "4", "-n", "indexer@%h", "--loglevel=info"]

View File

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)

View File

@@ -0,0 +1,30 @@
"""Celery приложение индексер-воркера."""
from celery import Celery
from app.config import settings
celery_app = Celery(
"worker_indexer",
broker=settings.RABBITMQ_URL,
backend=settings.REDIS_URL,
include=["app.tasks.index"],
)
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={
"gpu.*": {"queue": "queue.gpu"},
"index.*": {"queue": "queue.index"},
"notify.*": {"queue": "queue.notify"},
"gost.*": {"queue": "queue.gost"},
},
task_acks_late=True,
task_reject_on_worker_lost=True,
result_expires=86400,
)

View File

@@ -0,0 +1,55 @@
"""Конфигурация индексер-воркера."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Настройки индексер-воркера."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# PostgreSQL
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "antiplagiator"
POSTGRES_USER: str = "antiplagiator"
POSTGRES_PASSWORD: str = "changeme"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# RabbitMQ
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
# MinIO
MINIO_ENDPOINT: str = "minio:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "changeme"
MINIO_BUCKET_DOCS: str = "documents"
# Elasticsearch
ELASTICSEARCH_URL: str = "http://elasticsearch:9200"
# Настройки обработки текста
FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов
FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов
MAX_FINGERPRINTS_PER_DOC: int = 500 # Максимум хэшей Winnowing на документ
# App
ENVIRONMENT: str = "development"
DEBUG: bool = False
@property
def database_url_sync(self) -> str:
"""Синхронный URL для SQLAlchemy."""
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,67 @@
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
import logging
from contextlib import contextmanager
from typing import Generator
from minio import Minio
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
logger = logging.getLogger(__name__)
# Синхронный движок SQLAlchemy
engine = create_engine(
settings.database_url_sync,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=3600,
)
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()
_minio_client: Minio | None = None
def get_minio() -> Minio:
"""Получить или создать MinIO клиент."""
global _minio_client
if _minio_client is None:
_minio_client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False,
)
return _minio_client
def update_task_status(task_id: str, status: str, error: str | None = None) -> None:
"""Обновить статус задачи в БД."""
from app.models import Task
with db_session() as session:
task = session.get(Task, task_id)
if task:
task.status = status
if error:
task.error = error
session.commit()

View File

@@ -0,0 +1,69 @@
"""Извлечение текста из DOCX файлов через python-docx."""
import io
import logging
logger = logging.getLogger(__name__)
def extract_text_from_docx(data: bytes) -> str:
"""
Извлечь текст из DOCX файла.
Обрабатывает параграфы, таблицы и заголовки.
Args:
data: Байты DOCX файла
Returns:
Извлечённый текст
Raises:
ValueError: Если не удалось открыть DOCX
"""
from docx import Document as DocxDocument
from docx.oxml.ns import qn
try:
doc = DocxDocument(io.BytesIO(data))
except Exception as e:
raise ValueError(f"Не удалось открыть DOCX: {e}") from e
texts: list[str] = []
# Основной текст из параграфов
for para in doc.paragraphs:
text = para.text.strip()
if text:
texts.append(text)
# Текст из таблиц
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
cell_text = cell.text.strip()
if cell_text:
texts.append(cell_text)
return "\n".join(texts)
def extract_text_from_txt(data: bytes) -> str:
"""
Извлечь текст из TXT файла с определением кодировки.
Args:
data: Байты TXT файла
Returns:
Текст файла
"""
# Пробуем UTF-8, затем cp1251 (Windows-1251 для русских текстов)
for encoding in ("utf-8", "cp1251", "latin-1"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
# Последний вариант — игнорировать ошибки
return data.decode("utf-8", errors="ignore")

View File

@@ -0,0 +1,80 @@
"""Извлечение текста из PDF файлов через PyMuPDF."""
import logging
logger = logging.getLogger(__name__)
def extract_text_from_pdf(data: bytes) -> str:
"""
Извлечь текст из PDF файла.
Обрабатывает многостраничные документы.
Удаляет лишние переносы строк и пробелы.
Args:
data: Байты PDF файла
Returns:
Извлечённый текст
Raises:
ValueError: Если не удалось открыть PDF
"""
import fitz # PyMuPDF
try:
doc = fitz.open(stream=data, filetype="pdf")
except Exception as e:
raise ValueError(f"Не удалось открыть PDF: {e}") from e
texts: list[str] = []
for page_num, page in enumerate(doc):
try:
page_text = page.get_text("text")
if page_text.strip():
texts.append(page_text)
except Exception as e:
logger.warning(f"Ошибка извлечения текста со страницы {page_num}: {e}")
continue
doc.close()
full_text = "\n".join(texts)
# Нормализация: убрать множественные переносы строк
import re
full_text = re.sub(r"\n{3,}", "\n\n", full_text)
full_text = re.sub(r"[ \t]+", " ", full_text)
return full_text.strip()
def extract_metadata_from_pdf(data: bytes) -> dict:
"""
Извлечь метаданные из PDF (title, author, subject и т.д.).
Args:
data: Байты PDF файла
Returns:
Словарь метаданных
"""
import fitz
try:
doc = fitz.open(stream=data, filetype="pdf")
metadata = doc.metadata or {}
doc.close()
return {
"title": metadata.get("title", ""),
"author": metadata.get("author", ""),
"subject": metadata.get("subject", ""),
"keywords": metadata.get("keywords", ""),
"creator": metadata.get("creator", ""),
"pages": doc.page_count if hasattr(doc, "page_count") else 0,
}
except Exception as e:
logger.warning(f"Ошибка извлечения метаданных PDF: {e}")
return {}

View File

@@ -0,0 +1,329 @@
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
import io
import logging
from pathlib import Path
from typing import Any
from celery.utils.log import get_task_logger
from sqlalchemy import func, select
from app.algorithms.minhash import add_to_lsh, find_similar
from app.algorithms.winnowing import winnow
from app.celery_app import celery_app
from app.config import settings
from app.db import db_session, get_minio, update_task_status
from app.extractors.docx import extract_text_from_docx, extract_text_from_txt
from app.extractors.pdf import extract_text_from_pdf
logger = get_task_logger(__name__)
def _split_into_fragments(
text: str,
window: int = 200,
overlap: int = 50,
) -> list[dict[str, Any]]:
"""
Разбить текст на фрагменты для проверки плагиата.
Использует скользящее окно с перекрытием.
Args:
text: Исходный текст
window: Размер окна в словах
overlap: Перекрытие между фрагментами в словах
Returns:
Список словарей {"text": str, "start": int, "end": int}
"""
words = text.split()
if not words:
return []
fragments = []
step = window - overlap
char_positions = []
# Вычислить позиции символов для каждого слова
pos = 0
for word in words:
char_positions.append(pos)
pos += len(word) + 1 # +1 для пробела
for i in range(0, max(1, len(words) - window + 1), step):
chunk_words = words[i : i + window]
if len(chunk_words) < 20: # Пропустить слишком короткие фрагменты
continue
start_char = char_positions[i]
end_idx = min(i + window - 1, len(words) - 1)
end_char = char_positions[end_idx] + len(words[end_idx])
fragments.append({
"text": " ".join(chunk_words),
"start": start_char,
"end": end_char,
})
return fragments
@celery_app.task(
name="index.extract_and_check",
bind=True,
max_retries=3,
default_retry_delay=60,
)
def extract_and_check(
self,
task_id: str,
minio_key: str,
filename: str,
) -> dict[str, Any]:
"""
Извлечь текст из документа и проверить плагиат (уровни 1-2).
Алгоритм:
1. Скачать файл из MinIO
2. Извлечь текст (PDF/DOCX/TXT)
3. Разбить на фрагменты по 200 слов с перекрытием 50 слов
4. Уровень 1: Winnowing против базы fingerprints в PostgreSQL
5. Уровень 2: MinHash LSH нечёткий поиск
6. Диспатч gpu.check_plagiarism для уровней 3 и 4
Args:
task_id: ID задачи в PostgreSQL
minio_key: Ключ объекта в MinIO
filename: Оригинальное имя файла
"""
logger.info(f"Извлечение текста для задачи {task_id!r}, файл: {filename!r}")
update_task_status(task_id, "processing")
try:
# Скачать из MinIO
minio = get_minio()
response = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
file_data = response.read()
response.close()
response.release_conn()
logger.info(f"Файл скачан из MinIO: {minio_key} ({len(file_data)} байт)")
# Извлечь текст в зависимости от формата
ext = Path(filename).suffix.lower()
if ext == ".pdf":
text = extract_text_from_pdf(file_data)
elif ext == ".docx":
text = extract_text_from_docx(file_data)
else:
text = extract_text_from_txt(file_data)
if not text.strip():
raise ValueError("Не удалось извлечь текст из документа")
logger.info(f"Текст извлечён: {len(text)} символов, {len(text.split())} слов")
# Разбить на фрагменты
fragments = _split_into_fragments(
text,
window=settings.FRAGMENT_WINDOW_WORDS,
overlap=settings.FRAGMENT_OVERLAP_WORDS,
)
logger.info(f"Фрагментов создано: {len(fragments)}")
# ──── Уровень 1: Winnowing fingerprints ────────────────────────────────
level1_matches: list[dict] = []
doc_fingerprint = winnow(text)
if doc_fingerprint:
from app.models import Document, Fingerprint
with db_session() as session:
hashes = list(doc_fingerprint)[: settings.MAX_FINGERPRINTS_PER_DOC]
# Найти совпадения в базе fingerprints
matching_docs = session.execute(
select(
Fingerprint.doc_id,
func.count(Fingerprint.id).label("match_count"),
)
.where(Fingerprint.hash_value.in_(hashes))
.group_by(Fingerprint.doc_id)
.having(func.count(Fingerprint.id) > len(hashes) * 0.1)
.order_by(func.count(Fingerprint.id).desc())
.limit(20)
).all()
for doc_id, match_count in matching_docs:
similarity = match_count / len(hashes) * 100
if similarity < 20:
continue
doc = session.get(Document, doc_id)
if not doc:
continue
level1_matches.append({
"fragment": text[:200],
"position_start": 0,
"position_end": len(text),
"similarity": round(similarity, 1),
"method": "exact",
"source_title": doc.title,
"source_url": doc.url,
"source_db": doc.source,
})
logger.info(f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений")
# ──── Уровень 2: MinHash LSH ────────────────────────────────────────────
level2_matches: list[dict] = []
similar_keys = find_similar(text)
if similar_keys:
from app.models import Document
with db_session() as session:
for key in similar_keys[:10]:
# Ключ формата "doc:{id}"
try:
doc_id = int(key.split(":")[-1])
doc = session.get(Document, doc_id)
if not doc:
continue
level2_matches.append({
"fragment": text[:200],
"position_start": 0,
"position_end": len(text),
"similarity": 60.0, # MinHash даёт только факт похожести
"method": "fuzzy",
"source_title": doc.title,
"source_url": doc.url,
"source_db": doc.source,
})
except (ValueError, IndexError):
continue
logger.info(f"Уровень 2 (MinHash): {len(level2_matches)} совпадений")
# ──── Диспатч GPU задачи (уровни 3-4) ─────────────────────────────────
# Передаём только текстовые данные (JSON-сериализуемые)
celery_app.send_task(
"gpu.check_plagiarism",
args=[task_id, text, fragments],
kwargs={
"level1_matches": level1_matches,
"level2_matches": level2_matches,
},
queue="queue.gpu",
)
logger.info(f"GPU задача отправлена для задачи {task_id!r}")
return {
"task_id": task_id,
"fragments": len(fragments),
"level1": len(level1_matches),
"level2": len(level2_matches),
}
except Exception as exc:
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
update_task_status(task_id, "failed", str(exc))
raise self.retry(exc=exc, countdown=60)
@celery_app.task(name="index.add_document")
def add_document(doc_data: dict[str, Any]) -> dict[str, Any]:
"""
Добавить документ из внешнего источника в систему.
Алгоритм:
1. Дедупликация по ext_id
2. Сохранить метаданные в PostgreSQL
3. Индексировать в Elasticsearch
4. Вычислить Winnowing fingerprints
5. Добавить в MinHash LSH
6. Диспатч gpu.embed_documents для FAISS эмбеддингов
Args:
doc_data: Словарь с метаданными документа
Returns:
dict со статусом операции и doc_id
"""
from app.models import Document, Fingerprint
ext_id = doc_data.get("ext_id")
if not ext_id:
return {"status": "error", "reason": "ext_id обязателен"}
with db_session() as session:
# Проверить дублирование
existing = session.execute(
select(Document).where(Document.ext_id == ext_id)
).scalar_one_or_none()
if existing:
return {"status": "duplicate", "doc_id": existing.id}
# Создать документ
allowed_fields = {c.key for c in Document.__table__.columns}
doc_kwargs = {k: v for k, v in doc_data.items() if k in allowed_fields}
doc = Document(**doc_kwargs)
session.add(doc)
session.flush()
doc_id = doc.id
# Вычислить fingerprints
text = doc_data.get("full_text") or doc_data.get("abstract", "") or ""
if text:
fp = winnow(text)
fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
for i, hash_val in enumerate(fingerprints_to_add):
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
# Добавить в MinHash LSH (in-memory)
add_to_lsh(f"doc:{doc_id}", text)
session.commit()
logger.info(f"Документ {doc_id} добавлен в PostgreSQL: {doc_data.get('title', '')[:50]!r}")
# Индексация в Elasticsearch
try:
from elasticsearch import Elasticsearch
from app.config import settings as cfg
es = Elasticsearch(cfg.ELASTICSEARCH_URL)
es_doc = {
"doc_id": doc_id,
"source": doc_data.get("source"),
"title": doc_data.get("title", ""),
"abstract": doc_data.get("abstract", ""),
"authors": " ".join(
f"{a.get('last_name', '')} {a.get('first_name', '')}"
for a in doc_data.get("authors", [])
),
"year": doc_data.get("year"),
"lang": doc_data.get("lang"),
"journal": doc_data.get("journal"),
"doi": doc_data.get("doi"),
"url": doc_data.get("url"),
}
es.index(index="documents", id=str(doc_id), document=es_doc)
logger.info(f"Документ {doc_id} проиндексирован в Elasticsearch")
except Exception as e:
logger.warning(f"Ошибка индексации в ES для документа {doc_id}: {e}")
# Диспатч FAISS эмбеддингов
celery_app.send_task(
"gpu.embed_documents",
args=[[doc_id]],
queue="queue.gpu",
)
return {"status": "indexed", "doc_id": doc_id}

View File

@@ -0,0 +1,13 @@
celery==5.4.0
redis==5.0.4
sqlalchemy==2.0.30
psycopg2-binary==2.9.9
elasticsearch==8.13.0
minio==7.2.7
PyMuPDF==1.24.0
python-docx==1.1.0
datasketch==1.6.5
xxhash==3.4.1
langdetect==1.0.9
pydantic-settings==2.2.1
httpx==0.27.0