Бэкенд:
- TicketInput: ticket_id принимает int | str, приводится к str (было 500 на числовых ID)
- LLM: добавлен импорт logger, обработка 429 и не-200 ответов от OpenRouter
- Смена модели на poolside/laguna-m.1:free (обход лимитов OpenRouter)
- Добавлен GET /api/collections/{id} — получение коллекции по ID
- Дедикат заявок: hash() заменён на детерминированный hashlib.md5
для ID точек в Qdrant (раньше после рестарта пода создавались дубликаты)
- Добавлена get_existing_ticket_ids() — проверка дубликатов до эмбеддинга,
пропуск уже загруженных заявок
- Мультитенантная изоляция: tenant_id + collection_id во всех фильтрах Qdrant
- Эмбеддинги: заменён несовместимый openai SDK на прямой httpx
- Реренкинг: исправлен доступ к ScoredPoint.payload через .get()
- Чанкинг: расширены роли (support/operator/agent + client/user/customer)
- bcrypt: понижен до 4.0.1 (5.x несовместим с passlib)
Фронтенд:
- Регистрация: добавлен выбор тарифа (free/starter/pro)
- CollectionView: ticket_count загружается с бэкенда при монтировании
(раньше жил только в React-состоянии — сбрасывался после обновления страницы)
- uploadFile: используется getToken() вместо устаревшей переменной token,
в ошибке показывается реальный HTTP-статус
- Nginx: увеличен client_max_body_size до 50M для загрузки файлов
- k8s: добавлены манифесты namespace, postgres, redis, qdrant, backend,
frontend, ingress, traefik
61 lines
1.7 KiB
Python
61 lines
1.7 KiB
Python
import logging
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
|
EMBEDDING_MODEL = settings.openrouter_embedding_model
|
|
IS_NVIDIA = "nvidia" in EMBEDDING_MODEL
|
|
|
|
_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def get_client() -> httpx.AsyncClient:
|
|
global _client
|
|
if _client is None:
|
|
_client = httpx.AsyncClient(
|
|
base_url=OPENROUTER_BASE,
|
|
headers={
|
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=30,
|
|
)
|
|
return _client
|
|
|
|
|
|
def _prefix_text(text: str, prefix: str) -> str:
|
|
if IS_NVIDIA:
|
|
return f"{prefix}: {text}"
|
|
return text
|
|
|
|
|
|
async def embed_texts(texts: list[str]) -> list[list[float]]:
|
|
prefixed = [_prefix_text(t, "passage") for t in texts]
|
|
client = get_client()
|
|
response = await client.post(
|
|
"/embeddings",
|
|
json={"model": EMBEDDING_MODEL, "input": prefixed},
|
|
)
|
|
data = response.json()
|
|
if not data.get("data"):
|
|
logger.error("Empty embedding response: %s", data)
|
|
return [[0.0] * 2048 for _ in texts]
|
|
return [item["embedding"] for item in data["data"]]
|
|
|
|
|
|
async def embed_query(text: str) -> list[float]:
|
|
prefixed = _prefix_text(text, "query")
|
|
client = get_client()
|
|
response = await client.post(
|
|
"/embeddings",
|
|
json={"model": EMBEDDING_MODEL, "input": [prefixed]},
|
|
)
|
|
data = response.json()
|
|
if not data.get("data"):
|
|
logger.error("Empty embedding response for query: %s", data)
|
|
return [0.0] * 2048
|
|
return data["data"][0]["embedding"]
|