Compare commits
2 commits
c4924b4da8
...
5f6ad253de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6ad253de | ||
|
|
69c2bc114b |
5 changed files with 459 additions and 133 deletions
391
README.md
391
README.md
|
|
@ -1,172 +1,301 @@
|
||||||
# RAG-ассистент техподдержки
|
# Support Bot SaaS — RAG-ассистент техподдержки
|
||||||
|
|
||||||
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты и формирует ответ на их основе.
|
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты в векторной БД и формирует ответ на их основе через LLM.
|
||||||
|
|
||||||
|
Развёрнута на Kubernetes (k0s), используются OpenRouter для эмбеддингов и LLM.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
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** | `poolside/laguna-m.1:free` через OpenRouter |
|
||||||
|
| **Ingress** | Traefik v3.7 |
|
||||||
|
| **Оркестрация** | Kubernetes (k0s v1.36.1) |
|
||||||
|
| **CI** | GitLab CI (Forgejo) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API v1
|
||||||
|
|
||||||
|
Аутентификация: `Authorization: Bearer <API-ключ>`
|
||||||
|
|
||||||
|
API-ключ можно посмотреть и сбросить на странице **Настройки** в веб-интерфейсе.
|
||||||
|
|
||||||
|
**Базовый URL:** `http://api.sovet.itoservice.ru/api/v1`
|
||||||
|
|
||||||
|
### Получить информацию об аккаунте
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <API_KEY>" http://api.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://api.sovet.itoservice.ru/api/v1/me/api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "новый-ключ"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Список коллекций
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <API_KEY>" http://api.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://api.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://api.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
|
||||||
Паттерны для отбраковки пустых тикетов — в **`prepare_data.py:19-22`**, переменная `NON_SOLUTION`:
|
│ ├── 01-postgres.yaml ... 06-ingress.yaml
|
||||||
|
│ └── traefik/ # Traefik CRDs + Deployment
|
||||||
```python
|
├── tickets*.json # тестовые данные
|
||||||
NON_SOLUTION = re.compile(
|
└── README.md
|
||||||
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
|
||||||
r"|нет обратной связи|запрос завершу|открыть его снова"
|
|
||||||
r"|откройте новый|обратиться в службу поддержки"
|
|
||||||
r"|свяжитесь с технической"
|
|
||||||
r"|напишите нам|позвоните нам)", re.I
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Если в последнем сообщении support встречается одно из этих слов — тикет считается бесполезным и не попадает в базу знаний. Можно расширять или уточнять список.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технологии
|
|
||||||
|
|
||||||
| Компонент | Технология |
|
|
||||||
|-----------|-----------|
|
|
||||||
| Эмбеддинги | `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
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.routers import auth, tickets, search
|
from app.routers import auth, tickets, search, api_v1
|
||||||
from app.middleware import RateLimitMiddleware
|
from app.middleware import RateLimitMiddleware
|
||||||
from app.metrics import MetricsMiddleware, metrics_endpoint
|
from app.metrics import MetricsMiddleware, metrics_endpoint
|
||||||
|
|
||||||
|
|
@ -38,6 +38,7 @@ app.add_middleware(RateLimitMiddleware, requests_per_minute=30)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(tickets.router)
|
app.include_router(tickets.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
|
app.include_router(api_v1.router)
|
||||||
|
|
||||||
app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])
|
app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])
|
||||||
|
|
||||||
|
|
|
||||||
153
backend/app/routers/api_v1.py
Normal file
153
backend/app/routers/api_v1.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.api_key import get_user_by_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.collection import Collection
|
||||||
|
from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse, UserResponse, CollectionResponse
|
||||||
|
from app.models.search_schemas import SearchRequest, SearchResponse, SourceItem
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.embedding import embed_query
|
||||||
|
from app.services.vector_store import search as qdrant_search, get_existing_ticket_ids
|
||||||
|
from app.services.reranking import rerank
|
||||||
|
from app.services.llm import generate_answer
|
||||||
|
from app.services.chunking import build_chunk
|
||||||
|
from app.services.embedding import embed_texts
|
||||||
|
from app.services.vector_store import upsert_chunks
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["API v1"])
|
||||||
|
|
||||||
|
FETCH_K = 20
|
||||||
|
FINAL_K = 5
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_me(user: User = Depends(get_user_by_api_key)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/api-key")
|
||||||
|
async def rotate_api_key(
|
||||||
|
user: User = Depends(get_user_by_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user.api_key = str(uuid.uuid4().hex[:32])
|
||||||
|
await db.commit()
|
||||||
|
return {"api_key": user.api_key}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections", response_model=list[CollectionResponse])
|
||||||
|
async def list_collections(
|
||||||
|
user: User = Depends(get_user_by_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Collection).where(Collection.user_id == user.id).order_by(Collection.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets", response_model=TicketsUploadResponse)
|
||||||
|
async def upload_tickets(
|
||||||
|
body: TicketsUpload,
|
||||||
|
collection_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_user_by_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Collection).where(Collection.id == collection_id, Collection.user_id == user.id)
|
||||||
|
)
|
||||||
|
collection = result.scalar_one_or_none()
|
||||||
|
if not collection:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
||||||
|
|
||||||
|
existing_ids = get_existing_ticket_ids(str(user.id), str(collection_id))
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
for t in body.tickets:
|
||||||
|
if t.ticket_id in existing_ids:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
ticket_dict = {
|
||||||
|
"ticket_id": t.ticket_id,
|
||||||
|
"category": t.category,
|
||||||
|
"description": t.description,
|
||||||
|
"client": t.client,
|
||||||
|
"messages": [{"role": m.role, "text": m.text} for m in t.messages],
|
||||||
|
}
|
||||||
|
chunk = build_chunk(ticket_dict)
|
||||||
|
if chunk:
|
||||||
|
chunk["tenant_id"] = str(user.id)
|
||||||
|
chunk["collection_id"] = str(collection_id)
|
||||||
|
chunks.append(chunk)
|
||||||
|
processed += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
if chunks:
|
||||||
|
search_texts = [c["search_text"] for c in chunks]
|
||||||
|
vectors = await embed_texts(search_texts)
|
||||||
|
await upsert_chunks(str(user.id), chunks, vectors)
|
||||||
|
collection.ticket_count += processed
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TicketsUploadResponse(
|
||||||
|
processed=processed,
|
||||||
|
skipped=skipped,
|
||||||
|
collection_ticket_count=collection.ticket_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search", response_model=SearchResponse)
|
||||||
|
async def search(
|
||||||
|
body: SearchRequest,
|
||||||
|
user: User = Depends(get_user_by_api_key),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Collection).where(
|
||||||
|
Collection.id == uuid.UUID(body.collection_id),
|
||||||
|
Collection.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
collection = result.scalar_one_or_none()
|
||||||
|
if not collection:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
||||||
|
|
||||||
|
query_vector = await embed_query(body.query)
|
||||||
|
|
||||||
|
qdrant_results = await qdrant_search(
|
||||||
|
tenant_id=str(user.id),
|
||||||
|
query_vector=query_vector,
|
||||||
|
collection_id=body.collection_id,
|
||||||
|
k=FETCH_K,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not qdrant_results:
|
||||||
|
return SearchResponse(
|
||||||
|
answer="Недостаточно информации в истории обращений" if body.generate_answer else None,
|
||||||
|
sources=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
reranked = rerank(body.query, qdrant_results, k=FINAL_K)
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
SourceItem(ticket_id=r["ticket_id"], score=r["score"], category=r["category"], full_text=r["full_text"])
|
||||||
|
for r in reranked
|
||||||
|
]
|
||||||
|
|
||||||
|
answer = None
|
||||||
|
if body.generate_answer:
|
||||||
|
context = "\n\n".join(
|
||||||
|
f"Тикет №{r['ticket_id']} ({r['category']}):\n{r['full_text']}"
|
||||||
|
for r in reranked
|
||||||
|
)
|
||||||
|
answer = await generate_answer(body.query, context)
|
||||||
|
|
||||||
|
return SearchResponse(answer=answer, sources=sources)
|
||||||
|
|
@ -120,4 +120,6 @@ export const api = {
|
||||||
body: JSON.stringify({ tickets }),
|
body: JSON.stringify({ tickets }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
rotateApiKey: () => request<{ api_key: string }>('/api/v1/me/api-key', { method: 'PATCH' }),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { api, getToken, User } from '../api/client'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [rotating, setRotating] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
|
|
@ -13,6 +15,26 @@ export default function Settings() {
|
||||||
api.me().then(setUser)
|
api.me().then(setUser)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
async function copyKey() {
|
||||||
|
if (!user) return
|
||||||
|
await navigator.clipboard.writeText(user.api_key)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateKey() {
|
||||||
|
if (!confirm('Сгенерировать новый API-ключ? Старый перестанет работать.')) return
|
||||||
|
setRotating(true)
|
||||||
|
try {
|
||||||
|
const res = await api.rotateApiKey()
|
||||||
|
setUser(prev => prev ? { ...prev, api_key: res.api_key } : null)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Ошибка: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setRotating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
|
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
|
||||||
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
||||||
|
|
@ -24,7 +46,26 @@ export default function Settings() {
|
||||||
{user && (
|
{user && (
|
||||||
<div>
|
<div>
|
||||||
<p><strong>Email:</strong> {user.email}</p>
|
<p><strong>Email:</strong> {user.email}</p>
|
||||||
<p><strong>API Key:</strong> <code>{user.api_key}</code></p>
|
<p>
|
||||||
|
<strong>API Key:</strong>{' '}
|
||||||
|
<code style={{ fontSize: 13 }}>{user.api_key}</code>
|
||||||
|
<button onClick={copyKey} style={{ marginLeft: 8, fontSize: 12 }}>
|
||||||
|
{copied ? 'Скопировано' : 'Копировать'}
|
||||||
|
</button>
|
||||||
|
<button onClick={rotateKey} disabled={rotating} style={{ marginLeft: 8, fontSize: 12 }}>
|
||||||
|
{rotating ? 'Обновление...' : 'Сбросить'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>REST API:</strong>{' '}
|
||||||
|
<code style={{ fontSize: 13 }}>
|
||||||
|
POST /api/v1/search
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: '#666' }}>
|
||||||
|
Используйте <code>Authorization: Bearer <API_Key></code> для доступа к API.
|
||||||
|
Документация: <Link to="/api/docs">/api/docs</Link>
|
||||||
|
</p>
|
||||||
<p><strong>План:</strong> {user.plan}</p>
|
<p><strong>План:</strong> {user.plan}</p>
|
||||||
<p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p>
|
<p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue