Бэкенд:
- 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
65 lines
2.5 KiB
Python
65 lines
2.5 KiB
Python
import logging
|
||
|
||
import httpx
|
||
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Increase log level for httpx to reduce noise
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
|
||
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
||
|
||
_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=60,
|
||
)
|
||
return _client
|
||
|
||
|
||
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||
|
||
Правила:
|
||
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
||
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
||
- НЕ придумывай ответы, НЕ используй общие знания
|
||
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
||
- Укажи в конце: «Основано на тикете №...»"""
|
||
|
||
|
||
async def generate_answer(query: str, context: str) -> str | None:
|
||
client = get_client()
|
||
response = await client.post(
|
||
"/chat/completions",
|
||
json={
|
||
"model": settings.openrouter_llm_model,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{
|
||
"role": "user",
|
||
"content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}",
|
||
},
|
||
],
|
||
"temperature": 0.1,
|
||
"max_tokens": 2000,
|
||
},
|
||
)
|
||
if response.status_code != 200:
|
||
logger.error("LLM API error [%s]: %s", response.status_code, await response.aread())
|
||
return None
|
||
data = response.json()
|
||
if "choices" not in data or not data["choices"]:
|
||
logger.error("LLM response missing choices: %s", data.get("error", data))
|
||
return None
|
||
return data["choices"][0]["message"]["content"]
|