From 41af532e7bc8fcd32e3eb0267250799166c87796 Mon Sep 17 00:00:00 2001 From: ed0ss Date: Sat, 20 Jun 2026 19:51:08 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BC=D0=B0=D1=81=D1=88=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D1=8D=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=20=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд: - 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 --- backend/app/auth/hashing.py | 8 +- backend/app/config.py | 2 +- backend/app/main.py | 7 +- backend/app/models/schemas.py | 17 +++- backend/app/routers/auth.py | 16 ++++ backend/app/routers/tickets.py | 9 +- backend/app/services/chunking.py | 8 +- backend/app/services/embedding.py | 52 ++++++++--- backend/app/services/llm.py | 68 ++++++++++---- backend/app/services/reranking.py | 24 ++--- backend/app/services/vector_store.py | 23 ++++- backend/requirements.txt | 5 +- frontend/nginx.conf | 4 + frontend/src/api/client.ts | 39 +++++++- frontend/src/pages/CollectionView.tsx | 97 ++++++++++++++++++-- frontend/src/pages/Register.tsx | 49 ++++++++-- k8s/00-namespace.yaml | 4 + k8s/01-postgres.yaml | 81 +++++++++++++++++ k8s/02-redis.yaml | 74 ++++++++++++++++ k8s/03-qdrant.yaml | 79 +++++++++++++++++ k8s/04-backend.yaml | 69 +++++++++++++++ k8s/05-frontend.yaml | 36 ++++++++ k8s/06-ingress.yaml | 30 +++++++ k8s/traefik/00-crds.yaml | 3 + k8s/traefik/01-core.yaml | 123 ++++++++++++++++++++++++++ test_upload.py | 30 +++++++ 26 files changed, 879 insertions(+), 78 deletions(-) create mode 100644 k8s/00-namespace.yaml create mode 100644 k8s/01-postgres.yaml create mode 100644 k8s/02-redis.yaml create mode 100644 k8s/03-qdrant.yaml create mode 100644 k8s/04-backend.yaml create mode 100644 k8s/05-frontend.yaml create mode 100644 k8s/06-ingress.yaml create mode 100644 k8s/traefik/00-crds.yaml create mode 100644 k8s/traefik/01-core.yaml create mode 100644 test_upload.py diff --git a/backend/app/auth/hashing.py b/backend/app/auth/hashing.py index 553f089..a1e3a9f 100644 --- a/backend/app/auth/hashing.py +++ b/backend/app/auth/hashing.py @@ -1,11 +1,9 @@ -from passlib.context import CryptContext - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +import bcrypt def hash_password(password: str) -> str: - return pwd_context.hash(password) + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() def verify_password(plain: str, hashed: str) -> bool: - return pwd_context.verify(plain, hashed) + return bcrypt.checkpw(plain.encode(), hashed.encode()) diff --git a/backend/app/config.py b/backend/app/config.py index 2592502..4e71d1a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free" - openrouter_llm_model: str = "google/gemma-4-31b-it:free" + openrouter_llm_model: str = "poolside/laguna-m.1:free" jwt_secret: str = "change-me-in-production" jwt_algorithm: str = "HS256" diff --git a/backend/app/main.py b/backend/app/main.py index c0fbc04..cc1ffde 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,7 +22,12 @@ app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["https://sovet.itoservice.ru", "https://api.sovet.itoservice.ru"], + allow_origins=[ + "https://sovet.itoservice.ru", + "https://api.sovet.itoservice.ru", + "http://sovet.itoservice.ru", + "http://api.sovet.itoservice.ru", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index ba808e0..271676b 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -1,12 +1,20 @@ import uuid from datetime import datetime -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator class RegisterRequest(BaseModel): email: EmailStr password: str + plan: str = "free" + + @field_validator("plan") + @classmethod + def validate_plan(cls, v: str) -> str: + if v not in ("free", "starter", "pro"): + raise ValueError("Invalid plan. Choose: free, starter, pro") + return v class LoginRequest(BaseModel): @@ -49,12 +57,17 @@ class TicketMessage(BaseModel): class TicketInput(BaseModel): - ticket_id: str + ticket_id: int | str category: str = "" description: str = "" client: str = "" messages: list[TicketMessage] = [] + @field_validator("ticket_id", mode="before") + @classmethod + def coerce_ticket_id(cls, v: int | str) -> str: + return str(v) + class TicketsUpload(BaseModel): tickets: list[TicketInput] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b9a622a..df68a20 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -33,6 +33,7 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): email=body.email, password_hash=hash_password(body.password), api_key=api_key, + plan=body.plan, ) db.add(user) await db.commit() @@ -80,6 +81,21 @@ async def list_collections( return result.scalars().all() +@router.get("/collections/{collection_id}", response_model=CollectionResponse) +async def get_collection( + collection_id: uuid.UUID, + user: User = Depends(get_current_user), + 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") + return collection + + @router.delete("/collections/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_collection( collection_id: uuid.UUID, diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py index af3155e..40c6b57 100644 --- a/backend/app/routers/tickets.py +++ b/backend/app/routers/tickets.py @@ -15,7 +15,7 @@ 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 +from app.services.vector_store import upsert_chunks, get_existing_ticket_ids router = APIRouter(prefix="/api/collections", tags=["tickets"]) @@ -33,11 +33,16 @@ async def process_tickets( 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, @@ -73,7 +78,7 @@ async def process_tickets( async def upload_tickets_json( collection_id: uuid.UUID, body: TicketsUpload, - user: User = Depends(get_user_by_api_key), + user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await process_tickets(db, collection_id, user, body.tickets) diff --git a/backend/app/services/chunking.py b/backend/app/services/chunking.py index 545608f..2c4388f 100644 --- a/backend/app/services/chunking.py +++ b/backend/app/services/chunking.py @@ -17,10 +17,14 @@ def clean_text(text: str) -> str: return text +SUPPORT_ROLES = {"support", "assistant", "operator", "agent"} +CLIENT_ROLES = {"client", "user", "customer"} + + def has_real_solution(messages: list[dict]) -> Optional[str]: support_texts = [] for m in messages: - if m["role"] == "support": + if m["role"] in SUPPORT_ROLES: cleaned = clean_text(m["text"]) if cleaned: support_texts.append(cleaned) @@ -40,7 +44,7 @@ def has_real_solution(messages: list[dict]) -> Optional[str]: def get_client_dialogue(messages: list[dict]) -> str: texts = [] for m in messages: - if m["role"] == "client": + if m["role"] in CLIENT_ROLES: cleaned = clean_text(m["text"]) if cleaned: texts.append(cleaned) diff --git a/backend/app/services/embedding.py b/backend/app/services/embedding.py index aca186a..fc790bd 100644 --- a/backend/app/services/embedding.py +++ b/backend/app/services/embedding.py @@ -1,15 +1,31 @@ -from openai import AsyncOpenAI +import logging + +import httpx from app.config import settings -client = AsyncOpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=settings.openrouter_api_key, -) +logger = logging.getLogger(__name__) +OPENROUTER_BASE = "https://openrouter.ai/api/v1" EMBEDDING_MODEL = settings.openrouter_embedding_model IS_NVIDIA = "nvidia" in EMBEDDING_MODEL +_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=30, + ) + return _client + def _prefix_text(text: str, prefix: str) -> str: if IS_NVIDIA: @@ -19,17 +35,27 @@ def _prefix_text(text: str, prefix: str) -> str: async def embed_texts(texts: list[str]) -> list[list[float]]: prefixed = [_prefix_text(t, "passage") for t in texts] - response = await client.embeddings.create( - model=EMBEDDING_MODEL, - input=prefixed, + client = get_client() + response = await client.post( + "/embeddings", + json={"model": EMBEDDING_MODEL, "input": prefixed}, ) - return [item.embedding for item in response.data] + data = response.json() + if not data.get("data"): + logger.error("Empty embedding response: %s", data) + return [[0.0] * 2048 for _ in texts] + return [item["embedding"] for item in data["data"]] async def embed_query(text: str) -> list[float]: prefixed = _prefix_text(text, "query") - response = await client.embeddings.create( - model=EMBEDDING_MODEL, - input=[prefixed], + client = get_client() + response = await client.post( + "/embeddings", + json={"model": EMBEDDING_MODEL, "input": [prefixed]}, ) - return response.data[0].embedding + data = response.json() + if not data.get("data"): + logger.error("Empty embedding response for query: %s", data) + return [0.0] * 2048 + return data["data"][0]["embedding"] diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 095c326..69f1290 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -1,11 +1,32 @@ -from openai import OpenAI +import logging + +import httpx from app.config import settings -client = OpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=settings.openrouter_api_key, -) +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 = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст). @@ -17,17 +38,28 @@ SYSTEM_PROMPT = """Ты — специалист техподдержки. От - Укажи в конце: «Основано на тикете №...»""" -async def generate_answer(query: str, context: str) -> str: - response = await client.chat.completions.create( - 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, +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, + }, ) - return response.choices[0].message.content + 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"] diff --git a/backend/app/services/reranking.py b/backend/app/services/reranking.py index 2fca8be..07a5537 100644 --- a/backend/app/services/reranking.py +++ b/backend/app/services/reranking.py @@ -1,26 +1,20 @@ -from sentence_transformers import CrossEncoder - -reranker = CrossEncoder( - "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", - max_length=512, - device="cpu", -) +from qdrant_client.models import ScoredPoint -def rerank(query: str, results: list[dict], k: int = 5) -> list[dict]: +def rerank(query: str, results: list[ScoredPoint], k: int = 5) -> list[dict]: if not results: return [] - pairs = [(query, r["payload"]["full_text"]) for r in results] - scores = reranker.predict(pairs) + def get_payload(p: ScoredPoint, key: str, default=""): + return (p.payload or {}).get(key, default) scored = [] - for i, score in enumerate(scores): + for r in results: scored.append({ - "ticket_id": results[i]["payload"]["ticket_id"], - "score": float(score), - "full_text": results[i]["payload"]["full_text"], - "category": results[i]["payload"].get("category", ""), + "ticket_id": get_payload(r, "ticket_id"), + "score": float(r.score or 0), + "full_text": get_payload(r, "full_text"), + "category": get_payload(r, "category"), }) scored.sort(key=lambda x: x["score"], reverse=True) diff --git a/backend/app/services/vector_store.py b/backend/app/services/vector_store.py index 2c57aa8..816fc12 100644 --- a/backend/app/services/vector_store.py +++ b/backend/app/services/vector_store.py @@ -1,3 +1,5 @@ +import hashlib + from qdrant_client import QdrantClient from qdrant_client.http.models import ( PointStruct, @@ -45,6 +47,25 @@ async def ensure_collection(size: int): 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) @@ -53,7 +74,7 @@ async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[f for chunk, vector in zip(chunks, vectors): points.append( PointStruct( - id=hash(f"{tenant_id}:{chunk['ticket_id']}") & 0x7FFFFFFFFFFFFFFF, + id=_point_id(tenant_id, chunk["ticket_id"]), vector=vector, payload={ "tenant_id": tenant_id, diff --git a/backend/requirements.txt b/backend/requirements.txt index 7c5b774..29b9f7d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,11 +8,8 @@ asyncpg==0.29.0 redis==5.1.0 qdrant-client==1.11.0 httpx==0.27.0 -openai==1.54.0 celery==5.4.0 pyjwt==2.9.0 -passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 python-multipart==0.0.12 -sentence-transformers==3.2.0 -numpy==1.26.4 prometheus-client==0.20.0 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 759d9b3..2434b4a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,6 +5,8 @@ server { root /usr/share/nginx/html; index index.html; + client_max_body_size 50M; + location / { try_files $uri $uri/ /index.html; } @@ -14,5 +16,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; + proxy_send_timeout 120s; } } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 47ac1b9..31f4f85 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -55,10 +55,10 @@ export interface SearchResult { } export const api = { - register: (email: string, password: string) => + register: (email: string, password: string, plan = 'free') => request<{ access_token: string }>('/register', { method: 'POST', - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, plan }), }), login: (email: string, password: string) => @@ -77,6 +77,8 @@ export const api = { listCollections: () => request('/collections'), + getCollection: (id: string) => request(`/collections/${id}`), + deleteCollection: (id: string) => request(`/collections/${id}`, { method: 'DELETE' }), @@ -85,4 +87,37 @@ export const api = { method: 'POST', body: JSON.stringify({ collection_id: collectionId, query, generate_answer: generateAnswer }), }), + + uploadFile: async (collectionId: string, file: File) => { + const formData = new FormData() + formData.append('file', file) + + const currentToken = getToken() + const headers: Record = {} + if (currentToken) headers['Authorization'] = `Bearer ${currentToken}` + + const res = await fetch(`${API_BASE}/collections/${collectionId}/tickets/upload`, { + method: 'POST', + headers, + body: formData, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + let detail = res.statusText + try { const j = JSON.parse(text); detail = j.detail || detail } catch {} + throw new Error(`Upload failed (${res.status}): ${detail}`) + } + + return res.json() as Promise<{ processed: number; skipped: number; collection_ticket_count: number }> + }, + + uploadTickets: (collectionId: string, tickets: any[]) => + request<{ processed: number; skipped: number; collection_ticket_count: number }>( + `/collections/${collectionId}/tickets`, + { + method: 'POST', + body: JSON.stringify({ tickets }), + }, + ), } diff --git a/frontend/src/pages/CollectionView.tsx b/frontend/src/pages/CollectionView.tsx index a0f68bd..03145b8 100644 --- a/frontend/src/pages/CollectionView.tsx +++ b/frontend/src/pages/CollectionView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { api, getToken } from '../api/client' @@ -7,12 +7,18 @@ export default function CollectionView() { const [query, setQuery] = useState('') const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + const [uploadResult, setUploadResult] = useState(null) + const [ticketCount, setTicketCount] = useState(0) + const fileRef = useRef(null) useEffect(() => { if (!getToken()) { window.location.href = '/login' + return } - }, []) + api.getCollection(id!).then(c => setTicketCount(c.ticket_count)).catch(() => {}) + }, [id]) async function handleSearch(e: React.FormEvent) { e.preventDefault() @@ -29,21 +35,102 @@ export default function CollectionView() { } } + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + setUploading(true) + setUploadResult(null) + try { + const res = await api.uploadFile(id!, file) + setUploadResult(`Загружено: ${res.processed} тикетов, пропущено: ${res.skipped}`) + setTicketCount(res.collection_ticket_count) + } catch (err: any) { + setUploadResult(`Ошибка: ${err.message}`) + } finally { + setUploading(false) + if (fileRef.current) fileRef.current.value = '' + } + } + + async function handlePasteJson() { + const text = prompt('Вставьте JSON с тикетами (массив объектов):') + if (!text) return + + try { + const tickets = JSON.parse(text) + const data = Array.isArray(tickets) ? tickets : [tickets] + const res = await api.uploadTickets(id!, data) + setUploadResult(`Загружено: ${res.processed} тикетов, пропущено: ${res.skipped}`) + setTicketCount(res.collection_ticket_count) + } catch (err: any) { + setUploadResult(`Ошибка: ${err.message}`) + } + } + return (
← Назад к списку -

Поиск по базе знаний

+

База знаний

-
+
+

Загрузка тикетов

+ {ticketCount > 0 && ( +

+ Тикетов в базе: {ticketCount} +

+ )} +
+ + +
+ {uploadResult && ( +

+ {uploadResult} +

+ )} +
+ +

Поиск по базе знаний

+ +