Бэкенд:
- 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
112 lines
3.3 KiB
Python
112 lines
3.3 KiB
Python
import hashlib
|
|
|
|
from qdrant_client import QdrantClient
|
|
from qdrant_client.http.models import (
|
|
PointStruct,
|
|
Filter,
|
|
FieldCondition,
|
|
MatchValue,
|
|
VectorParams,
|
|
Distance,
|
|
)
|
|
|
|
from app.config import settings
|
|
|
|
client = QdrantClient(url=settings.qdrant_url)
|
|
COLLECTION_NAME = "tickets"
|
|
_vector_size: int | None = None
|
|
|
|
|
|
def get_vector_size() -> int:
|
|
global _vector_size
|
|
if _vector_size is not None:
|
|
return _vector_size
|
|
|
|
collections = client.get_collections().collections
|
|
for c in collections:
|
|
if c.name == COLLECTION_NAME:
|
|
info = client.get_collection(COLLECTION_NAME)
|
|
_vector_size = info.config.params.vectors.size
|
|
return _vector_size
|
|
|
|
return 0
|
|
|
|
|
|
async def ensure_collection(size: int):
|
|
global _vector_size
|
|
collections = client.get_collections().collections
|
|
exists = any(c.name == COLLECTION_NAME for c in collections)
|
|
|
|
if not exists:
|
|
client.create_collection(
|
|
collection_name=COLLECTION_NAME,
|
|
vectors_config=VectorParams(size=size, distance=Distance.COSINE),
|
|
)
|
|
_vector_size = size
|
|
else:
|
|
get_vector_size()
|
|
|
|
|
|
def _point_id(tenant_id: str, ticket_id: str) -> int:
|
|
return int(hashlib.md5(f"{tenant_id}:{ticket_id}".encode()).hexdigest()[:16], 16)
|
|
|
|
|
|
def get_existing_ticket_ids(tenant_id: str, collection_id: str) -> set[str]:
|
|
scroll_filter = Filter(must=[
|
|
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
|
|
FieldCondition(key="collection_id", match=MatchValue(value=collection_id)),
|
|
])
|
|
points, _ = client.scroll(
|
|
collection_name=COLLECTION_NAME,
|
|
limit=10000,
|
|
filter=scroll_filter,
|
|
with_payload=True,
|
|
with_vectors=False,
|
|
)
|
|
return {p.payload["ticket_id"] for p in points if "ticket_id" in (p.payload or {})}
|
|
|
|
|
|
async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):
|
|
size = len(vectors[0])
|
|
await ensure_collection(size)
|
|
|
|
points = []
|
|
for chunk, vector in zip(chunks, vectors):
|
|
points.append(
|
|
PointStruct(
|
|
id=_point_id(tenant_id, chunk["ticket_id"]),
|
|
vector=vector,
|
|
payload={
|
|
"tenant_id": tenant_id,
|
|
"ticket_id": chunk["ticket_id"],
|
|
"client": chunk.get("client", ""),
|
|
"category": chunk.get("category", ""),
|
|
"collection_id": chunk.get("collection_id", ""),
|
|
"search_text": chunk.get("search_text", ""),
|
|
"full_text": chunk.get("full_text", ""),
|
|
},
|
|
)
|
|
)
|
|
|
|
client.upsert(collection_name=COLLECTION_NAME, points=points)
|
|
|
|
|
|
async def search(
|
|
tenant_id: str,
|
|
query_vector: list[float],
|
|
collection_id: str | None = None,
|
|
k: int = 20,
|
|
):
|
|
must_conditions = [FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))]
|
|
if collection_id:
|
|
must_conditions.append(FieldCondition(key="collection_id", match=MatchValue(value=collection_id)))
|
|
|
|
results = client.query_points(
|
|
collection_name=COLLECTION_NAME,
|
|
query=query_vector,
|
|
query_filter=Filter(must=must_conditions),
|
|
limit=k,
|
|
with_payload=True,
|
|
)
|
|
|
|
return results.points
|