Реструктуризация проекта в 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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
chroma_db/
|
.env.*
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
@ -10,3 +10,26 @@ build/
|
||||||
.streamlit/
|
.streamlit/
|
||||||
vector_store/
|
vector_store/
|
||||||
*.faiss.bin
|
*.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