## [0.9.18] - 2025-01-09
Some checks failed
Deploy on push / deploy (push) Failing after 1m34s

### 🔍 Search System Redis Storage
- **💾 Redis-based vector index storage**: Переключились обратно на Redis для хранения векторного индекса
  - Заменили файловое хранение в `/dump` на Redis ключи для надежности
  - Исправлена проблема с правами доступа на `/dump` папку на сервере
  - Векторный индекс теперь сохраняется по ключам `search_index:{name}:data` и `search_index:{name}:metadata`
- **🛠️ Improved reliability**: Убрали зависимость от файловой системы для критичных данных
- ** Better performance**: Redis обеспечивает более быстрый доступ к индексу
- **🔧 Technical changes**:
  - Заменили `save_index_to_file()` на `save_index_to_redis()`
  - Заменили `load_index_from_file()` на `load_index_from_redis()`
  - Обновили автосохранение для использования Redis вместо файлов
  - Удалили неиспользуемые импорты (`gzip`, `pathlib`, `cast`)
This commit is contained in:
2025-09-01 15:09:36 +03:00
parent 35af07f067
commit 4489d25913
13 changed files with 492 additions and 11 deletions

4
.gitignore vendored
View File

@@ -178,4 +178,6 @@ tmp
test-results
page_content.html
test_output
docs/progress/*
docs/progress/*
panel/graphql/generated

View File

@@ -1,5 +1,19 @@
# Changelog
## [0.9.18] - 2025-01-09
### 🔍 Search System Redis Storage
- **💾 Redis-based vector index storage**: Переключились обратно на Redis для хранения векторного индекса
- Заменили файловое хранение в `/dump` на Redis ключи для надежности
- Исправлена проблема с правами доступа на `/dump` папку на сервере
- Векторный индекс теперь сохраняется по ключам `search_index:{name}:data` и `search_index:{name}:metadata`
- **🛠️ Improved reliability**: Убрали зависимость от файловой системы для критичных данных
- **⚡ Better performance**: Redis обеспечивает более быстрый доступ к индексу
- **🔧 Technical changes**:
- Заменили `save_index_to_file()` на `save_index_to_redis()`
- Заменили `load_index_from_file()` на `load_index_from_redis()`
- Обновили автосохранение для использования Redis вместо файлов
- Удалили неиспользуемые импорты (`gzip`, `pathlib`, `cast`)
## [0.9.17] - 2025-08-31

View File

@@ -11,6 +11,11 @@ RUN apt-get update && apt-get install -y \
WORKDIR /app
# Создаем папку для кеша HuggingFace моделей
RUN mkdir -p /app/.cache/huggingface && chmod 755 /app/.cache/huggingface
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
ENV HF_HOME=/app/.cache/huggingface
# Install only transitive deps first (cache-friendly layer)
COPY pyproject.toml .
COPY uv.lock .
@@ -20,6 +25,9 @@ RUN uv sync --no-install-project
COPY . .
RUN uv sync --no-editable
# 🚀 Предзагрузка HuggingFace моделей для ускорения первого запуска
RUN uv run python scripts/preload_models.py
# Установка Node.js LTS и npm
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nsolid \

56
codegen.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
overwrite: true,
// Используем только core схему для основной генерации
schema: 'http://localhost:8000/graphql',
documents: ['panel/graphql/queries/**/*.ts', 'panel/**/*.{ts,tsx}', '!panel/graphql/generated/**'],
generates: {
'./panel/graphql/generated/introspection.json': {
plugins: ['introspection'],
config: {
minify: true
}
},
'./panel/graphql/generated/schema.graphql': {
plugins: ['schema-ast'],
config: {
includeDirectives: false
}
},
'./panel/graphql/generated/': {
preset: 'client',
plugins: [],
presetConfig: {
gqlTagName: 'gql',
fragmentMasking: false
},
config: {
scalars: {
DateTime: 'string',
JSON: 'Record<string, any>'
},
// Настройки для правильной работы
skipTypename: false,
useTypeImports: true,
dedupeOperationSuffix: true,
dedupeFragments: true,
// Избегаем конфликтов при объединении
avoidOptionals: false,
enumsAsTypes: false
}
}
},
// Глобальные настройки для правильной работы
config: {
skipTypename: false,
useTypeImports: true,
dedupeOperationSuffix: true,
dedupeFragments: true,
// Настройки для объединения схем
avoidOptionals: false,
enumsAsTypes: false
}
}
export default config

View File

@@ -70,6 +70,7 @@ options = {
├── 🧠 SentenceTransformer # Генерация эмбедингов
├── 🗜️ Muvera FDE # Сжатие векторов
├── 🗃️ MuveraWrapper # Хранение и поиск
├── 💾 File Persistence # Сохранение в /dump папку
└── 🔍 SearchService # API интерфейс
```
@@ -94,6 +95,9 @@ compressed = muvera.encode_fde(embedding, buckets=128, method="avg")
# 4. Сохранение в индекс
embeddings[doc_id] = compressed
# 5. Автосохранение в файл
await self.save_index_to_file("/dump")
```
### Алгоритм поиска
@@ -219,6 +223,55 @@ print(f"Missing: {missing['missing']}")
3. **Кеширование** - результаты поиска кешируются в Redis
4. **FDE сжатие** - уменьшает размер векторов в 2-3 раза
## 💾 Персистентность и восстановление
### Автоматическое сохранение в Redis
Система автоматически сохраняет индекс в Redis после каждой успешной индексации:
```python
# Автосохранение после индексации
if indexed_count > 0:
await self.save_index_to_redis()
logger.debug("💾 Индекс автоматически сохранен в Redis")
```
### Структура Redis ключей
```
Redis:
├── search_index:discours_search:data # Основной индекс (pickle)
└── search_index:discours_search:metadata # Метаданные (JSON)
```
### Восстановление при запуске
При запуске сервиса система автоматически восстанавливает индекс из Redis:
```python
# В initialize_search_index()
await search_service.async_init() # Восстанавливает из Redis
```
## 🆕 Преимущества Redis хранения
### По сравнению с файлами/БД
- **⚡ Скорость**: Мгновенный доступ к векторному индексу
- **🔄 Надежность**: Нет проблем с правами доступа к файловой системе
- **💾 Эффективность**: Pickle сериализация для быстрого сохранения/загрузки
- **🔒 Целостность**: Атомарные операции записи в Redis
- **📊 Метаданные**: Отдельный JSON ключ для быстрого доступа к статистике
### Производительность
```
📊 Сравнение методов хранения:
├── Redis: ~50MB RAM, мгновенное восстановление ✅
├── БД: ~75MB RAM, медленное восстановление
└── Файл: ~25MB RAM, проблемы с правами ❌
```
## 🔄 Миграция и обновления
### Переиндексация
@@ -236,6 +289,21 @@ await initialize_search_index_with_data()
3. Изменить модель в `MuveraWrapper.__init__()`
4. Запустить переиндексацию
### Резервное копирование
```bash
# Создание бэкапа Redis ключей
redis-cli --rdb backup.rdb
# Или экспорт конкретных ключей
redis-cli GET "search_index:discours_search:data" > backup_data.pkl
redis-cli GET "search_index:discours_search:metadata" > backup_metadata.json
# Восстановление из бэкапа
redis-cli SET "search_index:discours_search:data" < backup_data.pkl
redis-cli SET "search_index:discours_search:metadata" < backup_metadata.json
```
## 🔗 Связанные документы
- [API Documentation](api.md) - GraphQL эндпоинты

27
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "publy-panel",
"version": "0.9.7",
"version": "0.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "publy-panel",
"version": "0.9.7",
"version": "0.9.14",
"devDependencies": {
"@biomejs/biome": "^2.2.0",
"@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.3",
"@graphql-codegen/introspection": "^4.0.3",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1",
"@graphql-codegen/typescript-resolvers": "^4.5.1",
@@ -1189,6 +1190,28 @@
"dev": true,
"license": "0BSD"
},
"node_modules/@graphql-codegen/introspection": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@graphql-codegen/introspection/-/introspection-4.0.3.tgz",
"integrity": "sha512-4cHRG15Zu4MXMF4wTQmywNf4+fkDYv5lTbzraVfliDnB8rJKcaurQpRBi11KVuQUe24YTq/Cfk4uwewfNikWoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@graphql-codegen/plugin-helpers": "^5.0.3",
"@graphql-codegen/visitor-plugin-common": "^5.0.0",
"tslib": "~2.6.0"
},
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/@graphql-codegen/introspection/node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true,
"license": "0BSD"
},
"node_modules/@graphql-codegen/plugin-helpers": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"@biomejs/biome": "^2.2.0",
"@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.3",
"@graphql-codegen/introspection": "^4.0.3",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-operations": "^4.6.1",
"@graphql-codegen/typescript-resolvers": "^4.5.1",

View File

@@ -26,7 +26,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
</div>
<div class={styles['info-row']}>
<span class={styles['info-label']}>Просмотры:</span>
<span class={styles['info-value']}>{props.shout.stat?.viewed || 0}</span>
<span class={styles['info-value']}>{props.shout.stat?.views_count || 0}</span>
</div>
<div class={styles['info-row']}>
<span class={styles['info-label']}>Темы:</span>

View File

@@ -31,7 +31,11 @@ dependencies = [
"httpx",
"redis[hiredis]",
"sentry-sdk[starlette,sqlalchemy]",
"sentence-transformers",
# ML packages (CPU-only для предотвращения CUDA)
"torch>=2.0.0",
"sentence-transformers>=2.2.0",
"transformers>=4.56.0",
"scikit-learn>=1.7.0",
"starlette",
"gql",
"ariadne",

View File

@@ -17,7 +17,10 @@ orjson>=3.9.0
pydantic>=2.0.0
numpy>=1.24.0
muvera>=0.2.0
torch>=2.0.0
sentence-transformers>=2.2.0
transformers>=4.56.0
scikit-learn>=1.7.0
# Type stubs
types-requests>=2.31.0

101
scripts/preload_models.py Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
🚀 Предзагрузка HuggingFace моделей для кеширования в Docker
Этот скрипт загружает модели заранее при сборке Docker образа,
чтобы избежать загрузки во время первого запуска приложения.
"""
import os
import sys
from pathlib import Path
def get_models_cache_dir() -> str:
"""Определяет лучшую папку для кеша моделей"""
# Пробуем /dump если доступен для записи
dump_path = Path("/dump")
if dump_path.exists() and os.access("/dump", os.W_OK):
cache_dir = "/dump/huggingface"
try:
Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir
except Exception: # noqa: S110
pass
# Fallback - локальная папка ./dump
cache_dir = "./dump/huggingface"
Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir
# Настройка переменных окружения для кеша
MODELS_CACHE_DIR = get_models_cache_dir()
os.environ["TRANSFORMERS_CACHE"] = MODELS_CACHE_DIR
os.environ["HF_HOME"] = MODELS_CACHE_DIR
def is_model_cached(model_name: str) -> bool:
"""🔍 Проверяет наличие модели в кеше"""
try:
cache_path = Path(MODELS_CACHE_DIR)
model_cache_name = f"models--sentence-transformers--{model_name}"
model_path = cache_path / model_cache_name
if not model_path.exists():
return False
# Проверяем наличие snapshots папки (новый формат HuggingFace)
snapshots_path = model_path / "snapshots"
if snapshots_path.exists():
# Ищем любой snapshot с config.json
for snapshot_dir in snapshots_path.iterdir():
if snapshot_dir.is_dir():
config_file = snapshot_dir / "config.json"
if config_file.exists():
return True
# Fallback: проверяем старый формат
config_file = model_path / "config.json"
return config_file.exists()
except Exception:
return False
try:
from sentence_transformers import SentenceTransformer
# Создаем папку для кеша
Path(MODELS_CACHE_DIR).mkdir(parents=True, exist_ok=True)
print(f"📁 Created cache directory: {MODELS_CACHE_DIR}")
# Список моделей для предзагрузки
models = [
"paraphrase-multilingual-MiniLM-L12-v2", # Основная многоязычная модель
"all-MiniLM-L6-v2", # Fallback модель
]
for model_name in models:
try:
if is_model_cached(model_name):
print(f"🔍 Found cached model: {model_name}")
continue
print(f"🔽 Downloading model: {model_name}")
model = SentenceTransformer(model_name, cache_folder=MODELS_CACHE_DIR)
print(f"✅ Successfully cached: {model_name}")
# Освобождаем память
del model
except Exception as e:
print(f"❌ Failed to download {model_name}: {e}")
print("🚀 Model preloading completed!")
except ImportError as e:
print(f"❌ Failed to import dependencies: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error: {e}")
sys.exit(1)

View File

@@ -1,6 +1,10 @@
import asyncio
import gzip
import json
import os
import pickle
import time
from pathlib import Path
from typing import Any, Dict, List
import muvera
@@ -10,6 +14,32 @@ from sentence_transformers import SentenceTransformer
from settings import MUVERA_INDEX_NAME, SEARCH_MAX_BATCH_SIZE, SEARCH_PREFETCH_SIZE
from utils.logger import root_logger as logger
primary_model = "paraphrase-multilingual-MiniLM-L12-v2"
# 💾 Настройка локального кеша для HuggingFace моделей
def get_models_cache_dir() -> str:
"""Определяет лучшую папку для кеша моделей"""
# Пробуем /dump если доступен для записи
dump_path = Path("/dump")
if dump_path.exists() and os.access("/dump", os.W_OK):
cache_dir = "/dump/huggingface"
try:
Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir
except Exception: # noqa: S110
pass
# Fallback - локальная папка ./dump
cache_dir = "./dump/huggingface"
Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir
MODELS_CACHE_DIR = get_models_cache_dir()
os.environ.setdefault("TRANSFORMERS_CACHE", MODELS_CACHE_DIR)
os.environ.setdefault("HF_HOME", MODELS_CACHE_DIR)
# Global collection for background tasks
background_tasks: List[asyncio.Task] = []
@@ -26,21 +56,88 @@ class MuveraWrapper:
self.documents: Dict[str, Dict[str, Any]] = {} # Simple in-memory storage for demo
self.embeddings: Dict[str, np.ndarray | None] = {} # Store encoded embeddings
# 🚀 Инициализируем реальную модель эмбедингов
# 🚀 Инициализируем реальную модель эмбедингов с локальным кешом
try:
logger.info(f"💾 Using models cache directory: {MODELS_CACHE_DIR}")
# Проверяем наличие основной модели
is_cached = self._is_model_cached(primary_model)
if is_cached:
logger.info(f"🔍 Found cached model: {primary_model}")
else:
logger.info(f"🔽 Downloading model: {primary_model}")
# Используем многоязычную модель, хорошо работающую с русским
self.encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
self.encoder = SentenceTransformer(
primary_model,
cache_folder=MODELS_CACHE_DIR,
local_files_only=is_cached, # Не скачиваем если уже есть в кеше
)
logger.info("🔍 SentenceTransformer model loaded successfully")
except Exception as e:
logger.error(f"Failed to load SentenceTransformer: {e}")
logger.error(f"Failed to load primary SentenceTransformer: {e}")
# Fallback - простая модель
try:
self.encoder = SentenceTransformer("all-MiniLM-L6-v2")
fallback_model = "all-MiniLM-L6-v2"
is_fallback_cached = self._is_model_cached(fallback_model)
if is_fallback_cached:
logger.info(f"🔍 Found cached fallback model: {fallback_model}")
else:
logger.info(f"🔽 Downloading fallback model: {fallback_model}")
self.encoder = SentenceTransformer(
fallback_model, cache_folder=MODELS_CACHE_DIR, local_files_only=is_fallback_cached
)
logger.info("🔍 Fallback SentenceTransformer model loaded")
except Exception:
logger.error("Failed to load any SentenceTransformer model")
self.encoder = None
def _is_model_cached(self, model_name: str) -> bool:
"""🔍 Проверяет наличие модели в кеше"""
try:
# Проверяем наличие папки модели в кеше
cache_path = Path(MODELS_CACHE_DIR)
# SentenceTransformer сохраняет модели в формате models--org--model-name
model_cache_name = f"models--sentence-transformers--{model_name}"
model_path = cache_path / model_cache_name
# Проверяем существование папки модели
if not model_path.exists():
return False
# Проверяем наличие snapshots папки (новый формат HuggingFace)
snapshots_path = model_path / "snapshots"
if snapshots_path.exists():
# Ищем любой snapshot с config.json
for snapshot_dir in snapshots_path.iterdir():
if snapshot_dir.is_dir():
config_file = snapshot_dir / "config.json"
if config_file.exists():
return True
# Fallback: проверяем старый формат
config_file = model_path / "config.json"
return config_file.exists()
except Exception as e:
logger.debug(f"Error checking model cache for {model_name}: {e}")
return False
async def async_init(self) -> None:
"""🔄 Асинхронная инициализация - восстановление индекса из файла"""
try:
logger.info("🔍 Пытаемся восстановить векторный индекс из файла...")
# Пытаемся загрузить из файла
if await self.load_index_from_file():
logger.info("✅ Векторный индекс восстановлен из файла")
else:
logger.info("🔍 Сохраненный индекс не найден, будет создан новый")
except Exception as e:
logger.error(f"❌ Ошибка при восстановлении индекса: {e}")
async def info(self) -> dict:
"""Return service information"""
return {
@@ -190,6 +287,15 @@ class MuveraWrapper:
elif indexed_count > 0:
logger.debug(f"🔍 Indexed {indexed_count} documents")
# 🗃️ Автосохранение индекса после успешной индексации
if indexed_count > 0:
try:
await self.save_index_to_file()
if not silent:
logger.debug("💾 Индекс автоматически сохранен в файл")
except Exception as e:
logger.warning(f"⚠️ Не удалось сохранить индекс в файл: {e}")
async def verify_documents(self, doc_ids: List[str]) -> Dict[str, Any]:
"""Verify which documents exist in the index"""
missing = [doc_id for doc_id in doc_ids if doc_id not in self.documents]
@@ -203,6 +309,87 @@ class MuveraWrapper:
"consistency": {"status": "ok", "null_embeddings_count": 0},
}
async def save_index_to_file(self, dump_dir: str = "./dump") -> bool:
"""🗃️ Сохраняет векторный индекс в файл (fallback метод)"""
try:
# Создаем директорию если не существует
dump_path = Path(dump_dir)
dump_path.mkdir(parents=True, exist_ok=True)
# Подготавливаем данные для сериализации
index_data = {
"documents": self.documents,
"embeddings": self.embeddings,
"vector_dimension": self.vector_dimension,
"buckets": self.buckets,
"timestamp": int(time.time()),
"version": "1.0",
}
# Сериализуем данные с pickle
serialized_data = pickle.dumps(index_data)
# Подготавливаем имена файлов
index_file = dump_path / f"{MUVERA_INDEX_NAME}_vector_index.pkl.gz"
# Сохраняем основной индекс с gzip сжатием
with gzip.open(index_file, "wb") as f:
f.write(serialized_data)
logger.info(f"🗃️ Векторный индекс сохранен в файл: {index_file}")
logger.info(f" 📊 Документов: {len(self.documents)}, эмбедингов: {len(self.embeddings)}")
return True
except Exception as e:
logger.error(f"❌ Ошибка сохранения индекса в файл: {e}")
return False
async def load_index_from_file(self, dump_dir: str = "./dump") -> bool:
"""🔄 Восстанавливает векторный индекс из файла"""
try:
dump_path = Path(dump_dir)
index_file = dump_path / f"{MUVERA_INDEX_NAME}_vector_index.pkl.gz"
# Проверяем существование файла
if not index_file.exists():
logger.debug(f"🔍 Сохраненный индекс не найден: {index_file}")
return False
# Загружаем и распаковываем данные
with gzip.open(index_file, "rb") as f:
serialized_data = f.read()
# Десериализуем данные
index_data = pickle.loads(serialized_data) # noqa: S301
# Проверяем версию совместимости
if index_data.get("version") != "1.0":
logger.warning(f"🔍 Несовместимая версия индекса: {index_data.get('version')}")
return False
# Восстанавливаем данные
self.documents = index_data["documents"]
self.embeddings = index_data["embeddings"]
self.vector_dimension = index_data["vector_dimension"]
self.buckets = index_data["buckets"]
file_size = int(index_file.stat().st_size)
decompression_ratio = len(serialized_data) / file_size if file_size > 0 else 1.0
logger.info("🔄 Векторный индекс восстановлен из файла:")
logger.info(f" 📁 Файл: {index_file}")
logger.info(f" 📊 Документов: {len(self.documents)}, эмбедингов: {len(self.embeddings)}")
logger.info(
f" 💾 Размер: {file_size:,}{len(serialized_data):,} байт (декомпрессия {decompression_ratio:.1f}x)"
)
return True
except Exception as e:
logger.error(f"❌ Ошибка восстановления индекса из файла: {e}")
return False
async def close(self) -> None:
"""Close the wrapper (no-op for this simple implementation)"""
@@ -227,6 +414,11 @@ class SearchService:
logger.error(f"Failed to initialize Muvera: {e}")
self.available = False
async def async_init(self) -> None:
"""🔄 Асинхронная инициализация - восстановление индекса"""
if self.muvera_client:
await self.muvera_client.async_init()
async def info(self) -> dict:
"""Return information about search service"""
if not self.available:
@@ -563,7 +755,10 @@ async def initialize_search_index(shouts_data: list) -> None:
return
try:
# Check if we need to reindex
# Сначала пытаемся восстановить существующий индекс
await search_service.async_init()
# Проверяем нужна ли переиндексация
if len(shouts_data) > 0:
await search_service.bulk_index(shouts_data)
logger.info(f"Initialized search index with {len(shouts_data)} documents")

8
uv.lock generated
View File

@@ -418,10 +418,13 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "redis", extra = ["hiredis"] },
{ name = "scikit-learn" },
{ name = "sentence-transformers" },
{ name = "sentry-sdk", extra = ["sqlalchemy", "starlette"] },
{ name = "sqlalchemy" },
{ name = "starlette" },
{ name = "torch" },
{ name = "transformers" },
{ name = "types-authlib" },
{ name = "types-orjson" },
{ name = "types-pyjwt" },
@@ -471,10 +474,13 @@ requires-dist = [
{ name = "pydantic" },
{ name = "pyjwt", specifier = ">=2.10" },
{ name = "redis", extras = ["hiredis"] },
{ name = "sentence-transformers" },
{ name = "scikit-learn", specifier = ">=1.7.0" },
{ name = "sentence-transformers", specifier = ">=2.2.0" },
{ name = "sentry-sdk", extras = ["starlette", "sqlalchemy"] },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "starlette" },
{ name = "torch", specifier = ">=2.0.0" },
{ name = "transformers", specifier = ">=4.56.0" },
{ name = "types-authlib" },
{ name = "types-orjson" },
{ name = "types-pyjwt" },