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
This commit is contained in:
parent
0063df0a87
commit
41af532e7b
26 changed files with 879 additions and 78 deletions
|
|
@ -1,11 +1,9 @@
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
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:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
return pwd_context.verify(plain, hashed)
|
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free"
|
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_secret: str = "change-me-in-production"
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
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):
|
class LoginRequest(BaseModel):
|
||||||
|
|
@ -49,12 +57,17 @@ class TicketMessage(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class TicketInput(BaseModel):
|
class TicketInput(BaseModel):
|
||||||
ticket_id: str
|
ticket_id: int | str
|
||||||
category: str = ""
|
category: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
client: str = ""
|
client: str = ""
|
||||||
messages: list[TicketMessage] = []
|
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):
|
class TicketsUpload(BaseModel):
|
||||||
tickets: list[TicketInput]
|
tickets: list[TicketInput]
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
email=body.email,
|
email=body.email,
|
||||||
password_hash=hash_password(body.password),
|
password_hash=hash_password(body.password),
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
|
plan=body.plan,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -80,6 +81,21 @@ async def list_collections(
|
||||||
return result.scalars().all()
|
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)
|
@router.delete("/collections/{collection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_collection(
|
async def delete_collection(
|
||||||
collection_id: uuid.UUID,
|
collection_id: uuid.UUID,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.chunking import build_chunk
|
from app.services.chunking import build_chunk
|
||||||
from app.services.embedding import embed_texts
|
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"])
|
router = APIRouter(prefix="/api/collections", tags=["tickets"])
|
||||||
|
|
||||||
|
|
@ -33,11 +33,16 @@ async def process_tickets(
|
||||||
if not collection:
|
if not collection:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
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
|
processed = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
chunks = []
|
chunks = []
|
||||||
|
|
||||||
for t in tickets:
|
for t in tickets:
|
||||||
|
if t.ticket_id in existing_ids:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
ticket_dict = {
|
ticket_dict = {
|
||||||
"ticket_id": t.ticket_id,
|
"ticket_id": t.ticket_id,
|
||||||
"category": t.category,
|
"category": t.category,
|
||||||
|
|
@ -73,7 +78,7 @@ async def process_tickets(
|
||||||
async def upload_tickets_json(
|
async def upload_tickets_json(
|
||||||
collection_id: uuid.UUID,
|
collection_id: uuid.UUID,
|
||||||
body: TicketsUpload,
|
body: TicketsUpload,
|
||||||
user: User = Depends(get_user_by_api_key),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return await process_tickets(db, collection_id, user, body.tickets)
|
return await process_tickets(db, collection_id, user, body.tickets)
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,14 @@ def clean_text(text: str) -> str:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORT_ROLES = {"support", "assistant", "operator", "agent"}
|
||||||
|
CLIENT_ROLES = {"client", "user", "customer"}
|
||||||
|
|
||||||
|
|
||||||
def has_real_solution(messages: list[dict]) -> Optional[str]:
|
def has_real_solution(messages: list[dict]) -> Optional[str]:
|
||||||
support_texts = []
|
support_texts = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
if m["role"] == "support":
|
if m["role"] in SUPPORT_ROLES:
|
||||||
cleaned = clean_text(m["text"])
|
cleaned = clean_text(m["text"])
|
||||||
if cleaned:
|
if cleaned:
|
||||||
support_texts.append(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:
|
def get_client_dialogue(messages: list[dict]) -> str:
|
||||||
texts = []
|
texts = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
if m["role"] == "client":
|
if m["role"] in CLIENT_ROLES:
|
||||||
cleaned = clean_text(m["text"])
|
cleaned = clean_text(m["text"])
|
||||||
if cleaned:
|
if cleaned:
|
||||||
texts.append(cleaned)
|
texts.append(cleaned)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
from openai import AsyncOpenAI
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
client = AsyncOpenAI(
|
logger = logging.getLogger(__name__)
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=settings.openrouter_api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
||||||
EMBEDDING_MODEL = settings.openrouter_embedding_model
|
EMBEDDING_MODEL = settings.openrouter_embedding_model
|
||||||
IS_NVIDIA = "nvidia" in 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:
|
def _prefix_text(text: str, prefix: str) -> str:
|
||||||
if IS_NVIDIA:
|
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]]:
|
async def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||||
prefixed = [_prefix_text(t, "passage") for t in texts]
|
prefixed = [_prefix_text(t, "passage") for t in texts]
|
||||||
response = await client.embeddings.create(
|
client = get_client()
|
||||||
model=EMBEDDING_MODEL,
|
response = await client.post(
|
||||||
input=prefixed,
|
"/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]:
|
async def embed_query(text: str) -> list[float]:
|
||||||
prefixed = _prefix_text(text, "query")
|
prefixed = _prefix_text(text, "query")
|
||||||
response = await client.embeddings.create(
|
client = get_client()
|
||||||
model=EMBEDDING_MODEL,
|
response = await client.post(
|
||||||
input=[prefixed],
|
"/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"]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,32 @@
|
||||||
from openai import OpenAI
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
client = OpenAI(
|
logger = logging.getLogger(__name__)
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=settings.openrouter_api_key,
|
# 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 = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||||||
|
|
||||||
|
|
@ -17,17 +38,28 @@ SYSTEM_PROMPT = """Ты — специалист техподдержки. От
|
||||||
- Укажи в конце: «Основано на тикете №...»"""
|
- Укажи в конце: «Основано на тикете №...»"""
|
||||||
|
|
||||||
|
|
||||||
async def generate_answer(query: str, context: str) -> str:
|
async def generate_answer(query: str, context: str) -> str | None:
|
||||||
response = await client.chat.completions.create(
|
client = get_client()
|
||||||
model=settings.openrouter_llm_model,
|
response = await client.post(
|
||||||
messages=[
|
"/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": settings.openrouter_llm_model,
|
||||||
|
"messages": [
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}",
|
"content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature=0.1,
|
"temperature": 0.1,
|
||||||
max_tokens=2000,
|
"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"]
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
from sentence_transformers import CrossEncoder
|
from qdrant_client.models import ScoredPoint
|
||||||
|
|
||||||
reranker = CrossEncoder(
|
|
||||||
"cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
|
|
||||||
max_length=512,
|
|
||||||
device="cpu",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
if not results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
pairs = [(query, r["payload"]["full_text"]) for r in results]
|
def get_payload(p: ScoredPoint, key: str, default=""):
|
||||||
scores = reranker.predict(pairs)
|
return (p.payload or {}).get(key, default)
|
||||||
|
|
||||||
scored = []
|
scored = []
|
||||||
for i, score in enumerate(scores):
|
for r in results:
|
||||||
scored.append({
|
scored.append({
|
||||||
"ticket_id": results[i]["payload"]["ticket_id"],
|
"ticket_id": get_payload(r, "ticket_id"),
|
||||||
"score": float(score),
|
"score": float(r.score or 0),
|
||||||
"full_text": results[i]["payload"]["full_text"],
|
"full_text": get_payload(r, "full_text"),
|
||||||
"category": results[i]["payload"].get("category", ""),
|
"category": get_payload(r, "category"),
|
||||||
})
|
})
|
||||||
|
|
||||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from qdrant_client.http.models import (
|
from qdrant_client.http.models import (
|
||||||
PointStruct,
|
PointStruct,
|
||||||
|
|
@ -45,6 +47,25 @@ async def ensure_collection(size: int):
|
||||||
get_vector_size()
|
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]]):
|
async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):
|
||||||
size = len(vectors[0])
|
size = len(vectors[0])
|
||||||
await ensure_collection(size)
|
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):
|
for chunk, vector in zip(chunks, vectors):
|
||||||
points.append(
|
points.append(
|
||||||
PointStruct(
|
PointStruct(
|
||||||
id=hash(f"{tenant_id}:{chunk['ticket_id']}") & 0x7FFFFFFFFFFFFFFF,
|
id=_point_id(tenant_id, chunk["ticket_id"]),
|
||||||
vector=vector,
|
vector=vector,
|
||||||
payload={
|
payload={
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,8 @@ asyncpg==0.29.0
|
||||||
redis==5.1.0
|
redis==5.1.0
|
||||||
qdrant-client==1.11.0
|
qdrant-client==1.11.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
openai==1.54.0
|
|
||||||
celery==5.4.0
|
celery==5.4.0
|
||||||
pyjwt==2.9.0
|
pyjwt==2.9.0
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
sentence-transformers==3.2.0
|
|
||||||
numpy==1.26.4
|
|
||||||
prometheus-client==0.20.0
|
prometheus-client==0.20.0
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
@ -14,5 +16,7 @@ server {
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,10 @@ export interface SearchResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
register: (email: string, password: string) =>
|
register: (email: string, password: string, plan = 'free') =>
|
||||||
request<{ access_token: string }>('/register', {
|
request<{ access_token: string }>('/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, plan }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
|
|
@ -77,6 +77,8 @@ export const api = {
|
||||||
|
|
||||||
listCollections: () => request<Collection[]>('/collections'),
|
listCollections: () => request<Collection[]>('/collections'),
|
||||||
|
|
||||||
|
getCollection: (id: string) => request<Collection>(`/collections/${id}`),
|
||||||
|
|
||||||
deleteCollection: (id: string) =>
|
deleteCollection: (id: string) =>
|
||||||
request<void>(`/collections/${id}`, { method: 'DELETE' }),
|
request<void>(`/collections/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|
@ -85,4 +87,37 @@ export const api = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ collection_id: collectionId, query, generate_answer: generateAnswer }),
|
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<string, string> = {}
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { api, getToken } from '../api/client'
|
import { api, getToken } from '../api/client'
|
||||||
|
|
||||||
|
|
@ -7,12 +7,18 @@ export default function CollectionView() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [result, setResult] = useState<any>(null)
|
const [result, setResult] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadResult, setUploadResult] = useState<string | null>(null)
|
||||||
|
const [ticketCount, setTicketCount] = useState(0)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [])
|
api.getCollection(id!).then(c => setTicketCount(c.ticket_count)).catch(() => {})
|
||||||
|
}, [id])
|
||||||
|
|
||||||
async function handleSearch(e: React.FormEvent) {
|
async function handleSearch(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -29,21 +35,102 @@ export default function CollectionView() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
|
<div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
|
||||||
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
||||||
← Назад к списку
|
← Назад к списку
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1>Поиск по базе знаний</h1>
|
<h1>База знаний</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} style={{ margin: '20px 0' }}>
|
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 24 }}>
|
||||||
|
<h3 style={{ margin: '0 0 12px' }}>Загрузка тикетов</h3>
|
||||||
|
{ticketCount > 0 && (
|
||||||
|
<p style={{ fontSize: 13, color: '#666', marginBottom: 8 }}>
|
||||||
|
Тикетов в базе: {ticketCount}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#1976d2',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
{uploading ? 'Загрузка...' : 'Выбрать JSON/CSV файл'}
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,.csv"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={handlePasteJson}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ padding: '8px 16px', fontSize: 14 }}
|
||||||
|
>
|
||||||
|
Вставить JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{uploadResult && (
|
||||||
|
<p style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: uploadResult.startsWith('Ошибка') ? 'red' : 'green',
|
||||||
|
}}>
|
||||||
|
{uploadResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Поиск по базе знаний</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} style={{ margin: '12px 0' }}>
|
||||||
<textarea
|
<textarea
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Опишите проблему клиента..."
|
placeholder="Опишите проблему клиента..."
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
style={{ width: '100%', padding: 8, marginBottom: 8, boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
|
<button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
|
||||||
{loading ? 'Поиск...' : 'Найти'}
|
{loading ? 'Поиск...' : 'Найти'}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,23 @@ import { useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { api, setToken } from '../api/client'
|
import { api, setToken } from '../api/client'
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{ value: 'free', label: 'Free', desc: '1 база знаний, до 100 тикетов, без AI-генерации' },
|
||||||
|
{ value: 'starter', label: 'Starter', desc: '5 баз знаний, до 1000 тикетов, AI-ответы' },
|
||||||
|
{ value: 'pro', label: 'Pro', desc: 'Безлимитные базы знаний, 10 000+ тикетов, AI + приоритет' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [plan, setPlan] = useState('free')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
try {
|
||||||
const res = await api.register(email, password)
|
const res = await api.register(email, password, plan)
|
||||||
setToken(res.access_token)
|
setToken(res.access_token)
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -20,8 +27,8 @@ export default function Register() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
|
<div style={{ maxWidth: 500, margin: '60px auto', padding: 20 }}>
|
||||||
<h1>Регистрация</h1>
|
<h1 style={{ textAlign: 'center' }}>Регистрация</h1>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -30,7 +37,7 @@ export default function Register() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
style={{ width: '100%', padding: 8, marginBottom: 8, boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -40,15 +47,43 @@ export default function Register() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
style={{ width: '100%', padding: 8, marginBottom: 12, boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontWeight: 600, marginBottom: 8 }}>Выберите тариф:</p>
|
||||||
|
{PLANS.map(p => (
|
||||||
|
<label
|
||||||
|
key={p.value}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
border: `2px solid ${plan === p.value ? '#1976d2' : '#ddd'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: plan === p.value ? '#e3f2fd' : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="plan"
|
||||||
|
value={p.value}
|
||||||
|
checked={plan === p.value}
|
||||||
|
onChange={e => setPlan(e.target.value)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<strong>{p.label}</strong>
|
||||||
|
<span style={{ marginLeft: 8, color: '#666', fontSize: 13 }}>{p.desc}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||||
<button type="submit" style={{ width: '100%', padding: 10 }}>
|
<button type="submit" style={{ width: '100%', padding: 10, marginTop: 8 }}>
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p style={{ marginTop: 12 }}>
|
<p style={{ marginTop: 12, textAlign: 'center' }}>
|
||||||
Уже есть аккаунт? <Link to="/login">Войти</Link>
|
Уже есть аккаунт? <Link to="/login">Войти</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
4
k8s/00-namespace.yaml
Normal file
4
k8s/00-namespace.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: support-bot
|
||||||
81
k8s/01-postgres.yaml
Normal file
81
k8s/01-postgres.yaml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: postgres-pv
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 10Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
hostPath:
|
||||||
|
path: /data/support-bot/postgres
|
||||||
|
nodeAffinity:
|
||||||
|
required:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- worker-172.16.0.5
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: postgres-pvc
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: support_bot
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: postgres
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
value: postgres
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: postgres-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
74
k8s/02-redis.yaml
Normal file
74
k8s/02-redis.yaml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: redis-pv
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 5Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
hostPath:
|
||||||
|
path: /data/support-bot/redis
|
||||||
|
nodeAffinity:
|
||||||
|
required:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- worker-172.16.0.5
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: redis-pvc
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: redis-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: redis
|
||||||
|
ports:
|
||||||
|
- port: 6379
|
||||||
|
targetPort: 6379
|
||||||
79
k8s/03-qdrant.yaml
Normal file
79
k8s/03-qdrant.yaml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: qdrant-pv
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 20Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
hostPath:
|
||||||
|
path: /data/support-bot/qdrant
|
||||||
|
nodeAffinity:
|
||||||
|
required:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- worker-172.16.0.5
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: qdrant-pvc
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: qdrant
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: qdrant
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: qdrant
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: qdrant
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: qdrant
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 6333
|
||||||
|
- containerPort: 6334
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /qdrant/storage
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: qdrant-pvc
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: qdrant
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: qdrant
|
||||||
|
ports:
|
||||||
|
- port: 6333
|
||||||
|
targetPort: 6333
|
||||||
|
name: grpc
|
||||||
|
- port: 6334
|
||||||
|
targetPort: 6334
|
||||||
|
name: http
|
||||||
69
k8s/04-backend.yaml
Normal file
69
k8s/04-backend.yaml
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: git.itoservice.ru/dosnav/support-bot-saas/backend:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
value: postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
|
||||||
|
- name: REDIS_URL
|
||||||
|
value: redis://redis:6379/0
|
||||||
|
- name: QDRANT_URL
|
||||||
|
value: http://qdrant:6333
|
||||||
|
- name: OPENROUTER_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: app-env
|
||||||
|
key: OPENROUTER_API_KEY
|
||||||
|
- name: OPENROUTER_EMBEDDING_MODEL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: app-env
|
||||||
|
key: OPENROUTER_EMBEDDING_MODEL
|
||||||
|
- name: OPENROUTER_LLM_MODEL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: app-env
|
||||||
|
key: OPENROUTER_LLM_MODEL
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: app-env
|
||||||
|
key: JWT_SECRET
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backend
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
36
k8s/05-frontend.yaml
Normal file
36
k8s/05-frontend.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: frontend
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: frontend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: frontend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: frontend
|
||||||
|
image: git.itoservice.ru/dosnav/support-bot-saas/frontend:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: frontend
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: frontend
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
nodePort: 30080
|
||||||
|
type: NodePort
|
||||||
30
k8s/06-ingress.yaml
Normal file
30
k8s/06-ingress.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: frontend
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
routes:
|
||||||
|
- kind: Rule
|
||||||
|
match: Host(`sovet.itoservice.ru`)
|
||||||
|
services:
|
||||||
|
- name: frontend
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
routes:
|
||||||
|
- kind: Rule
|
||||||
|
match: Host(`api.sovet.itoservice.ru`) && (PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/openapi.json`) || PathPrefix(`/metrics`))
|
||||||
|
services:
|
||||||
|
- name: backend
|
||||||
|
port: 8000
|
||||||
3
k8s/traefik/00-crds.yaml
Normal file
3
k8s/traefik/00-crds.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# CRDs from upstream
|
||||||
|
# kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.7/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
|
||||||
|
# kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.7/docs/content/reference/dynamic-configuration/kubernetes-crd-rbac.yml
|
||||||
123
k8s/traefik/01-core.yaml
Normal file
123
k8s/traefik/01-core.yaml
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: traefik-ingress-controller
|
||||||
|
namespace: support-bot
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: ClusterRole
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: traefik-ingress-controller
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["services", "endpoints", "secrets", "configmaps", "endpointslices", "nodes"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["discovery.k8s.io"]
|
||||||
|
resources: ["endpointslices"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["extensions"]
|
||||||
|
resources: ["ingresses"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["networking.k8s.io"]
|
||||||
|
resources: ["ingresses", "ingressclasses"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["extensions", "networking.k8s.io"]
|
||||||
|
resources: ["ingresses/status"]
|
||||||
|
verbs: ["update"]
|
||||||
|
- apiGroups: ["traefik.io"]
|
||||||
|
resources: ["*"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
metadata:
|
||||||
|
name: traefik-ingress-controller
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: traefik-ingress-controller
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: traefik-ingress-controller
|
||||||
|
namespace: support-bot
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: support-bot
|
||||||
|
labels:
|
||||||
|
app: traefik
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: traefik
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: traefik
|
||||||
|
spec:
|
||||||
|
serviceAccountName: traefik-ingress-controller
|
||||||
|
hostNetwork: true
|
||||||
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
|
containers:
|
||||||
|
- name: traefik
|
||||||
|
image: traefik:v3.7
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
containerPort: 80
|
||||||
|
hostPort: 80
|
||||||
|
- name: websecure
|
||||||
|
containerPort: 443
|
||||||
|
hostPort: 443
|
||||||
|
- name: dashboard
|
||||||
|
containerPort: 9000
|
||||||
|
args:
|
||||||
|
- --entryPoints.web.address=:80
|
||||||
|
- --entryPoints.websecure.address=:443
|
||||||
|
- --entryPoints.traefik.address=:9000
|
||||||
|
- --providers.kubernetescrd
|
||||||
|
- --providers.kubernetesingress
|
||||||
|
- --api.dashboard=true
|
||||||
|
- --api.insecure=true
|
||||||
|
- --ping=true
|
||||||
|
- --log.level=INFO
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ping
|
||||||
|
port: 9000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ping
|
||||||
|
port: 9000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: support-bot
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: traefik
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
|
- name: websecure
|
||||||
|
port: 443
|
||||||
|
targetPort: 443
|
||||||
|
- name: dashboard
|
||||||
|
port: 9000
|
||||||
|
targetPort: 9000
|
||||||
30
test_upload.py
Normal file
30
test_upload.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import urllib.request, json
|
||||||
|
|
||||||
|
data = json.dumps({'email':'testplan@test.ru','password':'test123'}).encode()
|
||||||
|
req = urllib.request.Request('http://localhost:8000/api/login', data=data, headers={'Content-Type':'application/json'}, method='POST')
|
||||||
|
r = json.loads(urllib.request.urlopen(req).read())
|
||||||
|
token = r['access_token']
|
||||||
|
print('1. Login OK')
|
||||||
|
|
||||||
|
# Upload via multipart
|
||||||
|
boundary = '----TestBoundary123'
|
||||||
|
body_parts = []
|
||||||
|
body_parts.append('--' + boundary)
|
||||||
|
body_parts.append('Content-Disposition: form-data; name="file"; filename="test.json"')
|
||||||
|
body_parts.append('Content-Type: application/json')
|
||||||
|
body_parts.append('')
|
||||||
|
body_parts.append(json.dumps([{'ticket_id':'T-002','category':'network','description':'Network down','client':'Petr','messages':[{'role':'client','text':'internet not working'},{'role':'support','text':'try rebooting the router'}]}]))
|
||||||
|
body_parts.append('--' + boundary + '--')
|
||||||
|
body_parts.append('')
|
||||||
|
body = '\r\n'.join(body_parts).encode()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
}
|
||||||
|
req = urllib.request.Request('http://localhost:8000/api/collections/9756bf5f-a89d-424d-bd73-ff4ad839ae4b/tickets/upload', data=body, headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
resp = json.loads(urllib.request.urlopen(req).read().decode())
|
||||||
|
print('2. Upload OK:', resp)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print('2. Upload error:', e.code, e.read().decode())
|
||||||
Loading…
Add table
Reference in a new issue