Реструктуризация проекта в SaaS: FastAPI бэкенд, React SPA фронтенд, Helm-чарт, CI/CD
Some checks are pending
CI / lint-backend (push) Waiting to run
CI / lint-frontend (push) Waiting to run
CI / build-backend (push) Blocked by required conditions
CI / build-frontend (push) Blocked by required conditions

- Бэкенд: 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:
ed0ss 2026-06-20 17:40:27 +03:00
parent fa620edcf8
commit 0063df0a87
60 changed files with 3525 additions and 1 deletions

48
.github/workflows/ci.yml vendored Normal file
View 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
View file

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

View file

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

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

View file

View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

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

View 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

View 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] = []

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

View file

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

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

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

View file

View 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,
}

View 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

View 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

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

View 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

View file

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

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

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View 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
View 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

View 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
View 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>,
)

View 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' }}>
&larr; Назад к списку
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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' }}>
&larr; Назад
</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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
frontend/tsconfig.json Normal file
View 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"]
}

View 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
View 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',
},
},
})

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

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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
View file

@ -0,0 +1,8 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'backend'
static_configs:
- targets: ['backend:8000']
metrics_path: '/api/metrics'