Compare commits

...

2 commits

Author SHA1 Message Date
ed0ss
5f6ad253de docs: обновление README, мёрж feat/rest-api в master
Some checks are pending
CI / lint-backend (push) Waiting to run
CI / lint-frontend (push) Waiting to run
CI / build-backend (push) Blocked by required conditions
CI / build-frontend (push) Blocked by required conditions
- README.md полностью переписан под текущую архитектуру: K8s + FastAPI +
  React + Qdrant + OpenRouter
- Добавлена документация REST API v1 с примерами curl
- Добавлена информация об очистке ПД, дедупликации, архитектуре
- Мёрдж ветки feat/rest-api: новый роутер /api/v1/ с API-ключом
2026-06-25 11:55:55 +03:00
ed0ss
69c2bc114b feat: внешний REST API v1 с аутентификацией по API-ключу
- Добавлен роутер /api/v1/ с аутентификацией через API-ключ
- Endpoints: GET /api/v1/me, PATCH /api/v1/me/api-key,
  GET /api/v1/collections, POST /api/v1/tickets, POST /api/v1/search
- На странице настроек: копирование и сброс API-ключа
2026-06-25 11:43:51 +03:00
5 changed files with 459 additions and 133 deletions

387
README.md
View file

@ -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) │ └─────────────┘
│ │ ┌─────────────┐
│ │────▶│ FastAPI │ (backend)
└─────────────┘ └──────┬──────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Qdrant │ │ Redis │
│(пользова-│ │(векторная│ │(кэш/сес- │
│ тели, │ │ БД) │ │ сии) │
│коллекции)│ └──────────┘ └──────────┘
└──────────┘
│ │
▼ ▼
sentence-transformers ← векторизация (intfloat/multilingual-e5-small, GPU CUDA) ┌──────────────┐
│ │ OpenRouter │
▼ │(эмбеддинги + │
chroma_db/ ← векторная база данных (416 чанков) │ LLM) │
│ └──────────────┘
▼
app.py ← Streamlit-интерфейс
│
├── вопрос специалиста → embedding → поиск топ-5
│ │
│ ▼
└── контекст → LM Studio (qwen2.5-7b) → ответ
``` ```
### Компоненты
| Компонент | Технология |
|-----------|-----------|
| **Фронтенд** | 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
``` ```

View file

@ -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"])

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

View file

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

View file

@ -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 &lt;API_Key&gt;</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>