fix: масштабные исправления и улучшения бэкенда и фронтенда
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

Бэкенд:
- 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:
ed0ss 2026-06-20 19:51:08 +03:00
parent 0063df0a87
commit 41af532e7b
26 changed files with 879 additions and 78 deletions

View file

@ -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())

View file

@ -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"

View file

@ -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=["*"],

View file

@ -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]

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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"]

View file

@ -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=[
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,
"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"]

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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;
}
}

View file

@ -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<Collection[]>('/collections'),
getCollection: (id: string) => request<Collection>(`/collections/${id}`),
deleteCollection: (id: string) =>
request<void>(`/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<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 }),
},
),
}

View file

@ -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<any>(null)
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(() => {
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<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 (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
&larr; Назад к списку
</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
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Опишите проблему клиента..."
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' }}>
{loading ? 'Поиск...' : 'Найти'}

View file

@ -2,16 +2,23 @@ import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
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() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [plan, setPlan] = useState('free')
const [error, setError] = useState('')
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
try {
const res = await api.register(email, password)
const res = await api.register(email, password, plan)
setToken(res.access_token)
navigate('/dashboard')
} catch (err: any) {
@ -20,8 +27,8 @@ export default function Register() {
}
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
<h1>Регистрация</h1>
<div style={{ maxWidth: 500, margin: '60px auto', padding: 20 }}>
<h1 style={{ textAlign: 'center' }}>Регистрация</h1>
<form onSubmit={handleSubmit}>
<div>
<input
@ -30,7 +37,7 @@ export default function Register() {
value={email}
onChange={e => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 8, marginBottom: 8 }}
style={{ width: '100%', padding: 8, marginBottom: 8, boxSizing: 'border-box' }}
/>
</div>
<div>
@ -40,15 +47,43 @@ export default function Register() {
value={password}
onChange={e => setPassword(e.target.value)}
required
style={{ width: '100%', padding: 8, marginBottom: 8 }}
style={{ width: '100%', padding: 8, marginBottom: 12, boxSizing: 'border-box' }}
/>
</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>}
<button type="submit" style={{ width: '100%', padding: 10 }}>
<button type="submit" style={{ width: '100%', padding: 10, marginTop: 8 }}>
Зарегистрироваться
</button>
</form>
<p style={{ marginTop: 12 }}>
<p style={{ marginTop: 12, textAlign: 'center' }}>
Уже есть аккаунт? <Link to="/login">Войти</Link>
</p>
</div>

4
k8s/00-namespace.yaml Normal file
View file

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: support-bot

81
k8s/01-postgres.yaml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())