Compare commits

..

No commits in common. "master" and "feat/rest-api" have entirely different histories.

19 changed files with 209 additions and 1161 deletions

484
README.md
View file

@ -1,394 +1,172 @@
# Support Bot SaaS — RAG-ассистент техподдержки # RAG-ассистент техподдержки
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты в векторной БД и формирует ответ на их основе через LLM. Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты и формирует ответ на их основе.
Развёрнута на Kubernetes (k0s), используются OpenRouter для эмбеддингов и LLM.
---
## Быстрый старт (локальный Docker)
```bash
# 1. Клонировать репозиторий
git clone https://git.itoservice.ru/dosnav/support-bot-saas.git
cd support-bot-saas
# 2. Создать backend/.env из шаблона
cp backend/.env.example backend/.env
# отредактировать backend/.env — указать OPENROUTER_API_KEY и JWT_SECRET
# 3. Запустить
docker compose up --build -d
```
После запуска веб-интерфейс доступен по адресу: **http://localhost:8080**
### Первый вход
1. Открой **http://localhost:8080** в браузере
2. Зарегистрируйся (Email + пароль, тариф free)
3. После регистрации автоматически войдёшь в систему
4. Создай коллекцию (например «OFRS»)
5. Загрузи JSON-файл с тикетами через кнопку «Выбрать JSON/CSV файл»
Формат JSON:
```json
[
{
"ticket_id": "12345",
"category": "Сбой работы",
"description": "Описание проблемы",
"client": "Имя клиента",
"messages": [
{"role": "client", "text": "У меня проблема"},
{"role": "support", "text": "Решение проблемы"}
]
}
]
```
6. После загрузки тикетов введи вопрос в поле поиска и нажми «Найти»
Система найдёт похожие тикеты, а LLM сформирует ответ на их основе.
### Остановка
```bash
docker compose down
# С данными:
docker compose down -v
```
---
## Конфигурация (.env)
Все настройки задаются через файл `backend/.env` (создать из `backend/.env.example`):
```bash
cp backend/.env.example backend/.env
```
| Переменная | По умолчанию | Описание |
|---|---|---|
| `OPENROUTER_API_KEY` | — | API-ключ OpenRouter **(обязательно)** |
| `JWT_SECRET` | `change-me-in-production` | Секрет для подписи JWT **(обязательно сменить)** |
| `OPENROUTER_LLM_MODEL` | `openrouter/owl-alpha` | Модель LLM |
| `OPENROUTER_EMBEDDING_MODEL` | `nvidia/llama-nemotron-embed-vl-1b-v2:free` | Модель эмбеддингов |
| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot` | Подключение к PostgreSQL |
| `REDIS_URL` | `redis://localhost:6379/0` | Подключение к Redis |
| `QDRANT_URL` | `http://localhost:6333` | Подключение к Qdrant |
| `PROXY_URL` | — | Адрес HTTP-прокси для OpenRouter (опционально) |
| `PROXY_LOGIN` | — | Логин прокси |
| `PROXY_PASS` | — | Пароль прокси |
| `JWT_ALGORITHM` | `HS256` | Алгоритм подписи JWT |
| `JWT_EXPIRE_MINUTES` | `1440` | Время жизни токена (минут) |
Значения по умолчанию заданы в `backend/app/config.py`. Если переменная в `.env` не указана — используется дефолт.
--- ---
## Архитектура ## Архитектура
``` ```
Пользователь (браузер) tickets.json (792 тикета)
┌─────────────┐ ┌─────────────┐ prepare_data.py ← фильтрация, очистка, нарезка чанков
│ Traefik │────▶│ Nginx │ (frontend, React SPA)
│ (Ingress) │ └─────────────┘
│ │ ┌─────────────┐ sentence-transformers ← векторизация (intfloat/multilingual-e5-small, GPU CUDA)
│ │────▶│ FastAPI │ (backend)
└─────────────┘ └──────┬──────┘
chroma_db/ ← векторная база данных (416 чанков)
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ app.py ← Streamlit-интерфейс
│PostgreSQL│ │ Qdrant │ │ Redis │
│(пользова-│ │(векторная│ │(кэш/сес- │ ├── вопрос специалиста → embedding → поиск топ-5
│ тели, │ │ БД) │ │ сии) │ │ │
│коллекции)│ └──────────┘ └──────────┘ │ ▼
└──────────┘ └── контекст → LM Studio (qwen2.5-7b) → ответ
┌──────────────┐
│ OpenRouter │
│(эмбеддинги + │
│ LLM) │
└──────────────┘
``` ```
### Компоненты
| Компонент | Технология |
|-----------|-----------|
| **Фронтенд** | React + Vite + TypeScript, Nginx |
| **Бэкенд** | FastAPI (Python 3.12) + SQLAlchemy async |
| **База данных** | PostgreSQL (пользователи, коллекции) |
| **Векторная БД** | Qdrant (точки с эмбеддингами) |
| **Кэш** | Redis |
| **Эмбеддинги** | `nvidia/llama-nemotron-embed-vl-1b-v2:free` через OpenRouter |
| **LLM** | `openrouter/owl-alpha` через OpenRouter |
| **Ingress** | Traefik v3.7 |
| **Оркестрация** | Kubernetes (k0s v1.36.1) |
| **CI** | GitLab CI (Forgejo) |
---
## REST API v1
Аутентификация: `Authorization: Bearer <API-ключ>`
API-ключ можно посмотреть и сбросить на странице **Настройки** в веб-интерфейсе.
**Базовый URL:** `http://sovet.itoservice.ru/api/v1`
### Получить информацию об аккаунте
```bash
curl -H "Authorization: Bearer <API_KEY>" http://sovet.itoservice.ru/api/v1/me
```
Ответ:
```json
{
"id": "uuid",
"email": "user@example.com",
"api_key": "d469cee785e444cf9abd24a3e709108c",
"plan": "free",
"created_at": "2026-06-20T12:00:00Z"
}
```
### Сбросить API-ключ
```bash
curl -X PATCH -H "Authorization: Bearer <API_KEY>" http://sovet.itoservice.ru/api/v1/me/api-key
```
Ответ:
```json
{
"api_key": "новый-ключ"
}
```
### Список коллекций
```bash
curl -H "Authorization: Bearer <API_KEY>" http://sovet.itoservice.ru/api/v1/collections
```
Ответ:
```json
[
{
"id": "49a3f88e-...",
"user_id": "uuid",
"name": "OFRS",
"ticket_count": 2901,
"created_at": "2026-06-20T..."
}
]
```
### Загрузить тикеты в коллекцию
```bash
curl -X POST "http://sovet.itoservice.ru/api/v1/tickets?collection_id=<ID_КОЛЛЕКЦИИ>" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"tickets": [
{
"ticket_id": "12345",
"category": "Сбой работы",
"description": "Описание проблемы",
"client": "Имя клиента",
"messages": [
{"role": "client", "text": "У меня проблема"},
{"role": "support", "text": "Решение проблемы"}
]
}
]
}'
```
Ответ:
```json
{
"processed": 1,
"skipped": 0,
"collection_ticket_count": 2902
}
```
### Поиск с AI-ответом
```bash
curl -X POST http://sovet.itoservice.ru/api/v1/search \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"collection_id": "<ID_КОЛЛЕКЦИИ>",
"query": "не могу начать смену в приложении",
"generate_answer": true
}'
```
Параметры:
- `collection_id` — ID коллекции (обязательно)
- `query` — текст проблемы (обязательно)
- `generate_answer` — true/false, генерировать ответ LLM (опционально, по умолчанию true)
Ответ:
```json
{
"answer": "Добрый день! Для решения проблемы...",
"sources": [
{
"ticket_id": "38446",
"score": 0.514,
"category": "Вопрос по работе мобильного приложения",
"full_text": "Категория: ...\nПроблема: ...\nРешение: ..."
}
]
}
```
---
## Как это работает ## Как это работает
### 1. Загрузка тикетов ### 1. Подготовка данных (`prepare_data.py`)
Пользователь загружает JSON-файл с тикетами через веб-интерфейс или API. Что делает скрипт:
Бэкенд:
- Проверяет дубликаты (по `tenant_id:ticket_id` через md5)
- Очищает персональные данные: телефоны → `[ТЕЛЕФОН]`, email → `[EMAIL]`, имена → `[ИМЯ]`
- Строит чанк: выделяет сообщения поддержки (роли `support/assistant/operator/agent`) как решение
- Фильтрует бесполезные тикеты:
- последнее сообщение поддержки — просьба уточнить / прислать скрин
- слишком короткие (< 100 символов)
- нет описания и нет сообщений клиента
- Векторизует `search_text` (описание + сообщения клиента) через OpenRouter
- Сохраняет точку в Qdrant с полями: `tenant_id`, `collection_id`, `ticket_id`, `category`, `client`, `search_text`, `full_text`
### 2. Поиск и генерация - Читает все файлы `tickets*.json` в папке проекта (сейчас 6 файлов, ~3300 тикетов)
- **Чистит текст**: удаляет цитаты `[q]...[/q]`, ссылки, лишние пробелы
- **Фильтрует**: оставляет только тикеты с реальным решением. Отбрасываются:
- тикеты, где последнее сообщение support — просьба уточнить / прислать скрин
- тикеты без ответа / с закрытием "нет обратной связи"
- слишком короткие сообщения (< 100 символов)
- **Формирует чанки**: каждый тикет = один чанк вида:
```
Категория: ...
Проблема: <описание>
Решение: <все сообщения support>
```
- **Векторизует**: превращает текст в вектор (384 числа) через `intfloat/multilingual-e5-small` на GPU
- **Сохраняет** в ChromaDB (папка `chroma_db/`)
Пользователь вводит вопрос. Система: Результат: 416 чанков с реальными решениями (376 тикетов отфильтровано как бесполезные).
1. Векторизует вопрос той же моделью эмбеддингов
2. Ищет 20 похожих точек в Qdrant (фильтр по `tenant_id` + `collection_id`)
3. Сортирует по скорингу (косинусная близость), берёт топ-5
4. Формирует контекст из `full_text` найденных тикетов
5. Отправляет в LLM с системным промптом
6. Возвращает ответ + исходные тикеты
### Очистка персональных данных ### 2. Поиск и генерация (`app.py`)
Перед сохранением в Qdrant из текста удаляются: Специалист вводит проблему клиента. Приложение:
- Номера телефонов: `+7xxxxxxxxxx`, `8xxxxxxxxxx``[ТЕЛЕФОН]`
- Email-адреса → `[EMAIL]`
- Русские имена в обращениях → `[ИМЯ]`
--- 1. **Векторизует вопрос** той же моделью эмбеддингов
2. **Ищет 5 похожих чанков** в ChromaDB по косинусной близости
3. **Формирует контекст** из найденных тикетов
4. **Отправляет в LM Studio** (чат-модель Qwen2.5-7B) с промптом:
> "Ответь на основе ТОЛЬКО переданного контекста. Не придумывай, не отсылай в другую поддержку"
5. **Показывает ответ** и исходные тикеты для проверки
## Разработка ## Установка и запуск
### Локальный запуск бэкенда ### Требования
- Python 3.11+
- NVIDIA GPU с 8+ GB VRAM (для эмбеддингов и LLM)
- [LM Studio](https://lmstudio.ai/) (для чат-модели)
### Установка
```bash ```bash
cd backend
python -m venv venv
venv\Scripts\activate # Windows
pip install -r requirements.txt pip install -r requirements.txt
# Настройки в .env:
# openrouter_api_key=...
# database_url=postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot
# qdrant_url=http://localhost:6333
uvicorn app.main:app --reload
``` ```
### Локальный запуск фронтенда Установить PyTorch с CUDA (если ещё не):
```bash
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
```
### Подготовка базы знаний
```bash ```bash
cd frontend python prepare_data.py
npm install
npm run dev
``` ```
Скрипт создаст папку `chroma_db/` с векторной базой.
### Сборка Docker-образов ### Запуск
```bash ```bash
docker build -t git.itoservice.ru/dosnav/support-bot-saas/backend:latest ./backend streamlit run app.py --server.headless true
docker build -t git.itoservice.ru/dosnav/support-bot-saas/frontend:latest ./frontend
docker push git.itoservice.ru/dosnav/support-bot-saas/backend:latest
docker push git.itoservice.ru/dosnav/support-bot-saas/frontend:latest
``` ```
Открыть в браузере: http://localhost:8501
### Деплой в K8s ### Запуск чат-модели
```bash Для генерации ответов нужна чат-модель в LM Studio:
kubectl apply -f k8s/00-namespace.yaml 1. Открыть LM Studio
kubectl apply -f k8s/01-postgres.yaml 2. Вкладка Developer → загрузить модель (например, `Qwen2.5-7B-Instruct`)
kubectl apply -f k8s/02-redis.yaml 3. Нажать Start Server
kubectl apply -f k8s/03-qdrant.yaml 4. API будет доступен на `http://localhost:1234/v1`
kubectl apply -f k8s/04-backend.yaml
kubectl apply -f k8s/05-frontend.yaml
kubectl apply -f k8s/traefik/
kubectl apply -f k8s/06-ingress.yaml
```
--- Без чат-модели приложение работает в режиме поиска — показывает похожие тикеты без генерации ответа.
## Структура проекта ## Структура проекта
``` ```
support-bot-saas/ support-bot/
├── backend/ ├── tickets*.json # исходные данные (несколько файлов)
│ └── app/ ├── prepare_data.py # подготовка и векторизация
│ ├── auth/ # JWT + API-ключ аутентификация ├── app.py # Streamlit-интерфейс + RAG
│ ├── models/ # SQLAlchemy модели + Pydantic схемы ├── requirements.txt # зависимости
│ ├── routers/ # FastAPI роутеры ├── chroma_db/ # векторная БД (создаётся prepare_data.py)
│ │ ├── auth.py # регистрация, логин, коллекции └── .gitignore
│ │ ├── tickets.py # загрузка тикетов ```
│ │ ├── search.py # поиск (для фронтенда)
│ │ └── api_v1.py # внешний REST API v1 ## Системные промпты
│ ├── services/ # бизнес-логика
│ │ ├── chunking.py # нарезка чанков, очистка ПД Главный системный промпт — в файле **`app.py:15`**, переменная `SYSTEM_PROMPT`:
│ │ ├── embedding.py # эмбеддинги через OpenRouter
│ │ ├── vector_store.py# Qdrant: upsert, scroll, search ```python
│ │ ├── reranking.py # сортировка результатов SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
│ │ └── llm.py # генерация ответа через OpenRouter
│ ├── config.py # настройки из .env Правила:
│ ├── database.py # подключение к БД - Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
│ └── main.py # точка входа FastAPI - Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
├── frontend/ - НЕ придумывай ответы, НЕ используй общие знания
│ └── src/ - НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
│ ├── api/client.ts # API-клиент - Укажи в конце: «Основано на тикете №...»"""
│ ├── pages/ # страницы React ```
│ │ ├── Login.tsx / Register.tsx
│ │ ├── Dashboard.tsx Именно этот промпт управляет тем, **как LLM отвечает** на вопрос специалиста. Если нужно изменить стиль ответа, тон, правила или требования к формату — редактировать здесь.
│ │ ├── CollectionView.tsx
│ │ └── Settings.tsx Промпт отправляется в LM Studio как `system`-сообщение при каждом запросе генерации ответа (строка `app.py:120-127`).
│ └── nginx.conf # конфиг Nginx
├── k8s/ # манифесты Kubernetes ### Фильтрация тикетов без решения
│ ├── 00-namespace.yaml ... 06-ingress.yaml
│ ├── host-fix-pod.yaml # debug-под для отладки ноды Паттерны для отбраковки пустых тикетов — в **`prepare_data.py:19-22`**, переменная `NON_SOLUTION`:
│ └── traefik/ # Traefik CRDs + Deployment
├── docs/ # документация ```python
│ ├── DEPLOY_DOCKER.md NON_SOLUTION = re.compile(
│ ├── DEPLOY_KUBERNETES.md r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
│ └── API.md r"|нет обратной связи|запрос завершу|открыть его снова"
├── scripts/ # скрипты r"|откройте новый|обратиться в службу поддержки"
│ ├── script.py # клиент для API r"|свяжитесь с технической"
│ └── test_api.sh r"|напишите нам|позвоните нам)", re.I
├── docker-compose.yml # локальный запуск (Docker) )
├── docker-compose.monitoring.yml # Prometheus + Grafana ```
├── tickets*.json # тестовые данные
├── README.md Если в последнем сообщении support встречается одно из этих слов — тикет считается бесполезным и не попадает в базу знаний. Можно расширять или уточнять список.
├── .env # конфиг для docker-compose
└── backend/.env.example # шаблон .env для бэкенда ---
## Технологии
| Компонент | Технология |
|-----------|-----------|
| Эмбеддинги | `intfloat/multilingual-e5-small` (384d, GPU) через `sentence-transformers` |
| Векторная БД | ChromaDB |
| Чат-модель | Qwen2.5-7B-Instruct через LM Studio (OpenAI-совместимый API) |
| Фреймворк | LangChain + Streamlit |
| Язык | Python 3.11 |
| GPU | CUDA 12.4+ |
## Git
```bash
git remote add origin https://git.itoservice.ru/dosnav/support-bot.git
git push -u origin master
``` ```

View file

@ -1,7 +0,0 @@
OPENROUTER_API_KEY=
JWT_SECRET=
OPENROUTER_LLM_MODEL=
OPENROUTER_EMBEDDING_MODEL=
DATABASE_URL=
REDIS_URL=
QDRANT_URL=

View file

@ -12,11 +12,7 @@ class Settings(BaseSettings):
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free" openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free"
openrouter_llm_model: str = "openrouter/owl-alpha" openrouter_llm_model: str = "poolside/laguna-m.1:free"
proxy_url: str = ""
proxy_login: str = ""
proxy_pass: str = ""
jwt_secret: str = "change-me-in-production" jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"

View file

@ -16,22 +16,14 @@ _client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient: def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None: if _client is None:
kwargs: dict = { _client = httpx.AsyncClient(
"base_url": OPENROUTER_BASE, base_url=OPENROUTER_BASE,
"headers": { headers={
"Authorization": f"Bearer {settings.openrouter_api_key}", "Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
"timeout": 30, timeout=30,
} )
if settings.proxy_url:
proxy = settings.proxy_url
if not proxy.startswith(("http://", "https://")):
proxy = "http://" + proxy
if settings.proxy_login and settings.proxy_pass:
proxy = proxy.replace("://", f"://{settings.proxy_login}:{settings.proxy_pass}@")
kwargs["proxy"] = proxy
_client = httpx.AsyncClient(**kwargs)
return _client return _client

View file

@ -17,22 +17,14 @@ _client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient: def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None: if _client is None:
kwargs: dict = { _client = httpx.AsyncClient(
"base_url": OPENROUTER_BASE, base_url=OPENROUTER_BASE,
"headers": { headers={
"Authorization": f"Bearer {settings.openrouter_api_key}", "Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
"timeout": 60, timeout=60,
} )
if settings.proxy_url:
proxy = settings.proxy_url
if not proxy.startswith(("http://", "https://")):
proxy = "http://" + proxy
if settings.proxy_login and settings.proxy_pass:
proxy = proxy.replace("://", f"://{settings.proxy_login}:{settings.proxy_pass}@")
kwargs["proxy"] = proxy
_client = httpx.AsyncClient(**kwargs)
return _client return _client
@ -64,11 +56,10 @@ async def generate_answer(query: str, context: str) -> str | None:
}, },
) )
if response.status_code != 200: if response.status_code != 200:
body = await response.aread() logger.error("LLM API error [%s]: %s", response.status_code, await response.aread())
logger.error("LLM API error [%s]: %s", response.status_code, body) return None
return f"LLM API error [{response.status_code}]: {body.decode(errors='replace')}"
data = response.json() data = response.json()
if "choices" not in data or not data["choices"]: if "choices" not in data or not data["choices"]:
logger.error("LLM response missing choices: %s", data.get("error", data)) logger.error("LLM response missing choices: %s", data.get("error", data))
return f"LLM response error: {data.get('error', data)}" return None
return data["choices"][0]["message"]["content"] return data["choices"][0]["message"]["content"]

View file

@ -9,7 +9,6 @@ from qdrant_client.http.models import (
VectorParams, VectorParams,
Distance, Distance,
) )
from qdrant_client.http.exceptions import UnexpectedResponse
from app.config import settings from app.config import settings
@ -53,23 +52,18 @@ def _point_id(tenant_id: str, ticket_id: str) -> int:
def get_existing_ticket_ids(tenant_id: str, collection_id: str) -> set[str]: def get_existing_ticket_ids(tenant_id: str, collection_id: str) -> set[str]:
try: scroll_filter = Filter(must=[
scroll_filter = Filter(must=[ FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)), FieldCondition(key="collection_id", match=MatchValue(value=collection_id)),
FieldCondition(key="collection_id", match=MatchValue(value=collection_id)), ])
]) points, _ = client.scroll(
points, _ = client.scroll( collection_name=COLLECTION_NAME,
collection_name=COLLECTION_NAME, limit=10000,
limit=10000, scroll_filter=scroll_filter,
scroll_filter=scroll_filter, with_payload=True,
with_payload=True, with_vectors=False,
with_vectors=False, )
) return {p.payload["ticket_id"] for p in points if "ticket_id" in (p.payload or {})}
return {p.payload["ticket_id"] for p in points if "ticket_id" in (p.payload or {})}
except UnexpectedResponse as e:
if b"doesn't exist" in e.content:
return set()
raise
async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]): async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):

View file

@ -1,58 +1,55 @@
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16-alpine
environment: environment:
POSTGRES_DB: support_bot
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: support_bot ports:
- "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
redis: redis:
image: redis:7-alpine image: redis:7-alpine
healthcheck: ports:
test: ["CMD", "redis-cli", "ping"] - "6379:6379"
interval: 5s
timeout: 3s
retries: 10
qdrant: qdrant:
image: qdrant/qdrant image: qdrant/qdrant:latest
ports:
- "6333:6333"
- "6334:6334"
volumes: volumes:
- qdrant_storage:/qdrant/storage - qdrant_data:/qdrant/storage
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
interval: 5s
timeout: 3s
retries: 10
backend: backend:
build: ./backend build: ./backend
env_file: ./backend/.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:
condition: service_healthy
redis:
condition: service_healthy
qdrant:
condition: service_healthy
frontend:
build: ./frontend
ports: ports:
- "8080:80" - "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: depends_on:
- backend - 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: volumes:
pgdata: pgdata:
qdrant_storage: qdrant_data:

View file

@ -1,303 +0,0 @@
# API
Базовый URL: `http://sovet.itoservice.ru/api/v1`
Авторизация: `Authorization: Bearer <api_key>`
> Получить API-ключ можно при регистрации через UI (отображается в настройках профиля).
---
## 1. Поиск по базе знаний
`POST /api/v1/search`
Поиск релевантных тикетов в коллекции с опциональной генерацией ответа.
### Request body
```json
{
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": true
}
```
| Поле | Тип | По умолчанию | Описание |
|------------------|---------|--------------|------------------------------------|
| `collection_id` | string | — | ID коллекции |
| `query` | string | — | Поисковый запрос |
| `generate_answer`| boolean | `true` | Сгенерировать ответ LLM или нет |
### Response
```json
{
"answer": "Проверьте подключение к интернету...",
"sources": [
{
"ticket_id": "12345",
"score": 0.5885,
"category": "техподдержка",
"full_text": "Категория: техподдержка\nПроблема: ...\nРешение: ..."
}
]
}
```
| Поле | Тип | Описание |
|----------|----------------------------|----------------------------------------|
| `answer` | string или null | Сгенерированный ответ LLM или null |
| `sources`| array of SourceItem | Найденные тикеты (от 0 до 5) |
### SourceItem
| Поле | Тип | Описание |
|------------|---------|-----------------------------|
| `ticket_id`| string | ID тикета |
| `score` | float | Релевантность (0..1) |
| `category` | string | Категория тикета |
| `full_text`| string | Полный текст решения |
### Пример (curl)
```bash
curl -s -X POST http://sovet.itoservice.ru/api/v1/search \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": true
}'
```
### Пример (Python)
```python
import requests
resp = requests.post(
"http://sovet.itoservice.ru/api/v1/search",
headers={"Authorization": "Bearer <api-key>"},
json={
"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"query": "не могу начать смену в приложении",
"generate_answer": True,
},
)
data = resp.json()
print(data["answer"])
```
---
## 2. Загрузка тикетов
`POST /api/v1/tickets?collection_id=<uuid>`
Загрузка массива тикетов в коллекцию. Дубликаты по `ticket_id` автоматически пропускаются.
### Request body
```json
{
"tickets": [
{
"ticket_id": 12345,
"category": "техподдержка",
"description": "Не работает принтер",
"client": "Иван",
"messages": [
{"role": "user", "text": "У меня не печатает принтер"},
{"role": "assistant", "text": "Проверьте подключение кабеля USB к компьютеру..."}
]
}
]
}
```
| Поле | Тип | По умолчанию | Описание |
|---------------|----------------------|--------------|-----------------------------|
| `ticket_id` | int или string | — | Уникальный ID тикета |
| `category` | string | `""` | Категория проблемы |
| `description` | string | `""` | Краткое описание проблемы |
| `client` | string | `""` | Имя клиента |
| `messages` | array of Message | `[]` | Переписка (user + assistant)|
### Message
```json
{"role": "user|assistant", "text": "текст сообщения"}
```
- `role``"user"` или `"assistant"`. Сообщения `"assistant"` используются как решение.
- `text` — текст сообщения. Решение должно быть длиннее 100 символов и не заканчиваться вопросительным знаком.
### Response
```json
{
"processed": 1,
"skipped": 0,
"collection_ticket_count": 100
}
```
| Поле | Тип | Описание |
|------------------------|-------|-----------------------------------|
| `processed` | int | Количество обработанных тикетов |
| `skipped` | int | Пропущено (дубликат или нет решения)|
| `collection_ticket_count`| int | Всего тикетов в коллекции |
### Почему тикет может быть пропущен (skipped)
1. **Дубликат**`ticket_id` уже существует в коллекции
2. **Нет решения** — в `messages` нет сообщения от `assistant` длиннее 100 символов
3. **Решение — вопрос** — последнее сообщение `assistant` заканчивается на `?` и короче 150 символов
4. **Решение — заглушка** — текст содержит фразы вроде «уточните», «приложите скрин», «позвоните нам» и т.п.
### Пример (curl)
```bash
curl -s -X POST 'http://sovet.itoservice.ru/api/v1/tickets?collection_id=<uuid>' \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"tickets": [{"ticket_id": 1, "description": "Проблема", "messages": [{"role": "user", "text": "вопрос"}, {"role": "assistant", "text": "Проверьте соединение и перезагрузите устройство, затем повторите попытку. Если не помогает обратитесь к администратору системы."}]}]}'
```
---
## 3. Список коллекций
`GET /api/v1/collections`
### Response
```json
[
{
"id": "83f64913-9cb6-495e-984a-cbae31eacf54",
"user_id": "30939310-e3a3-4993-b1ac-10c81b751843",
"name": "OFRS",
"ticket_count": 2898,
"created_at": "2026-06-29T11:45:27.866413Z"
}
]
```
---
## 4. Профиль пользователя
`GET /api/v1/me`
### Response
```json
{
"id": "30939310-e3a3-4993-b1ac-10c81b751843",
"email": "user@example.com",
"api_key": "abc123...",
"plan": "pro",
"created_at": "2026-06-29T11:45:27.866413Z"
}
```
---
## 5. Сброс API-ключа
`PATCH /api/v1/me/api-key`
### Response
```json
{
"api_key": "новый-ключ-32-символа"
}
```
---
## 6. JWT-эндпоинты (для UI)
Эти эндпоинты требуют JWT-токен (получается через `/api/login`), а не API-ключ.
| Метод | Путь | Описание |
|-------|-------------------------------|------------------------------|
| POST | `/api/register` | Регистрация |
| POST | `/api/login` | Вход (возвращает JWT) |
| GET | `/api/me` | Профиль |
| POST | `/api/collections` | Создать коллекцию |
| GET | `/api/collections` | Список коллекций |
| GET | `/api/collections/{id}` | Информация о коллекции |
| DELETE| `/api/collections/{id}` | Удалить коллекцию |
| POST | `/api/collections/{id}/tickets` | Загрузить тикеты (JSON)|
| POST | `/api/collections/{id}/tickets/upload`| Загрузить тикеты (файл)|
| POST | `/api/search` | Поиск |
### Регистрация
```json
POST /api/register
{
"email": "user@example.com",
"password": "secret123",
"plan": "free"
}
→ 201 {"access_token": "jwt...", "token_type": "bearer"}
```
### Логин
```json
POST /api/login
{
"email": "user@example.com",
"password": "secret123"
}
→ {"access_token": "jwt...", "token_type": "bearer"}
```
---
## 7. Ошибки
Все ошибки возвращают стандартный формат FastAPI:
```json
{
"detail": "Collection not found"
}
```
HTTP-статусы:
- `200` — успех
- `201` — создано
- `400` — неверный запрос
- `401` — неавторизован
- `404` — не найдено
- `429` — превышен лимит запросов (30 req/min)
- `500` — внутренняя ошибка сервера
---
## 8. Rate limiting
30 запросов в минуту на один IP-адрес. При превышении — `429 Too Many Requests`.
Лимит действует на все эндпоинты, кроме `/api/health`.
---
## 9. Health check
`GET /api/health`
```json
{"status": "ok"}
```
Не требует авторизации.

View file

@ -1,115 +0,0 @@
# Развёртывание через Docker
## Требования
- Docker Engine 24+
- Docker Compose v2+
- Git
## Состав
Сервис состоит из 5 компонентов:
| Компонент | Образ | Назначение |
|-------------|------------------------------------|----------------------|
| postgres | `postgres:16` | База данных |
| redis | `redis:7-alpine` | Кэш / Celery broker |
| qdrant | `qdrant/qdrant` | Векторная БД |
| backend | сборка из `./backend` | FastAPI приложение |
| frontend | сборка из `./frontend` | React SPA (nginx) |
Опционально — мониторинг (Prometheus + Grafana) из `docker-compose.monitoring.yml`.
## 1. Клонирование и настройка
```bash
git clone <repo-url> support-bot-saas
cd support-bot-saas
```
Создайте файл `.env` в корне проекта:
```ini
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_EMBEDDING_MODEL=nvidia/llama-nemotron-embed-vl-1b-v2:free
OPENROUTER_LLM_MODEL=openai/gpt-oss-120b:free
JWT_SECRET=<случайная строка 32+ символов>
# Опционально: HTTP-прокси для OpenRouter
PROXY_URL=192.168.1.1:3128
PROXY_LOGIN=
PROXY_PASS=
# Эти переменные переопределяются в docker-compose.yml для работы по container name:
# DATABASE_URL, REDIS_URL, QDRANT_URL — можно не указывать
```
## 2. Запуск
```bash
docker compose up -d
```
Проверка:
```bash
docker compose ps
curl http://localhost:8080/api/health
# {"status":"ok"}
```
Фронтенд будет доступен на `http://localhost:8080`.
## 3. Мониторинг (опционально)
```bash
docker compose -f docker-compose.monitoring.yml up -d
```
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3001` (admin / admin)
## 4. Остановка
```bash
docker compose down
# С удалением томов БД:
docker compose down -v
```
## 5. Переменные окружения
| Переменная | По умолчанию | Обязательная |
|-------------------------------|---------------------------------------------------|--------------|
| `OPENROUTER_API_KEY` | `""` | Да |
| `OPENROUTER_EMBEDDING_MODEL` | `nvidia/llama-nemotron-embed-vl-1b-v2:free` | Нет |
| `OPENROUTER_LLM_MODEL` | `openrouter/owl-alpha` | Нет |
| `JWT_SECRET` | `change-me-in-production` | Да (в проде) |
| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot` | Нет (переопределяется в compose) |
| `REDIS_URL` | `redis://localhost:6379/0` | Нет (переопределяется в compose) |
| `QDRANT_URL` | `http://localhost:6333` | Нет (переопределяется в compose) |
| `PROXY_URL` | `""` | Нет |
| `PROXY_LOGIN` | `""` | Нет |
| `PROXY_PASS` | `""` | Нет |
| `JWT_EXPIRE_MINUTES` | `1440` | Нет |
## 6. Сборка образов вручную
```bash
# Бэкенд
docker build -t support-bot-backend ./backend
# Фронтенд
docker build -t support-bot-frontend ./frontend
```
После сборки замените `build: ./backend` и `build: ./frontend` в `docker-compose.yml` на `image: support-bot-backend` и `image: support-bot-frontend`, либо удалите `build` секции и пересоберите compose.
## 7. Тома (volumes)
| Том | Назначение | Размер по умолчанию |
|-----------------|----------------------|---------------------|
| `pgdata` | Данные PostgreSQL | без ограничения |
| `qdrant_storage`| Данные Qdrant | без ограничения |
Тома создаются автоматически в `docker compose up`.

View file

@ -1,182 +0,0 @@
# Развёртывание в Kubernetes
## Требования
- Kubernetes 1.28+ (проверено на k0s 1.36.1)
- kubectl 1.28+
- Доступ к registry с образами backend и frontend
## Состав manifests
Все манифесты в `k8s/`. Порядок применения — по префиксу номера.
| Файл | Ресурсы |
|-------------------------|-------------------------------------|
| `00-namespace.yaml` | Namespace `support-bot` |
| `01-postgres.yaml` | PV, PVC, Deployment, Service (postgres:16-alpine) |
| `02-redis.yaml` | PV, PVC, Deployment, Service (redis:7-alpine) |
| `03-qdrant.yaml` | PV, PVC, Deployment, Service (qdrant/qdrant) |
| `04-backend.yaml` | Deployment, Service (FastAPI) |
| `05-frontend.yaml` | Deployment, Service (nginx SPA) |
| `06-ingress.yaml` | IngressRoute (Traefik CRD) |
| `host-fix-pod.yaml` | Debug pod для отладки узла |
## 1. Подготовка
### 1.1 Создание namespace
```bash
kubectl apply -f k8s/00-namespace.yaml
```
### 1.2 Настройка Traefik (Ingress Controller)
```bash
# CRDs и RBAC (однократно)
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.3/docs/content/reference/dynamic-configuration/kubernetes-crd-rbac.yml
# Сам Traefik
kubectl apply -f k8s/traefik/01-core.yaml
```
Traefik запускается с `hostNetwork: true` на портах 80/443.
### 1.3 Настройка Private Registry (если необходимо)
```bash
kubectl create secret docker-registry regcred \
--docker-server=<registry> \
--docker-username=<user> \
--docker-password=<pass> \
-n support-bot
```
Secret `regcred` уже указан в `imagePullSecrets` у backend и frontend.
### 1.4 Secrets
Секрет `app-env` содержит переменные окружения для backend. Создаётся из `.env`:
```bash
kubectl create secret generic app-env \
--from-env-file=.env \
-n support-bot
```
Состав ключей в `app-env`:
| Ключ | Назначение |
|------------------------------|--------------------------------------|
| `OPENROUTER_API_KEY` | API-ключ OpenRouter |
| `OPENROUTER_EMBEDDING_MODEL` | Модель эмбеддингов |
| `OPENROUTER_LLM_MODEL` | Модель LLM |
| `JWT_SECRET` | Секрет для JWT |
| `PROXY_URL` | Адрес HTTP-прокси (опционально) |
| `PROXY_LOGIN` | Логин прокси (опционально) |
| `PROXY_PASS` | Пароль прокси (опционально) |
Переменные `DATABASE_URL`, `REDIS_URL`, `QDRANT_URL` заданы в Deployment статически (через container_name).
## 2. Развёртывание
### 2.1 Базы данных
```bash
kubectl apply -f k8s/01-postgres.yaml
kubectl apply -f k8s/02-redis.yaml
kubectl apply -f k8s/03-qdrant.yaml
```
Дождаться запуска:
```bash
kubectl wait --for=condition=ready pod -l app=postgres -n support-bot --timeout=120s
kubectl wait --for=condition=ready pod -l app=redis -n support-bot --timeout=60s
kubectl wait --for=condition=ready pod -l app=qdrant -n support-bot --timeout=60s
```
### 2.2 Backend
```bash
kubectl apply -f k8s/04-backend.yaml
kubectl rollout status deployment/backend -n support-bot
```
Проверка:
```bash
kubectl exec deploy/backend -n support-bot -- curl -s http://localhost:8000/api/health
# {"status":"ok"}
```
### 2.3 Frontend
```bash
kubectl apply -f k8s/05-frontend.yaml
kubectl rollout status deployment/frontend -n support-bot
```
### 2.4 Ingress
```bash
kubectl apply -f k8s/06-ingress.yaml
```
IngressRoute слушает порт 80 (entrypoint `web`), хост задаётся в манифесте.
## 3. Проверка
```bash
curl http://<node-ip>/api/health
# {"status":"ok"}
curl http://<node-ip>/
# HTML страница
```
## 4. Обновление backend
После изменения кода или `.env`:
```bash
# 1. Собрать образ
docker build -t <registry>/backend:latest ./backend
docker push <registry>/backend:latest
# 2. Обновить секрет (если менялся .env)
kubectl delete secret app-env -n support-bot
kubectl create secret generic app-env --from-env-file=.env -n support-bot
# 3. Перекатить под
kubectl rollout restart deployment/backend -n support-bot
kubectl rollout status deployment/backend -n support-bot
```
## 5. Persistent Volumes
| Компонент | Размер | Путь на ноде |
|-----------|--------|-----------------------------|
| postgres | 10Gi | `/mnt/data/postgres` |
| redis | 5Gi | `/mnt/data/redis` |
| qdrant | 20Gi | `/mnt/data/qdrant` |
PV привязаны к ноде через `nodeAffinity`. При смене воркера — заменить значение `hostnames` в манифестах.
## 6. Удаление
```bash
# Удалить всё, кроме PV
kubectl delete -f k8s/06-ingress.yaml
kubectl delete -f k8s/05-frontend.yaml
kubectl delete -f k8s/04-backend.yaml
kubectl delete -f k8s/03-qdrant.yaml
kubectl delete -f k8s/02-redis.yaml
kubectl delete -f k8s/01-postgres.yaml
# Секреты
kubectl delete secret app-env regcred -n support-bot
# Namespace (удалит всё, включая PV/PVC)
kubectl delete namespace support-bot
```

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-192.168.123.5 - worker-172.16.0.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-192.168.123.5 - worker-172.16.0.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -17,7 +17,7 @@ spec:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: values:
- worker-192.168.123.5 - worker-172.16.0.5
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim

View file

@ -49,21 +49,6 @@ spec:
secretKeyRef: secretKeyRef:
name: app-env name: app-env
key: JWT_SECRET key: JWT_SECRET
- name: PROXY_URL
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_URL
- name: PROXY_LOGIN
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_LOGIN
- name: PROXY_PASS
valueFrom:
secretKeyRef:
name: app-env
key: PROXY_PASS
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/health path: /api/health

View file

@ -15,8 +15,6 @@ spec:
labels: labels:
app: frontend app: frontend
spec: spec:
imagePullSecrets:
- name: regcred
containers: containers:
- name: frontend - name: frontend
image: git.itoservice.ru/dosnav/support-bot-saas/frontend:latest image: git.itoservice.ru/dosnav/support-bot-saas/frontend:latest

View file

@ -13,3 +13,18 @@ spec:
- name: frontend - name: frontend
port: 80 port: 80
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: backend
namespace: support-bot
spec:
entryPoints:
- web
routes:
- kind: Rule
match: Host(`api.sovet.itoservice.ru`) && (PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/openapi.json`) || PathPrefix(`/metrics`))
services:
- name: backend
port: 8000

View file

@ -1,23 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: host-fix
namespace: support-bot
spec:
hostPID: true
hostNetwork: true
containers:
- name: host-fix
image: cr-internal.twcstorage.ru/k0sproject/calico-node:v3.32.0-0
command: ["sleep", "3600"]
securityContext:
privileged: true
runAsUser: 0
volumeMounts:
- name: host-root
mountPath: /host
volumes:
- name: host-root
hostPath:
path: /
type: Directory

View file

@ -1,12 +0,0 @@
import requests, sys
r = requests.post(
"http://sovet.itoservice.ru/api/v1/search",
headers={"Authorization": "Bearer 5fd81e60ba17418fa930cdf70504ebb2"},
json={"collection_id": "83f64913-9cb6-495e-984a-cbae31eacf54", "query": sys.argv[1], "generate_answer": True},
)
data = r.json()
if data["answer"]:
print(data["answer"])
else:
print("Ошибка генерации ответа (LLM недоступен). Найдено источников:", len(data["sources"]))

View file

@ -1,56 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://sovet.itoservice.ru}"
API_KEY="${1:-5fd81e60ba17418fa930cdf70504ebb2}"
COLLECTION_ID="${2:-}"
echo "=== Коллекции ==="
COLLECTIONS=$(curl -s -X GET "$BASE_URL/api/v1/collections" \
-H "Authorization: Bearer $API_KEY")
echo "$COLLECTIONS" | python3 -m json.tool 2>/dev/null || echo "$COLLECTIONS"
if [ -z "$COLLECTION_ID" ]; then
COLLECTION_ID=$(echo "$COLLECTIONS" | python3 -c "
import sys, json
data = json.load(sys.stdin)
if data: print(data[0]['id'])
else: print('')
" 2>/dev/null || echo "")
fi
if [ -z "$COLLECTION_ID" ]; then
echo "Нет коллекций. Создайте через админку, затем: $0 <api_key> <collection_id>"
exit 1
fi
echo "collection_id=$COLLECTION_ID"
echo ""
echo "=== Загрузка тикета ==="
curl -s -X POST "$BASE_URL/api/v1/tickets?collection_id=$COLLECTION_ID" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tickets": [{
"ticket_id": 999,
"category": "мобильное приложение",
"description": "Не могу начать смену в приложении",
"client": "Иван",
"messages": [
{"role": "user", "text": "Не могу начать смену в приложении, кнопка не активна"},
{"role": "assistant", "text": "Проверьте подключение к интернету и перезапустите приложение. Если не помогает, очистите кеш в настройках телефона. Убедитесь, что установлена последняя версия приложения из магазина."}
]
}]
}' | python3 -m json.tool 2>/dev/null || true
echo ""
echo "=== Поиск ==="
curl -s -X POST "$BASE_URL/api/v1/search" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"collection_id\": \"$COLLECTION_ID\",
\"query\": \"не могу начать смену в приложении\",
\"generate_answer\": true
}" | python3 -m json.tool 2>/dev/null || true