support-bot-saas/backend/app/routers/tickets.py
ed0ss 41af532e7b
Some checks are pending
CI / lint-backend (push) Waiting to run
CI / lint-frontend (push) Waiting to run
CI / build-backend (push) Blocked by required conditions
CI / build-frontend (push) Blocked by required conditions
fix: масштабные исправления и улучшения бэкенда и фронтенда
Бэкенд:
- 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
2026-06-20 19:51:58 +03:00

118 lines
3.8 KiB
Python

import uuid
import json
import csv
import io
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.api_key import get_user_by_api_key
from app.auth.deps import get_current_user
from app.database import get_db
from app.models.collection import Collection
from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse
from app.models.user import User
from app.services.chunking import build_chunk
from app.services.embedding import embed_texts
from app.services.vector_store import upsert_chunks, get_existing_ticket_ids
router = APIRouter(prefix="/api/collections", tags=["tickets"])
async def process_tickets(
db: AsyncSession,
collection_id: uuid.UUID,
user: User,
tickets: list[TicketInput],
) -> TicketsUploadResponse:
result = await db.execute(
select(Collection).where(Collection.id == collection_id, Collection.user_id == user.id)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
existing_ids = get_existing_ticket_ids(str(user.id), str(collection_id))
processed = 0
skipped = 0
chunks = []
for t in tickets:
if t.ticket_id in existing_ids:
skipped += 1
continue
ticket_dict = {
"ticket_id": t.ticket_id,
"category": t.category,
"description": t.description,
"client": t.client,
"messages": [{"role": m.role, "text": m.text} for m in t.messages],
}
chunk = build_chunk(ticket_dict)
if chunk:
chunk["tenant_id"] = str(user.id)
chunk["collection_id"] = str(collection_id)
chunks.append(chunk)
processed += 1
else:
skipped += 1
if chunks:
search_texts = [c["search_text"] for c in chunks]
vectors = await embed_texts(search_texts)
await upsert_chunks(str(user.id), chunks, vectors)
collection.ticket_count += processed
await db.commit()
return TicketsUploadResponse(
processed=processed,
skipped=skipped,
collection_ticket_count=collection.ticket_count,
)
@router.post("/{collection_id}/tickets", response_model=TicketsUploadResponse)
async def upload_tickets_json(
collection_id: uuid.UUID,
body: TicketsUpload,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await process_tickets(db, collection_id, user, body.tickets)
@router.post("/{collection_id}/tickets/upload", response_model=TicketsUploadResponse)
async def upload_tickets_file(
collection_id: uuid.UUID,
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
if file.filename.endswith(".json"):
data = json.loads(content)
tickets = [TicketInput(**t) for t in data] if isinstance(data, list) else [TicketInput(**data)]
elif file.filename.endswith(".csv"):
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
tickets = []
for row in reader:
tickets.append(
TicketInput(
ticket_id=row.get("ticket_id", ""),
category=row.get("category", ""),
description=row.get("description", ""),
client=row.get("client", ""),
)
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported format. Use JSON or CSV.",
)
return await process_tickets(db, collection_id, user, tickets)