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
|
||||
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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=["*"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
← Назад к списку
|
||||
</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 ? 'Поиск...' : 'Найти'}
|
||||
|
|
|
|||
|
|
@ -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
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