Compare commits
10 commits
feat/rest-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0527c9bf6 | ||
|
|
f13b5a4869 | ||
|
|
cca3457f52 | ||
|
|
696c555740 | ||
|
|
98717f3838 | ||
|
|
11d018d4f6 | ||
|
|
14025ae30d | ||
|
|
348b744960 | ||
|
|
4166c6f0ef | ||
|
|
5f6ad253de |
19 changed files with 1159 additions and 207 deletions
484
README.md
484
README.md
|
|
@ -1,172 +1,394 @@
|
||||||
# RAG-ассистент техподдержки
|
# Support Bot SaaS — RAG-ассистент техподдержки
|
||||||
|
|
||||||
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты и формирует ответ на их основе.
|
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты в векторной БД и формирует ответ на их основе через LLM.
|
||||||
|
|
||||||
|
Развёрнута на 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. Подготовка данных (`prepare_data.py`)
|
### 1. Загрузка тикетов
|
||||||
|
|
||||||
Что делает скрипт:
|
Пользователь загружает 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`
|
||||||
|
|
||||||
- Читает все файлы `tickets*.json` в папке проекта (сейчас 6 файлов, ~3300 тикетов)
|
### 2. Поиск и генерация
|
||||||
- **Чистит текст**: удаляет цитаты `[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
|
||||||
python prepare_data.py
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
Скрипт создаст папку `chroma_db/` с векторной базой.
|
|
||||||
|
|
||||||
### Запуск
|
### Сборка Docker-образов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
streamlit run app.py --server.headless true
|
docker build -t git.itoservice.ru/dosnav/support-bot-saas/backend:latest ./backend
|
||||||
|
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
|
||||||
|
|
||||||
Для генерации ответов нужна чат-модель в LM Studio:
|
```bash
|
||||||
1. Открыть LM Studio
|
kubectl apply -f k8s/00-namespace.yaml
|
||||||
2. Вкладка Developer → загрузить модель (например, `Qwen2.5-7B-Instruct`)
|
kubectl apply -f k8s/01-postgres.yaml
|
||||||
3. Нажать Start Server
|
kubectl apply -f k8s/02-redis.yaml
|
||||||
4. API будет доступен на `http://localhost:1234/v1`
|
kubectl apply -f k8s/03-qdrant.yaml
|
||||||
|
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/
|
support-bot-saas/
|
||||||
├── tickets*.json # исходные данные (несколько файлов)
|
├── backend/
|
||||||
├── prepare_data.py # подготовка и векторизация
|
│ └── app/
|
||||||
├── app.py # Streamlit-интерфейс + RAG
|
│ ├── auth/ # JWT + API-ключ аутентификация
|
||||||
├── requirements.txt # зависимости
|
│ ├── models/ # SQLAlchemy модели + Pydantic схемы
|
||||||
├── chroma_db/ # векторная БД (создаётся prepare_data.py)
|
│ ├── routers/ # FastAPI роутеры
|
||||||
└── .gitignore
|
│ │ ├── auth.py # регистрация, логин, коллекции
|
||||||
```
|
│ │ ├── tickets.py # загрузка тикетов
|
||||||
|
│ │ ├── search.py # поиск (для фронтенда)
|
||||||
## Системные промпты
|
│ │ └── api_v1.py # внешний REST API v1
|
||||||
|
│ ├── services/ # бизнес-логика
|
||||||
Главный системный промпт — в файле **`app.py:15`**, переменная `SYSTEM_PROMPT`:
|
│ │ ├── chunking.py # нарезка чанков, очистка ПД
|
||||||
|
│ │ ├── embedding.py # эмбеддинги через OpenRouter
|
||||||
```python
|
│ │ ├── vector_store.py# Qdrant: upsert, scroll, search
|
||||||
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
│ │ ├── reranking.py # сортировка результатов
|
||||||
|
│ │ └── llm.py # генерация ответа через OpenRouter
|
||||||
Правила:
|
│ ├── config.py # настройки из .env
|
||||||
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
│ ├── database.py # подключение к БД
|
||||||
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
│ └── main.py # точка входа FastAPI
|
||||||
- НЕ придумывай ответы, НЕ используй общие знания
|
├── frontend/
|
||||||
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
│ └── src/
|
||||||
- Укажи в конце: «Основано на тикете №...»"""
|
│ ├── api/client.ts # API-клиент
|
||||||
```
|
│ ├── pages/ # страницы React
|
||||||
|
│ │ ├── Login.tsx / Register.tsx
|
||||||
Именно этот промпт управляет тем, **как LLM отвечает** на вопрос специалиста. Если нужно изменить стиль ответа, тон, правила или требования к формату — редактировать здесь.
|
│ │ ├── Dashboard.tsx
|
||||||
|
│ │ ├── CollectionView.tsx
|
||||||
Промпт отправляется в LM Studio как `system`-сообщение при каждом запросе генерации ответа (строка `app.py:120-127`).
|
│ │ └── Settings.tsx
|
||||||
|
│ └── nginx.conf # конфиг Nginx
|
||||||
### Фильтрация тикетов без решения
|
├── k8s/ # манифесты Kubernetes
|
||||||
|
│ ├── 00-namespace.yaml ... 06-ingress.yaml
|
||||||
Паттерны для отбраковки пустых тикетов — в **`prepare_data.py:19-22`**, переменная `NON_SOLUTION`:
|
│ ├── host-fix-pod.yaml # debug-под для отладки ноды
|
||||||
|
│ └── traefik/ # Traefik CRDs + Deployment
|
||||||
```python
|
├── docs/ # документация
|
||||||
NON_SOLUTION = re.compile(
|
│ ├── DEPLOY_DOCKER.md
|
||||||
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
│ ├── DEPLOY_KUBERNETES.md
|
||||||
r"|нет обратной связи|запрос завершу|открыть его снова"
|
│ └── API.md
|
||||||
r"|откройте новый|обратиться в службу поддержки"
|
├── scripts/ # скрипты
|
||||||
r"|свяжитесь с технической"
|
│ ├── script.py # клиент для API
|
||||||
r"|напишите нам|позвоните нам)", re.I
|
│ └── test_api.sh
|
||||||
)
|
├── docker-compose.yml # локальный запуск (Docker)
|
||||||
```
|
├── docker-compose.monitoring.yml # Prometheus + Grafana
|
||||||
|
├── tickets*.json # тестовые данные
|
||||||
Если в последнем сообщении support встречается одно из этих слов — тикет считается бесполезным и не попадает в базу знаний. Можно расширять или уточнять список.
|
├── README.md
|
||||||
|
├── .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
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
JWT_SECRET=
|
||||||
|
OPENROUTER_LLM_MODEL=
|
||||||
|
OPENROUTER_EMBEDDING_MODEL=
|
||||||
|
DATABASE_URL=
|
||||||
|
REDIS_URL=
|
||||||
|
QDRANT_URL=
|
||||||
|
|
@ -12,7 +12,11 @@ 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 = "poolside/laguna-m.1:free"
|
openrouter_llm_model: str = "openrouter/owl-alpha"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,22 @@ _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:
|
||||||
_client = httpx.AsyncClient(
|
kwargs: dict = {
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,22 @@ _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:
|
||||||
_client = httpx.AsyncClient(
|
kwargs: dict = {
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,10 +64,11 @@ async def generate_answer(query: str, context: str) -> str | None:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error("LLM API error [%s]: %s", response.status_code, await response.aread())
|
body = await response.aread()
|
||||||
return None
|
logger.error("LLM API error [%s]: %s", response.status_code, body)
|
||||||
|
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 None
|
return f"LLM response error: {data.get('error', data)}"
|
||||||
return data["choices"][0]["message"]["content"]
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -52,18 +53,23 @@ 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]:
|
||||||
scroll_filter = Filter(must=[
|
try:
|
||||||
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
|
scroll_filter = Filter(must=[
|
||||||
FieldCondition(key="collection_id", match=MatchValue(value=collection_id)),
|
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
|
||||||
])
|
FieldCondition(key="collection_id", match=MatchValue(value=collection_id)),
|
||||||
points, _ = client.scroll(
|
])
|
||||||
collection_name=COLLECTION_NAME,
|
points, _ = client.scroll(
|
||||||
limit=10000,
|
collection_name=COLLECTION_NAME,
|
||||||
scroll_filter=scroll_filter,
|
limit=10000,
|
||||||
with_payload=True,
|
scroll_filter=scroll_filter,
|
||||||
with_vectors=False,
|
with_payload=True,
|
||||||
)
|
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]]):
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,58 @@
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: support_bot
|
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
ports:
|
POSTGRES_DB: support_bot
|
||||||
- "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
|
||||||
ports:
|
healthcheck:
|
||||||
- "6379:6379"
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:latest
|
image: qdrant/qdrant
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
- "6334:6334"
|
|
||||||
volumes:
|
volumes:
|
||||||
- qdrant_data:/qdrant/storage
|
- qdrant_storage:/qdrant/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
env_file: ./backend/.env
|
||||||
- "8000:8000"
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
|
||||||
- REDIS_URL=redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
- QDRANT_URL=http://qdrant:6333
|
QDRANT_URL: http://qdrant:6333
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
- redis
|
condition: service_healthy
|
||||||
- qdrant
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
worker:
|
frontend:
|
||||||
build: ./backend
|
build: ./frontend
|
||||||
command: celery -A app.workers.process_file worker --loglevel=info
|
ports:
|
||||||
env_file: .env
|
- "8080:80"
|
||||||
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:
|
||||||
- postgres
|
- backend
|
||||||
- redis
|
|
||||||
- qdrant
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
qdrant_data:
|
qdrant_storage:
|
||||||
|
|
|
||||||
303
docs/API.md
Normal file
303
docs/API.md
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# 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"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не требует авторизации.
|
||||||
115
docs/DEPLOY_DOCKER.md
Normal file
115
docs/DEPLOY_DOCKER.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Развёртывание через 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`.
|
||||||
182
docs/DEPLOY_KUBERNETES.md
Normal file
182
docs/DEPLOY_KUBERNETES.md
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# Развёртывание в 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
|
||||||
|
```
|
||||||
|
|
@ -17,7 +17,7 @@ spec:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/hostname
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- worker-172.16.0.5
|
- worker-192.168.123.5
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ spec:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/hostname
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- worker-172.16.0.5
|
- worker-192.168.123.5
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ spec:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/hostname
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- worker-172.16.0.5
|
- worker-192.168.123.5
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,21 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,3 @@ 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
|
|
||||||
|
|
|
||||||
23
k8s/host-fix-pod.yaml
Normal file
23
k8s/host-fix-pod.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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
|
||||||
12
scripts/script.py
Normal file
12
scripts/script.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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"]))
|
||||||
56
scripts/test_api.sh
Normal file
56
scripts/test_api.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/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
|
||||||
Loading…
Add table
Reference in a new issue