- Добавлен роутер /api/v1/ с аутентификацией через API-ключ - Endpoints: GET /api/v1/me, PATCH /api/v1/me/api-key, GET /api/v1/collections, POST /api/v1/tickets, POST /api/v1/search - На странице настроек: копирование и сброс API-ключа
153 lines
4.8 KiB
Python
153 lines
4.8 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, 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.database import get_db
|
|
from app.models.collection import Collection
|
|
from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse, UserResponse, CollectionResponse
|
|
from app.models.search_schemas import SearchRequest, SearchResponse, SourceItem
|
|
from app.models.user import User
|
|
from app.services.embedding import embed_query
|
|
from app.services.vector_store import search as qdrant_search, get_existing_ticket_ids
|
|
from app.services.reranking import rerank
|
|
from app.services.llm import generate_answer
|
|
from app.services.chunking import build_chunk
|
|
from app.services.embedding import embed_texts
|
|
from app.services.vector_store import upsert_chunks
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["API v1"])
|
|
|
|
FETCH_K = 20
|
|
FINAL_K = 5
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_me(user: User = Depends(get_user_by_api_key)):
|
|
return user
|
|
|
|
|
|
@router.patch("/me/api-key")
|
|
async def rotate_api_key(
|
|
user: User = Depends(get_user_by_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
user.api_key = str(uuid.uuid4().hex[:32])
|
|
await db.commit()
|
|
return {"api_key": user.api_key}
|
|
|
|
|
|
@router.get("/collections", response_model=list[CollectionResponse])
|
|
async def list_collections(
|
|
user: User = Depends(get_user_by_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Collection).where(Collection.user_id == user.id).order_by(Collection.created_at.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("/tickets", response_model=TicketsUploadResponse)
|
|
async def upload_tickets(
|
|
body: TicketsUpload,
|
|
collection_id: uuid.UUID,
|
|
user: User = Depends(get_user_by_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
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 body.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("/search", response_model=SearchResponse)
|
|
async def search(
|
|
body: SearchRequest,
|
|
user: User = Depends(get_user_by_api_key),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Collection).where(
|
|
Collection.id == uuid.UUID(body.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")
|
|
|
|
query_vector = await embed_query(body.query)
|
|
|
|
qdrant_results = await qdrant_search(
|
|
tenant_id=str(user.id),
|
|
query_vector=query_vector,
|
|
collection_id=body.collection_id,
|
|
k=FETCH_K,
|
|
)
|
|
|
|
if not qdrant_results:
|
|
return SearchResponse(
|
|
answer="Недостаточно информации в истории обращений" if body.generate_answer else None,
|
|
sources=[],
|
|
)
|
|
|
|
reranked = rerank(body.query, qdrant_results, k=FINAL_K)
|
|
|
|
sources = [
|
|
SourceItem(ticket_id=r["ticket_id"], score=r["score"], category=r["category"], full_text=r["full_text"])
|
|
for r in reranked
|
|
]
|
|
|
|
answer = None
|
|
if body.generate_answer:
|
|
context = "\n\n".join(
|
|
f"Тикет №{r['ticket_id']} ({r['category']}):\n{r['full_text']}"
|
|
for r in reranked
|
|
)
|
|
answer = await generate_answer(body.query, context)
|
|
|
|
return SearchResponse(answer=answer, sources=sources)
|