diff --git a/backend/app/main.py b/backend/app/main.py index cc1ffde..bbab7a0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.database import engine from app.models.base import Base -from app.routers import auth, tickets, search +from app.routers import auth, tickets, search, api_v1 from app.middleware import RateLimitMiddleware from app.metrics import MetricsMiddleware, metrics_endpoint @@ -38,6 +38,7 @@ app.add_middleware(RateLimitMiddleware, requests_per_minute=30) app.include_router(auth.router) app.include_router(tickets.router) app.include_router(search.router) +app.include_router(api_v1.router) app.add_route("/api/metrics", metrics_endpoint, methods=["GET"]) diff --git a/backend/app/routers/api_v1.py b/backend/app/routers/api_v1.py new file mode 100644 index 0000000..4a0ff9e --- /dev/null +++ b/backend/app/routers/api_v1.py @@ -0,0 +1,153 @@ +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) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ffb2bd4..d9b1a51 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -120,4 +120,6 @@ export const api = { body: JSON.stringify({ tickets }), }, ), + + rotateApiKey: () => request<{ api_key: string }>('/api/v1/me/api-key', { method: 'PATCH' }), } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d1ca465..bfcec2c 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -4,6 +4,8 @@ import { api, getToken, User } from '../api/client' export default function Settings() { const [user, setUser] = useState(null) + const [copied, setCopied] = useState(false) + const [rotating, setRotating] = useState(false) useEffect(() => { if (!getToken()) { @@ -13,6 +15,26 @@ export default function Settings() { api.me().then(setUser) }, []) + async function copyKey() { + if (!user) return + await navigator.clipboard.writeText(user.api_key) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + async function rotateKey() { + if (!confirm('Сгенерировать новый API-ключ? Старый перестанет работать.')) return + setRotating(true) + try { + const res = await api.rotateApiKey() + setUser(prev => prev ? { ...prev, api_key: res.api_key } : null) + } catch (err: any) { + alert('Ошибка: ' + err.message) + } finally { + setRotating(false) + } + } + return (
@@ -24,7 +46,26 @@ export default function Settings() { {user && (

Email: {user.email}

-

API Key: {user.api_key}

+

+ API Key:{' '} + {user.api_key} + + +

+

+ REST API:{' '} + + POST /api/v1/search + +

+

+ Используйте Authorization: Bearer <API_Key> для доступа к API. + Документация: /api/docs +

План: {user.plan}

Дата регистрации: {new Date(user.created_at).toLocaleDateString()}