Реструктуризация проекта в SaaS: FastAPI бэкенд, React SPA фронтенд, Helm-чарт, CI/CD
- Бэкенд: FastAPI + SQLAlchemy async + PostgreSQL, JWT-аутентификация, Qdrant multi-tenant векторное хранилище, Celery воркер - Фронтенд: React + Vite + TypeScript SPA (логин, регистрация, дашборд, поиск по коллекциям, настройки) - Инфраструктура: docker-compose, Helm-чарт, GitHub Actions CI, Prometheus/Grafana мониторинг - Embedding через OpenRouter (nvidia/llama-nemotron-embed-vl-1b-v2:free) - LLM: google/gemma-4-31b-it:free через OpenRouter
This commit is contained in:
parent
fa620edcf8
commit
0063df0a87
60 changed files with 3525 additions and 1 deletions
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install ruff
|
||||
- run: ruff check app/
|
||||
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
build-backend:
|
||||
needs: [lint-backend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: docker build -t backend ./backend
|
||||
|
||||
build-frontend:
|
||||
needs: [lint-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: docker build -t frontend ./frontend
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
chroma_db/
|
||||
.env.*
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
|
@ -10,3 +10,26 @@ build/
|
|||
.streamlit/
|
||||
vector_store/
|
||||
*.faiss.bin
|
||||
chroma_db/
|
||||
|
||||
# backend
|
||||
backend/__pycache__/
|
||||
backend/.env
|
||||
backend/vector_store/
|
||||
|
||||
# frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
|
||||
# opencode
|
||||
.opencode/node_modules/
|
||||
|
||||
# tools
|
||||
twc-*.yaml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
|
|||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
24
backend/app/auth/api_key.py
Normal file
24
backend/app/auth/api_key.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
api_key_scheme = HTTPBearer()
|
||||
|
||||
|
||||
async def get_user_by_api_key(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(api_key_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
result = await db.execute(
|
||||
select(User).where(User.api_key == credentials.credentials)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
return user
|
||||
32
backend/app/auth/deps.py
Normal file
32
backend/app/auth/deps.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import decode_token
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
return user
|
||||
11
backend/app/auth/hashing.py
Normal file
11
backend/app/auth/hashing.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
22
backend/app/auth/jwt.py
Normal file
22
backend/app/auth/jwt.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_token(user_id: uuid.UUID) -> str:
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
27
backend/app/config.py
Normal file
27
backend/app/config.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Support Bot SaaS"
|
||||
debug: bool = False
|
||||
|
||||
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
qdrant_url: str = "http://localhost:6333"
|
||||
|
||||
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"
|
||||
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 1440
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
14
backend/app/database.py
Normal file
14
backend/app/database.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=settings.debug)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
42
backend/app/main.py
Normal file
42
backend/app/main.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.models.base import Base
|
||||
from app.routers import auth, tickets, search
|
||||
from app.middleware import RateLimitMiddleware
|
||||
from app.metrics import MetricsMiddleware, metrics_endpoint
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
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_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware, requests_per_minute=30)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(tickets.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
34
backend/app/metrics.py
Normal file
34
backend/app/metrics.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.config import settings
|
||||
|
||||
http_requests = Counter(
|
||||
"http_requests_total",
|
||||
"Total HTTP requests",
|
||||
["method", "path", "status"],
|
||||
)
|
||||
|
||||
http_duration = Histogram(
|
||||
"http_request_duration_seconds",
|
||||
"HTTP request duration in seconds",
|
||||
["method", "path"],
|
||||
)
|
||||
|
||||
|
||||
class MetricsMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
method = request.method
|
||||
path = request.url.path
|
||||
|
||||
with http_duration.labels(method=method, path=path).time():
|
||||
response = await call_next(request)
|
||||
|
||||
http_requests.labels(method=method, path=path, status=response.status_code).inc()
|
||||
return response
|
||||
|
||||
|
||||
async def metrics_endpoint(request: Request):
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
29
backend/app/middleware.py
Normal file
29
backend/app/middleware.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import time
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, requests_per_minute: int = 10):
|
||||
super().__init__(app)
|
||||
self.requests_per_minute = requests_per_minute
|
||||
self.requests: dict = defaultdict(list)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith("/api/health"):
|
||||
return await call_next(request)
|
||||
|
||||
user_key = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
|
||||
now = time.time()
|
||||
window = now - 60
|
||||
|
||||
self.requests[user_key] = [t for t in self.requests[user_key] if t > window]
|
||||
self.requests[user_key].append(now)
|
||||
|
||||
if len(self.requests[user_key]) > self.requests_per_minute:
|
||||
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
|
||||
|
||||
return await call_next(request)
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
5
backend/app/models/base.py
Normal file
5
backend/app/models/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
24
backend/app/models/collection.py
Normal file
24
backend/app/models/collection.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collections"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
ticket_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
66
backend/app/models/schemas.py
Normal file
66
backend/app/models/schemas.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
api_key: str
|
||||
plan: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class CollectionResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
name: str
|
||||
ticket_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketMessage(BaseModel):
|
||||
role: str
|
||||
text: str
|
||||
|
||||
|
||||
class TicketInput(BaseModel):
|
||||
ticket_id: str
|
||||
category: str = ""
|
||||
description: str = ""
|
||||
client: str = ""
|
||||
messages: list[TicketMessage] = []
|
||||
|
||||
|
||||
class TicketsUpload(BaseModel):
|
||||
tickets: list[TicketInput]
|
||||
|
||||
|
||||
class TicketsUploadResponse(BaseModel):
|
||||
processed: int
|
||||
skipped: int
|
||||
collection_ticket_count: int
|
||||
18
backend/app/models/search_schemas.py
Normal file
18
backend/app/models/search_schemas.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
collection_id: str
|
||||
query: str
|
||||
generate_answer: bool = True
|
||||
|
||||
|
||||
class SourceItem(BaseModel):
|
||||
ticket_id: str
|
||||
score: float
|
||||
category: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
answer: str | None = None
|
||||
sources: list[SourceItem] = []
|
||||
23
backend/app/models/user.py
Normal file
23
backend/app/models/user.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
api_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
plan: Mapped[str] = mapped_column(String(32), default="free")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
97
backend/app/routers/auth.py
Normal file
97
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.hashing import hash_password, verify_password
|
||||
from app.auth.jwt import create_token
|
||||
from app.database import get_db
|
||||
from app.models.schemas import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
CollectionCreate,
|
||||
CollectionResponse,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.collection import Collection
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
api_key = str(uuid.uuid4().hex[:32])
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
api_key=api_key,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return TokenResponse(access_token=create_token(user.id))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
return TokenResponse(access_token=create_token(user.id))
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/collections", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_collection(
|
||||
body: CollectionCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collection = Collection(user_id=user.id, name=body.name)
|
||||
db.add(collection)
|
||||
await db.commit()
|
||||
await db.refresh(collection)
|
||||
return collection
|
||||
|
||||
|
||||
@router.get("/collections", response_model=list[CollectionResponse])
|
||||
async def list_collections(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Collection).where(Collection.user_id == user.id).order_by(Collection.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_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")
|
||||
|
||||
await db.delete(collection)
|
||||
await db.commit()
|
||||
69
backend/app/routers/search.py
Normal file
69
backend/app/routers/search.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.collection import Collection
|
||||
from app.models.search_schemas import SearchRequest, SearchResponse, SourceItem
|
||||
from app.models.user import User
|
||||
from app.services.embedding import embed_query
|
||||
from app.services.vector_store import search as qdrant_search
|
||||
from app.services.reranking import rerank
|
||||
from app.services.llm import generate_answer
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["search"])
|
||||
|
||||
FETCH_K = 20
|
||||
FINAL_K = 5
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResponse)
|
||||
async def search(
|
||||
body: SearchRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Collection).where(
|
||||
Collection.id == uuid.UUID(body.collection_id),
|
||||
Collection.user_id == user.id,
|
||||
)
|
||||
)
|
||||
collection = result.scalar_one_or_none()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
||||
|
||||
query_vector = await embed_query(body.query)
|
||||
|
||||
qdrant_results = await qdrant_search(
|
||||
tenant_id=str(user.id),
|
||||
query_vector=query_vector,
|
||||
collection_id=body.collection_id,
|
||||
k=FETCH_K,
|
||||
)
|
||||
|
||||
if not qdrant_results:
|
||||
return SearchResponse(
|
||||
answer="Недостаточно информации в истории обращений" if body.generate_answer else None,
|
||||
sources=[],
|
||||
)
|
||||
|
||||
reranked = rerank(body.query, qdrant_results, k=FINAL_K)
|
||||
|
||||
sources = [
|
||||
SourceItem(ticket_id=r["ticket_id"], score=r["score"], category=r["category"])
|
||||
for r in reranked
|
||||
]
|
||||
|
||||
answer = None
|
||||
if body.generate_answer:
|
||||
context = "\n\n".join(
|
||||
f"Тикет №{r['ticket_id']} ({r['category']}):\n{r['full_text']}"
|
||||
for r in reranked
|
||||
)
|
||||
answer = await generate_answer(body.query, context)
|
||||
|
||||
return SearchResponse(answer=answer, sources=sources)
|
||||
113
backend/app/routers/tickets.py
Normal file
113
backend/app/routers/tickets.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import uuid
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.api_key import get_user_by_api_key
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.collection import Collection
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/collections", tags=["tickets"])
|
||||
|
||||
|
||||
async def process_tickets(
|
||||
db: AsyncSession,
|
||||
collection_id: uuid.UUID,
|
||||
user: User,
|
||||
tickets: list[TicketInput],
|
||||
) -> TicketsUploadResponse:
|
||||
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")
|
||||
|
||||
processed = 0
|
||||
skipped = 0
|
||||
chunks = []
|
||||
|
||||
for t in tickets:
|
||||
ticket_dict = {
|
||||
"ticket_id": t.ticket_id,
|
||||
"category": t.category,
|
||||
"description": t.description,
|
||||
"client": t.client,
|
||||
"messages": [{"role": m.role, "text": m.text} for m in t.messages],
|
||||
}
|
||||
chunk = build_chunk(ticket_dict)
|
||||
if chunk:
|
||||
chunk["tenant_id"] = str(user.id)
|
||||
chunk["collection_id"] = str(collection_id)
|
||||
chunks.append(chunk)
|
||||
processed += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
if chunks:
|
||||
search_texts = [c["search_text"] for c in chunks]
|
||||
vectors = await embed_texts(search_texts)
|
||||
await upsert_chunks(str(user.id), chunks, vectors)
|
||||
|
||||
collection.ticket_count += processed
|
||||
await db.commit()
|
||||
|
||||
return TicketsUploadResponse(
|
||||
processed=processed,
|
||||
skipped=skipped,
|
||||
collection_ticket_count=collection.ticket_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{collection_id}/tickets", response_model=TicketsUploadResponse)
|
||||
async def upload_tickets_json(
|
||||
collection_id: uuid.UUID,
|
||||
body: TicketsUpload,
|
||||
user: User = Depends(get_user_by_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await process_tickets(db, collection_id, user, body.tickets)
|
||||
|
||||
|
||||
@router.post("/{collection_id}/tickets/upload", response_model=TicketsUploadResponse)
|
||||
async def upload_tickets_file(
|
||||
collection_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
content = await file.read()
|
||||
|
||||
if file.filename.endswith(".json"):
|
||||
data = json.loads(content)
|
||||
tickets = [TicketInput(**t) for t in data] if isinstance(data, list) else [TicketInput(**data)]
|
||||
elif file.filename.endswith(".csv"):
|
||||
text = content.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
tickets = []
|
||||
for row in reader:
|
||||
tickets.append(
|
||||
TicketInput(
|
||||
ticket_id=row.get("ticket_id", ""),
|
||||
category=row.get("category", ""),
|
||||
description=row.get("description", ""),
|
||||
client=row.get("client", ""),
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unsupported format. Use JSON or CSV.",
|
||||
)
|
||||
|
||||
return await process_tickets(db, collection_id, user, tickets)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
83
backend/app/services/chunking.py
Normal file
83
backend/app/services/chunking.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import re
|
||||
from typing import Optional
|
||||
|
||||
NON_SOLUTION = re.compile(
|
||||
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
||||
r"|нет обратной связи|запрос завершу|открыть его снова"
|
||||
r"|откройте новый|обратиться в службу поддержки"
|
||||
r"|свяжитесь с технической"
|
||||
r"|напишите нам|позвоните нам)", re.I
|
||||
)
|
||||
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
text = re.sub(r'\[/?q\].*?\[/q\]', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'https?://\S+', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def has_real_solution(messages: list[dict]) -> Optional[str]:
|
||||
support_texts = []
|
||||
for m in messages:
|
||||
if m["role"] == "support":
|
||||
cleaned = clean_text(m["text"])
|
||||
if cleaned:
|
||||
support_texts.append(cleaned)
|
||||
if not support_texts:
|
||||
return None
|
||||
combined = "\n".join(support_texts)
|
||||
if len(combined) < 100:
|
||||
return None
|
||||
last = support_texts[-1]
|
||||
if NON_SOLUTION.search(last):
|
||||
return None
|
||||
if last.strip().endswith("?") and len(last) < 150:
|
||||
return None
|
||||
return combined
|
||||
|
||||
|
||||
def get_client_dialogue(messages: list[dict]) -> str:
|
||||
texts = []
|
||||
for m in messages:
|
||||
if m["role"] == "client":
|
||||
cleaned = clean_text(m["text"])
|
||||
if cleaned:
|
||||
texts.append(cleaned)
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def build_chunk(ticket: dict) -> Optional[dict]:
|
||||
msgs = ticket.get("messages") or []
|
||||
if not msgs:
|
||||
return None
|
||||
description = clean_text(ticket.get("description", ""))
|
||||
solution = has_real_solution(msgs)
|
||||
if not solution:
|
||||
return None
|
||||
|
||||
client_msgs = get_client_dialogue(msgs)
|
||||
|
||||
search_parts = []
|
||||
if description:
|
||||
search_parts.append(description)
|
||||
if client_msgs:
|
||||
search_parts.append(client_msgs)
|
||||
search_text = "\n".join(search_parts).strip()
|
||||
if not search_text:
|
||||
return None
|
||||
|
||||
full_text = (
|
||||
f"Категория: {ticket['category']}\n"
|
||||
f"Проблема: {description}\n"
|
||||
f"Решение: {solution}"
|
||||
)
|
||||
|
||||
return {
|
||||
"ticket_id": ticket["ticket_id"],
|
||||
"client": ticket["client"],
|
||||
"category": ticket["category"],
|
||||
"search_text": search_text,
|
||||
"full_text": full_text,
|
||||
"tenant_id": None,
|
||||
}
|
||||
35
backend/app/services/embedding.py
Normal file
35
backend/app/services/embedding.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import settings
|
||||
|
||||
client = AsyncOpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=settings.openrouter_api_key,
|
||||
)
|
||||
|
||||
EMBEDDING_MODEL = settings.openrouter_embedding_model
|
||||
IS_NVIDIA = "nvidia" in EMBEDDING_MODEL
|
||||
|
||||
|
||||
def _prefix_text(text: str, prefix: str) -> str:
|
||||
if IS_NVIDIA:
|
||||
return f"{prefix}: {text}"
|
||||
return text
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
|
||||
|
||||
async def embed_query(text: str) -> list[float]:
|
||||
prefixed = _prefix_text(text, "query")
|
||||
response = await client.embeddings.create(
|
||||
model=EMBEDDING_MODEL,
|
||||
input=[prefixed],
|
||||
)
|
||||
return response.data[0].embedding
|
||||
33
backend/app/services/llm.py
Normal file
33
backend/app/services/llm.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from openai import OpenAI
|
||||
|
||||
from app.config import settings
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=settings.openrouter_api_key,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
27
backend/app/services/reranking.py
Normal file
27
backend/app/services/reranking.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from sentence_transformers import CrossEncoder
|
||||
|
||||
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]:
|
||||
if not results:
|
||||
return []
|
||||
|
||||
pairs = [(query, r["payload"]["full_text"]) for r in results]
|
||||
scores = reranker.predict(pairs)
|
||||
|
||||
scored = []
|
||||
for i, score in enumerate(scores):
|
||||
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", ""),
|
||||
})
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:k]
|
||||
91
backend/app/services/vector_store.py
Normal file
91
backend/app/services/vector_store.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http.models import (
|
||||
PointStruct,
|
||||
Filter,
|
||||
FieldCondition,
|
||||
MatchValue,
|
||||
VectorParams,
|
||||
Distance,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
client = QdrantClient(url=settings.qdrant_url)
|
||||
COLLECTION_NAME = "tickets"
|
||||
_vector_size: int | None = None
|
||||
|
||||
|
||||
def get_vector_size() -> int:
|
||||
global _vector_size
|
||||
if _vector_size is not None:
|
||||
return _vector_size
|
||||
|
||||
collections = client.get_collections().collections
|
||||
for c in collections:
|
||||
if c.name == COLLECTION_NAME:
|
||||
info = client.get_collection(COLLECTION_NAME)
|
||||
_vector_size = info.config.params.vectors.size
|
||||
return _vector_size
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def ensure_collection(size: int):
|
||||
global _vector_size
|
||||
collections = client.get_collections().collections
|
||||
exists = any(c.name == COLLECTION_NAME for c in collections)
|
||||
|
||||
if not exists:
|
||||
client.create_collection(
|
||||
collection_name=COLLECTION_NAME,
|
||||
vectors_config=VectorParams(size=size, distance=Distance.COSINE),
|
||||
)
|
||||
_vector_size = size
|
||||
else:
|
||||
get_vector_size()
|
||||
|
||||
|
||||
async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):
|
||||
size = len(vectors[0])
|
||||
await ensure_collection(size)
|
||||
|
||||
points = []
|
||||
for chunk, vector in zip(chunks, vectors):
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=hash(f"{tenant_id}:{chunk['ticket_id']}") & 0x7FFFFFFFFFFFFFFF,
|
||||
vector=vector,
|
||||
payload={
|
||||
"tenant_id": tenant_id,
|
||||
"ticket_id": chunk["ticket_id"],
|
||||
"client": chunk.get("client", ""),
|
||||
"category": chunk.get("category", ""),
|
||||
"collection_id": chunk.get("collection_id", ""),
|
||||
"search_text": chunk.get("search_text", ""),
|
||||
"full_text": chunk.get("full_text", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
client.upsert(collection_name=COLLECTION_NAME, points=points)
|
||||
|
||||
|
||||
async def search(
|
||||
tenant_id: str,
|
||||
query_vector: list[float],
|
||||
collection_id: str | None = None,
|
||||
k: int = 20,
|
||||
):
|
||||
must_conditions = [FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))]
|
||||
if collection_id:
|
||||
must_conditions.append(FieldCondition(key="collection_id", match=MatchValue(value=collection_id)))
|
||||
|
||||
results = client.query_points(
|
||||
collection_name=COLLECTION_NAME,
|
||||
query=query_vector,
|
||||
query_filter=Filter(must=must_conditions),
|
||||
limit=k,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
return results.points
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
9
backend/app/workers/process_file.py
Normal file
9
backend/app/workers/process_file.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from celery import Celery
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery("worker", broker=settings.redis_url, backend=settings.redis_url)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_tickets_file(file_path: str, tenant_id: str, collection_id: str):
|
||||
pass
|
||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
pydantic[email]
|
||||
sqlalchemy[asyncio]==2.0.35
|
||||
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
|
||||
python-multipart==0.0.12
|
||||
sentence-transformers==3.2.0
|
||||
numpy==1.26.4
|
||||
prometheus-client==0.20.0
|
||||
21
docker-compose.monitoring.yml
Normal file
21
docker-compose.monitoring.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: support_bot
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- qdrant
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
command: celery -A app.workers.process_file worker --loglevel=info
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- qdrant
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
qdrant_data:
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Support Bot SaaS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
frontend/nginx.conf
Normal file
18
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name sovet.itoservice.ru;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
1774
frontend/package-lock.json
generated
Normal file
1774
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "support-bot-saas-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import CollectionView from './pages/CollectionView'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/collections/:id" element={<CollectionView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
88
frontend/src/api/client.ts
Normal file
88
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
let token: string | null = localStorage.getItem('token')
|
||||
|
||||
export function setToken(t: string | null) {
|
||||
token = t
|
||||
if (t) localStorage.setItem('token', t)
|
||||
else localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return token
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || 'Request failed')
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
api_key: string
|
||||
plan: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
ticket_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
answer: string | null
|
||||
sources: { ticket_id: string; score: number; category: string }[]
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register: (email: string, password: string) =>
|
||||
request<{ access_token: string }>('/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
login: (email: string, password: string) =>
|
||||
request<{ access_token: string }>('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
me: () => request<User>('/me'),
|
||||
|
||||
createCollection: (name: string) =>
|
||||
request<Collection>('/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
listCollections: () => request<Collection[]>('/collections'),
|
||||
|
||||
deleteCollection: (id: string) =>
|
||||
request<void>(`/collections/${id}`, { method: 'DELETE' }),
|
||||
|
||||
search: (collectionId: string, query: string, generateAnswer = true) =>
|
||||
request<SearchResult>('/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ collection_id: collectionId, query, generate_answer: generateAnswer }),
|
||||
}),
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
81
frontend/src/pages/CollectionView.tsx
Normal file
81
frontend/src/pages/CollectionView.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api, getToken } from '../api/client'
|
||||
|
||||
export default function CollectionView() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!query.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.search(id!, query)
|
||||
setResult(res)
|
||||
} catch (err: any) {
|
||||
setResult({ error: err.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
|
||||
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
||||
← Назад к списку
|
||||
</Link>
|
||||
|
||||
<h1>Поиск по базе знаний</h1>
|
||||
|
||||
<form onSubmit={handleSearch} style={{ margin: '20px 0' }}>
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Опишите проблему клиента..."
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
<button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
|
||||
{loading ? 'Поиск...' : 'Найти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<div>
|
||||
{result.answer && (
|
||||
<div style={{ background: '#f5f5f5', padding: 16, borderRadius: 4, marginBottom: 16 }}>
|
||||
<h3>Ответ</h3>
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{result.answer}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.sources && result.sources.length > 0 && (
|
||||
<div>
|
||||
<h3>Найденные тикеты</h3>
|
||||
{result.sources.map((s: any, i: number) => (
|
||||
<div key={i} style={{ border: '1px solid #ddd', padding: 12, marginBottom: 8, borderRadius: 4 }}>
|
||||
<strong>Тикет №{s.ticket_id}</strong>
|
||||
<span style={{ marginLeft: 12, color: '#666' }}>
|
||||
Релевантность: {s.score.toFixed(3)} | {s.category}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.error && <p style={{ color: 'red' }}>{result.error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
frontend/src/pages/Dashboard.tsx
Normal file
79
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { api, Collection, setToken, getToken } from '../api/client'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [user, setUser] = useState<any>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
api.me().then(u => {
|
||||
setUser(u)
|
||||
return api.listCollections()
|
||||
}).then(setCollections).catch(() => navigate('/login'))
|
||||
}, [navigate])
|
||||
|
||||
async function createCollection(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const c = await api.createCollection(name)
|
||||
setCollections([c, ...collections])
|
||||
setName('')
|
||||
}
|
||||
|
||||
async function removeCollection(id: string) {
|
||||
await api.deleteCollection(id)
|
||||
setCollections(collections.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null)
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Support Bot SaaS</h1>
|
||||
<div>
|
||||
<Link to="/settings" style={{ marginRight: 12 }}>Настройки</Link>
|
||||
<button onClick={logout}>Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && <p>План: {user.plan} | Тип: {user.email}</p>}
|
||||
|
||||
<form onSubmit={createCollection} style={{ margin: '20px 0', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Название базы знаний"
|
||||
required
|
||||
style={{ flex: 1, padding: 8 }}
|
||||
/>
|
||||
<button type="submit" style={{ padding: '8px 16px' }}>Создать</button>
|
||||
</form>
|
||||
|
||||
<h2>Базы знаний</h2>
|
||||
{collections.length === 0 && <p>Пока нет ни одной базы знаний</p>}
|
||||
{collections.map(c => (
|
||||
<div key={c.id} style={{ border: '1px solid #ddd', padding: 12, marginBottom: 8, borderRadius: 4 }}>
|
||||
<Link to={`/collections/${c.id}`} style={{ fontSize: 18, fontWeight: 'bold' }}>
|
||||
{c.name}
|
||||
</Link>
|
||||
<p style={{ margin: '4px 0', color: '#666' }}>
|
||||
Тикетов: {c.ticket_count} | Создана: {new Date(c.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
<button onClick={() => removeCollection(c.id)} style={{ color: 'red' }}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/Login.tsx
Normal file
56
frontend/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { api, setToken } from '../api/client'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await api.login(email, password)
|
||||
setToken(res.access_token)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
|
||||
<h1>Вход</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button type="submit" style={{ width: '100%', padding: 10 }}>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: 12 }}>
|
||||
Нет аккаунта? <Link to="/register">Регистрация</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/pages/Register.tsx
Normal file
56
frontend/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { api, setToken } from '../api/client'
|
||||
|
||||
export default function Register() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await api.register(email, password)
|
||||
setToken(res.access_token)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
|
||||
<h1>Регистрация</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8, marginBottom: 8 }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button type="submit" style={{ width: '100%', padding: 10 }}>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: 12 }}>
|
||||
Уже есть аккаунт? <Link to="/login">Войти</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
frontend/src/pages/Settings.tsx
Normal file
34
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, getToken, User } from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
api.me().then(setUser)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
|
||||
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
||||
← Назад
|
||||
</Link>
|
||||
|
||||
<h1>Настройки</h1>
|
||||
|
||||
{user && (
|
||||
<div>
|
||||
<p><strong>Email:</strong> {user.email}</p>
|
||||
<p><strong>API Key:</strong> <code>{user.api_key}</code></p>
|
||||
<p><strong>План:</strong> {user.plan}</p>
|
||||
<p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/collectionview.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/register.tsx","./src/pages/settings.tsx"],"version":"5.9.3"}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
6
helm/support-bot-saas/Chart.yaml
Normal file
6
helm/support-bot-saas/Chart.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: support-bot-saas
|
||||
description: RAG SaaS for tech support
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
19
helm/support-bot-saas/templates/deployment-backend.yaml
Normal file
19
helm/support-bot-saas/templates/deployment-backend.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-backend
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}-backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: {{ .Values.image.backend }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.service.backendPort }}
|
||||
19
helm/support-bot-saas/templates/deployment-frontend.yaml
Normal file
19
helm/support-bot-saas/templates/deployment-frontend.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: {{ .Values.image.frontend }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.service.frontendPort }}
|
||||
26
helm/support-bot-saas/templates/ingress.yaml
Normal file
26
helm/support-bot-saas/templates/ingress.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-ingress
|
||||
spec:
|
||||
rules:
|
||||
- host: {{ .Values.ingress.apiHost }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-backend
|
||||
port:
|
||||
number: {{ .Values.service.backendPort }}
|
||||
- host: {{ .Values.ingress.appHost }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
port:
|
||||
number: {{ .Values.service.frontendPort }}
|
||||
10
helm/support-bot-saas/templates/service-backend.yaml
Normal file
10
helm/support-bot-saas/templates/service-backend.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-backend
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-backend
|
||||
ports:
|
||||
- port: {{ .Values.service.backendPort }}
|
||||
targetPort: {{ .Values.service.backendPort }}
|
||||
10
helm/support-bot-saas/templates/service-frontend.yaml
Normal file
10
helm/support-bot-saas/templates/service-frontend.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-frontend
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-frontend
|
||||
ports:
|
||||
- port: {{ .Values.service.frontendPort }}
|
||||
targetPort: {{ .Values.service.frontendPort }}
|
||||
23
helm/support-bot-saas/values.yaml
Normal file
23
helm/support-bot-saas/values.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
replicaCount: 2
|
||||
|
||||
image:
|
||||
backend: support-bot-backend:latest
|
||||
frontend: support-bot-frontend:latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
backendPort: 8000
|
||||
frontendPort: 80
|
||||
|
||||
ingress:
|
||||
apiHost: api.sovet.itoservice.ru
|
||||
appHost: sovet.itoservice.ru
|
||||
|
||||
postgres:
|
||||
size: 10Gi
|
||||
|
||||
redis:
|
||||
size: 5Gi
|
||||
|
||||
qdrant:
|
||||
size: 20Gi
|
||||
8
prometheus.yml
Normal file
8
prometheus.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'backend'
|
||||
static_configs:
|
||||
- targets: ['backend:8000']
|
||||
metrics_path: '/api/metrics'
|
||||
Loading…
Add table
Reference in a new issue