Initial commit
This commit is contained in:
commit
fa620edcf8
12 changed files with 928 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
chroma_db/
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.streamlit/
|
||||
vector_store/
|
||||
*.faiss.bin
|
||||
374
.opencode/plans/saas-rag-plan.md
Normal file
374
.opencode/plans/saas-rag-plan.md
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
# План: RAG SaaS для техподдержки
|
||||
|
||||
## Статус
|
||||
- [ ] Этап 0 — Форк и структура проекта
|
||||
- [ ] Этап 1 — Бэкенд: регистрация, авторизация, коллекции
|
||||
- [ ] Этап 2 — Бэкенд: приём тикетов (UI + API)
|
||||
- [ ] Этап 3 — Бэкенд: поиск и генерация ответа
|
||||
- [ ] Этап 4 — Фронтенд: SPA
|
||||
- [ ] Этап 5 — Инфраструктура: K8s + CI/CD
|
||||
- [ ] Этап 6 — Мониторинг, биллинг, доки
|
||||
|
||||
---
|
||||
|
||||
## Этап 0 — Форк и структура
|
||||
|
||||
Сделать форк текущего коммита `41b84e6`. Новая структура:
|
||||
|
||||
```
|
||||
support-bot-saas/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI приложение
|
||||
│ │ ├── config.py # настройки (env)
|
||||
│ │ ├── auth/ # JWT, регистрация
|
||||
│ │ ├── models/ # Pydantic схемы
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── auth.py # /api/register, /api/login
|
||||
│ │ │ ├── collections.py # /api/collections
|
||||
│ │ │ ├── tickets.py # /api/collections/:id/tickets
|
||||
│ │ │ └── search.py # /api/search
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── embedding.py # E5-large (общий воркер)
|
||||
│ │ │ ├── vector_store.py # Qdrant client
|
||||
│ │ │ ├── chunking.py # подготовка текста
|
||||
│ │ │ └── llm.py # внешний LLM
|
||||
│ │ └── workers/
|
||||
│ │ └── process_file.py # Celery — обработка файлов
|
||||
│ ├── Dockerfile
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ └── Dockerfile
|
||||
├── helm/ # K8s чарты
|
||||
├── docker-compose.yml # для локальной разработки
|
||||
└── .github/workflows/ # CI/CD
|
||||
```
|
||||
|
||||
**Что берём из текущего кода:**
|
||||
- chunking.py — `build_chunk`, `has_real_solution`, `clean_text`
|
||||
- cross-encoder реранжировщик (на Django-side или отдельный микросервис)
|
||||
|
||||
---
|
||||
|
||||
## Этап 1 — Бэкенд: регистрация, коллекции, Qdrant
|
||||
|
||||
### 1.1 FastAPI + PostgreSQL
|
||||
|
||||
```python
|
||||
# models/user.py
|
||||
class User(Base):
|
||||
id: uuid
|
||||
email: str
|
||||
password_hash: str
|
||||
api_key: str # для API-интеграции
|
||||
created_at: datetime
|
||||
plan: str # free, pro, enterprise
|
||||
|
||||
# models/collection.py
|
||||
class Collection(Base):
|
||||
id: uuid
|
||||
user_id: uuid # FK → user
|
||||
name: str
|
||||
ticket_count: int
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### 1.2 Qdrant multi-tenancy
|
||||
|
||||
**Одна коллекция Qdrant `tickets`, tenant_id в payload:**
|
||||
|
||||
```python
|
||||
# services/vector_store.py
|
||||
async def create_tenant_index(user_id: str):
|
||||
# Qdrant: одна общая коллекция + фильтр по tenant_id
|
||||
# Индекс создаётся один раз глобально
|
||||
pass
|
||||
|
||||
async def upsert_chunks(user_id: str, chunks: list[dict]):
|
||||
client.upsert(
|
||||
collection_name="tickets",
|
||||
points=[
|
||||
PointStruct(
|
||||
id=chunk["id"],
|
||||
vector=chunk["vector"],
|
||||
payload={
|
||||
"tenant_id": user_id,
|
||||
"ticket_id": chunk["ticket_id"],
|
||||
"category": chunk["category"],
|
||||
"search_text": chunk["search_text"],
|
||||
"full_text": chunk["full_text"],
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def search(user_id: str, query_vector: list, k: int = 20):
|
||||
return client.query_points(
|
||||
collection_name="tickets",
|
||||
query=query_vector,
|
||||
query_filter=Filter(
|
||||
must=[FieldCondition(key="tenant_id", match={"value": user_id})]
|
||||
),
|
||||
limit=k,
|
||||
)
|
||||
```
|
||||
|
||||
**Почему одна коллекция, а не на пользователя:**
|
||||
- Qdrant сам рекомендует такую схему для SaaS
|
||||
- Тысячи коллекций жрут память (каждая коллекция = свой HNSW-граф в RAM)
|
||||
- Фильтр по `tenant_id` индексируется, поиск не замедляется
|
||||
|
||||
**Регистрация пользователя:**
|
||||
1. Создать запись в PostgreSQL
|
||||
2. Сгенерировать `api_key`
|
||||
3. Qdrant ничего не делать — фильтрация по `tenant_id` будет работать автоматически
|
||||
|
||||
### API endpoints этапа 1
|
||||
|
||||
```
|
||||
POST /api/register # email + пароль → JWT + api_key
|
||||
POST /api/login # email + пароль → JWT
|
||||
GET /api/me # профиль
|
||||
POST /api/collections # создать базу знаний
|
||||
GET /api/collections # список баз
|
||||
DELETE /api/collections/:id # удалить базу (не рвать Qdrant)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этап 2 — Приём тикетов
|
||||
|
||||
### 2.1 Через UI (загрузка файла)
|
||||
|
||||
- Поддерживаемые форматы: JSON, CSV, XLSX
|
||||
- После загрузки → Celery task:
|
||||
1. Парсинг в единую схему
|
||||
2. apply `has_real_solution` filter
|
||||
3. `build_chunk` → search_text + full_text
|
||||
4. E5-large → вектор (через API embedding-воркера)
|
||||
5. Upsert в Qdrant
|
||||
6. Обновить `ticket_count` в Collection
|
||||
|
||||
### 2.2 Через API
|
||||
|
||||
```
|
||||
POST /api/collections/:id/tickets
|
||||
Authorization: Bearer <api_key>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tickets": [
|
||||
{
|
||||
"ticket_id": "ext-001",
|
||||
"category": "Сбой",
|
||||
"description": "Не могу войти",
|
||||
"messages": [
|
||||
{"role": "client", "text": "Пишет ошибку"},
|
||||
{"role": "support", "text": "Проверьте логин"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
```json
|
||||
{
|
||||
"processed": 1,
|
||||
"skipped": 0,
|
||||
"collection_ticket_count": 142
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Embedding — через OpenRouter API
|
||||
|
||||
GPU в K8s не будет, embedding тоже через OpenRouter. OpenRouter поддерживает несколько embedding-моделей: `text-embedding-3-small`, `text-embedding-3-large` и др. Выбор уточнить при старте — нужно тестировать качество на русском языке.
|
||||
|
||||
```python
|
||||
# services/embedding.py
|
||||
class OpenRouterEmbeddings:
|
||||
def __init__(self, api_key: str, model: str = "text-embedding-3-small"):
|
||||
self.client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
|
||||
self.model = model
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
response = await self.client.embeddings.create(
|
||||
model=self.model,
|
||||
input=texts
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
```
|
||||
|
||||
**Альтернатива:** если качество OpenRouter embedding не устроит — вынести E5-large в отдельный микросервис с CPU (batch inference, ~5-10 текстов/сек) или использовать другой API (Jina, Voyage, Cohere).
|
||||
|
||||
---
|
||||
|
||||
## Этап 3 — Поиск и генерация
|
||||
|
||||
```
|
||||
POST /api/search
|
||||
Authorization: Bearer <api_key>
|
||||
{
|
||||
"collection_id": "uuid",
|
||||
"query": "не могу загрузить фото в приложение",
|
||||
"generate_answer": true
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
```json
|
||||
{
|
||||
"answer": "Проверьте настройки камеры...",
|
||||
"sources": [
|
||||
{
|
||||
"ticket_id": "ext-001",
|
||||
"score": 3.51,
|
||||
"category": "Сбой"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Внутренняя логика search:
|
||||
|
||||
1. E5-large: query → вектор
|
||||
2. Qdrant: search по tenant_id + collection_id → 20 кандидатов
|
||||
3. Cross-encoder (CPU): rerank query vs full_text всех 20
|
||||
4. Взять топ-5
|
||||
5. Если `generate_answer=true` → LLM API (OpenAI/GigaChat) с системным промптом
|
||||
6. Вернуть ответ + источники
|
||||
|
||||
### LLM провайдер — OpenRouter
|
||||
|
||||
На этапе разработки — бесплатные модели OpenRouter (deepseek, qwen, gemini-free). Потом платные.
|
||||
|
||||
Клиент — стандартный OpenAI SDK с `base_url="https://openrouter.ai/api/v1"`.
|
||||
|
||||
```python
|
||||
# services/llm.py
|
||||
class OpenRouterLLM:
|
||||
def __init__(self, api_key: str, model: str = "deepseek/deepseek-chat"):
|
||||
self.client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key)
|
||||
self.model = model
|
||||
|
||||
async def generate(self, system_prompt: str, context: str, query: str) -> str:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Контекст:\n{context}\n\nВопрос:\n{query}"}
|
||||
]
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
```
|
||||
|
||||
**Модель по умолчанию:** уточнить после тестов. Варианты: `deepseek/deepseek-chat` (дёшево, хорошо для RAG), `qwen/qwen-2.5-7b-instruct` (бесплатно), `google/gemini-2.0-flash-free` (бесплатно).
|
||||
|
||||
Системный промпт — тот же, что сейчас в app.py.
|
||||
|
||||
---
|
||||
|
||||
## Этап 4 — Фронтенд
|
||||
|
||||
SPA на React (или Vue, решать тебе):
|
||||
|
||||
**Страницы:**
|
||||
- `/login`, `/register`
|
||||
- `/dashboard` — список коллекций, статистика
|
||||
- `/collections/:id` — просмотр тикетов, загрузка файла, поиск
|
||||
- `/settings` — API ключ, профиль
|
||||
|
||||
**Чат-интерфейс поиска:**
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Вопрос: │
|
||||
│ [________________] │
|
||||
│ [Найти] │
|
||||
├─────────────────────┤
|
||||
│ Ответ ассистента │
|
||||
│ ─────────────────── │
|
||||
│ Текст... │
|
||||
│ │
|
||||
│ 📄 Найденные │
|
||||
│ Тикет №123 │
|
||||
│ Тикет №456 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Технологии:** React + Vite + Tailwind или просто Jinja2 + HTMX для MVP.
|
||||
|
||||
---
|
||||
|
||||
## Этап 5 — Инфраструктура
|
||||
|
||||
### docker-compose (локальная разработка)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
redis:
|
||||
qdrant:
|
||||
backend:
|
||||
build: ./backend
|
||||
env:
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||
- DATABASE_URL=postgresql://...
|
||||
frontend:
|
||||
build: ./frontend
|
||||
env: ...
|
||||
worker: # Celery — парсинг, векторизация, upsert
|
||||
build: ./backend
|
||||
command: celery -A app.workers worker
|
||||
```
|
||||
|
||||
### K8s (Helm-чарт `helm/support-bot-saas/`)
|
||||
|
||||
```
|
||||
Deployments:
|
||||
├── backend — FastAPI (2-3 реплики)
|
||||
├── worker — Celery (1-2 реплики)
|
||||
├── frontend — Nginx + SPA
|
||||
└── cronjob — очистка старых данных
|
||||
|
||||
StatefulSets:
|
||||
├── postgres
|
||||
├── redis
|
||||
└── qdrant
|
||||
|
||||
Ingress: api.domain.com, app.domain.com
|
||||
```
|
||||
|
||||
### CI/CD (GitHub Actions)
|
||||
|
||||
```
|
||||
push → lint + test → build Docker → push to registry → deploy to k8s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этап 6 — После запуска
|
||||
|
||||
1. **Мониторинг:** Prometheus + Grafana (latency поиска, ошибки LLM, кол-во тикетов)
|
||||
2. **Биллинг:** Stripe / YooKassa — free (1 коллекция, 1000 тикетов), pro (10 коллекций, 100k тикетов)
|
||||
3. **Документация:** OpenAPI (Swagger у FastAPI из коробки), README по интеграции
|
||||
4. **Rate limiting:** slowapi — для free-тарифа 10 запросов/мин, для pro — без лимита
|
||||
|
||||
---
|
||||
|
||||
## Принятые решения
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| LLM провайдер | OpenRouter (бесплатные → платные) |
|
||||
| Embedding | OpenRouter API (если качество не устроит — отдельный E5-large микросервис на CPU) |
|
||||
| Фронтенд | React SPA |
|
||||
| Очередь задач | Celery + Redis |
|
||||
| K8s GPU | Нет, всё через API |
|
||||
| Тарифы | Free / Pro / Enterprise |
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **Модель для embedding через OpenRouter** — нужно протестировать `text-embedding-3-small/large` на русском, сравнить с E5-large.
|
||||
2. **LLM модель по умолчанию** — deepseek/qwen/gemini-free, решить после тестов.
|
||||
3. **Регионы / 152-ФЗ** — если целевая аудитория в РФ, нужен GigaChat как опция и хранение в РФ.
|
||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# RAG-ассистент техподдержки
|
||||
|
||||
Система поиска решений по истории обращений в техподдержку. Основана на RAG (Retrieval-Augmented Generation) — находит похожие решённые тикеты и формирует ответ на их основе.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
tickets.json (792 тикета)
|
||||
│
|
||||
▼
|
||||
prepare_data.py ← фильтрация, очистка, нарезка чанков
|
||||
│
|
||||
▼
|
||||
sentence-transformers ← векторизация (intfloat/multilingual-e5-small, GPU CUDA)
|
||||
│
|
||||
▼
|
||||
chroma_db/ ← векторная база данных (416 чанков)
|
||||
│
|
||||
▼
|
||||
app.py ← Streamlit-интерфейс
|
||||
│
|
||||
├── вопрос специалиста → embedding → поиск топ-5
|
||||
│ │
|
||||
│ ▼
|
||||
└── контекст → LM Studio (qwen2.5-7b) → ответ
|
||||
```
|
||||
|
||||
## Как это работает
|
||||
|
||||
### 1. Подготовка данных (`prepare_data.py`)
|
||||
|
||||
Что делает скрипт:
|
||||
|
||||
- Читает все файлы `tickets*.json` в папке проекта (сейчас 6 файлов, ~3300 тикетов)
|
||||
- **Чистит текст**: удаляет цитаты `[q]...[/q]`, ссылки, лишние пробелы
|
||||
- **Фильтрует**: оставляет только тикеты с реальным решением. Отбрасываются:
|
||||
- тикеты, где последнее сообщение support — просьба уточнить / прислать скрин
|
||||
- тикеты без ответа / с закрытием "нет обратной связи"
|
||||
- слишком короткие сообщения (< 100 символов)
|
||||
- **Формирует чанки**: каждый тикет = один чанк вида:
|
||||
```
|
||||
Категория: ...
|
||||
Проблема: <описание>
|
||||
Решение: <все сообщения support>
|
||||
```
|
||||
- **Векторизует**: превращает текст в вектор (384 числа) через `intfloat/multilingual-e5-small` на GPU
|
||||
- **Сохраняет** в ChromaDB (папка `chroma_db/`)
|
||||
|
||||
Результат: 416 чанков с реальными решениями (376 тикетов отфильтровано как бесполезные).
|
||||
|
||||
### 2. Поиск и генерация (`app.py`)
|
||||
|
||||
Специалист вводит проблему клиента. Приложение:
|
||||
|
||||
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
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Установить PyTorch с CUDA (если ещё не):
|
||||
```bash
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
|
||||
```
|
||||
|
||||
### Подготовка базы знаний
|
||||
|
||||
```bash
|
||||
python prepare_data.py
|
||||
```
|
||||
Скрипт создаст папку `chroma_db/` с векторной базой.
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
streamlit run app.py --server.headless true
|
||||
```
|
||||
Открыть в браузере: http://localhost:8501
|
||||
|
||||
### Запуск чат-модели
|
||||
|
||||
Для генерации ответов нужна чат-модель в LM Studio:
|
||||
1. Открыть LM Studio
|
||||
2. Вкладка Developer → загрузить модель (например, `Qwen2.5-7B-Instruct`)
|
||||
3. Нажать Start Server
|
||||
4. API будет доступен на `http://localhost:1234/v1`
|
||||
|
||||
Без чат-модели приложение работает в режиме поиска — показывает похожие тикеты без генерации ответа.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
support-bot/
|
||||
├── tickets*.json # исходные данные (несколько файлов)
|
||||
├── prepare_data.py # подготовка и векторизация
|
||||
├── app.py # Streamlit-интерфейс + RAG
|
||||
├── requirements.txt # зависимости
|
||||
├── chroma_db/ # векторная БД (создаётся prepare_data.py)
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## Системные промпты
|
||||
|
||||
Главный системный промпт — в файле **`app.py:15`**, переменная `SYSTEM_PROMPT`:
|
||||
|
||||
```python
|
||||
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||||
|
||||
Правила:
|
||||
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
||||
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
||||
- НЕ придумывай ответы, НЕ используй общие знания
|
||||
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
||||
- Укажи в конце: «Основано на тикете №...»"""
|
||||
```
|
||||
|
||||
Именно этот промпт управляет тем, **как LLM отвечает** на вопрос специалиста. Если нужно изменить стиль ответа, тон, правила или требования к формату — редактировать здесь.
|
||||
|
||||
Промпт отправляется в LM Studio как `system`-сообщение при каждом запросе генерации ответа (строка `app.py:120-127`).
|
||||
|
||||
### Фильтрация тикетов без решения
|
||||
|
||||
Паттерны для отбраковки пустых тикетов — в **`prepare_data.py:19-22`**, переменная `NON_SOLUTION`:
|
||||
|
||||
```python
|
||||
NON_SOLUTION = re.compile(
|
||||
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
|
||||
```
|
||||
185
app.py
Normal file
185
app.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import os
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
import streamlit as st
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from openai import OpenAI
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||
|
||||
LM_STUDIO_URL = os.getenv("LM_STUDIO_URL", "http://localhost:1234")
|
||||
EMBED_MODEL = "intfloat/multilingual-e5-large"
|
||||
VECTOR_DIR = Path(__file__).parent / "vector_store"
|
||||
VECTOR_INDEX = VECTOR_DIR / "index.faiss.bin"
|
||||
VECTOR_META = VECTOR_DIR / "metadata.pkl"
|
||||
|
||||
SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
|
||||
|
||||
Правила:
|
||||
- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
|
||||
- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
|
||||
- НЕ придумывай ответы, НЕ используй общие знания
|
||||
- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
|
||||
- Укажи в конце: «Основано на тикете №...»"""
|
||||
|
||||
FETCH_K = 20 # сколько достаём из FAISS для реранжа
|
||||
FINAL_K = 5 # сколько оставляем после реранжа
|
||||
|
||||
|
||||
class LocalEmbeddings(Embeddings):
|
||||
def __init__(self, model_name: str):
|
||||
self.model = SentenceTransformer(model_name, device="cuda")
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
emb = self.model.encode(texts, normalize_embeddings=True)
|
||||
return emb.tolist()
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
emb = self.model.encode([text], normalize_embeddings=True)
|
||||
return emb[0].tolist()
|
||||
|
||||
|
||||
@st.cache_resource
|
||||
def load_embeddings():
|
||||
return LocalEmbeddings(EMBED_MODEL)
|
||||
|
||||
|
||||
@st.cache_resource
|
||||
def load_reranker():
|
||||
return CrossEncoder(
|
||||
"cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
|
||||
max_length=512,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
|
||||
def load_faiss_index(path: Path):
|
||||
return faiss.deserialize_index(np.frombuffer(path.read_bytes(), dtype=np.uint8))
|
||||
|
||||
|
||||
def get_chat_client() -> OpenAI | None:
|
||||
try:
|
||||
OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed").models.list()
|
||||
return OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def search_similar(query: str, k: int = FETCH_K):
|
||||
embeddings = load_embeddings()
|
||||
qvec = np.array([embeddings.embed_query(query)], dtype=np.float32)
|
||||
index = load_faiss_index(VECTOR_INDEX)
|
||||
scores, indices = index.search(qvec, k)
|
||||
with open(VECTOR_META, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
metadatas = data["metadatas"]
|
||||
results = []
|
||||
for score, idx in zip(scores[0], indices[0]):
|
||||
if idx < 0 or idx >= len(metadatas):
|
||||
continue
|
||||
results.append((metadatas[idx], float(score)))
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def rerank(query: str, results: list) -> list:
|
||||
reranker = load_reranker()
|
||||
pairs = [(query, r[0]["full_text"]) for r in results]
|
||||
scores = reranker.predict(pairs)
|
||||
scored = [(results[i][0], float(scores[i])) for i in range(len(results))]
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored[:FINAL_K]
|
||||
|
||||
|
||||
def format_context(results) -> str:
|
||||
parts = []
|
||||
for meta, score in results:
|
||||
parts.append(
|
||||
f"Тикет №{meta['ticket_id']} ({meta['category']}, {meta['client']}):\n{meta['full_text']}"
|
||||
)
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def generate_answer(chat: OpenAI, query: str, context: str) -> str:
|
||||
response = chat.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}"},
|
||||
],
|
||||
temperature=0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
def main():
|
||||
st.set_page_config(page_title="RAG техподдержки", layout="wide")
|
||||
st.title("RAG-ассистент техподдержки")
|
||||
st.markdown(
|
||||
"Находит похожие обращения из истории и формирует ответ на их основе."
|
||||
)
|
||||
|
||||
if not VECTOR_INDEX.exists():
|
||||
st.error("Векторный индекс не найден. Запусти: python prepare_data.py")
|
||||
st.stop()
|
||||
|
||||
chat = get_chat_client()
|
||||
if chat is None:
|
||||
st.warning(
|
||||
"⚠️ Чат-модель не обнаружена. Загрузи модель в LM Studio."
|
||||
)
|
||||
|
||||
with st.form("query_form"):
|
||||
query = st.text_area(
|
||||
"Опишите проблему клиента:",
|
||||
placeholder="Например: клиент не может войти в ТГ-бот, пишет неверный логин",
|
||||
height=100,
|
||||
)
|
||||
submitted = st.form_submit_button("Получить ответ", type="primary")
|
||||
|
||||
if submitted and query:
|
||||
with st.spinner("Ищу похожие обращения..."):
|
||||
results = search_similar(query)
|
||||
|
||||
if not results:
|
||||
st.warning("Не найдено похожих обращений")
|
||||
return
|
||||
|
||||
with st.spinner("Оцениваю релевантность..."):
|
||||
results = rerank(query, results)
|
||||
|
||||
if not results:
|
||||
st.warning("После проверки релевантности не осталось подходящих тикетов")
|
||||
return
|
||||
|
||||
context = format_context(results)
|
||||
|
||||
if chat:
|
||||
with st.spinner("Формирую ответ на основе найденных тикетов..."):
|
||||
try:
|
||||
answer = generate_answer(chat, query, context)
|
||||
st.subheader("💬 Ответ ассистента")
|
||||
st.markdown(answer)
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка LLM: {e}")
|
||||
else:
|
||||
st.info("Результаты поиска без генерации ответа:")
|
||||
|
||||
with st.expander("📄 Найденные похожие обращения", expanded=True):
|
||||
for meta, score in results:
|
||||
with st.container(border=True):
|
||||
st.markdown(f"### Тикет №{meta['ticket_id']} (релевантность: {score:.3f})")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.markdown(f"**Категория:** {meta['category']}")
|
||||
with col2:
|
||||
st.markdown(f"**Клиент:** {meta['client']}")
|
||||
st.text(meta["full_text"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
171
prepare_data.py
Normal file
171
prepare_data.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import json
|
||||
import pickle
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
EMBED_MODEL = "intfloat/multilingual-e5-large"
|
||||
DATA_DIR = Path(__file__).parent
|
||||
TICKETS_PATTERN = "tickets*.json"
|
||||
VECTOR_DIR = DATA_DIR / "vector_store"
|
||||
VECTOR_INDEX = VECTOR_DIR / "index.faiss.bin"
|
||||
VECTOR_META = VECTOR_DIR / "metadata.pkl"
|
||||
|
||||
NON_SOLUTION = re.compile(
|
||||
r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
|
||||
r"|нет обратной связи|запрос завершу|открыть его снова"
|
||||
r"|откройте новый|обратиться в службу поддержки"
|
||||
r"|свяжитесь с технической"
|
||||
r"|напишите нам|позвоните нам)", re.I
|
||||
)
|
||||
|
||||
|
||||
class LocalEmbeddings(Embeddings):
|
||||
def __init__(self, model_name: str):
|
||||
self.model = SentenceTransformer(model_name, device="cuda")
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
emb = self.model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
|
||||
return emb.tolist()
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
emb = self.model.encode([text], normalize_embeddings=True)
|
||||
return emb[0].tolist()
|
||||
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
text = re.sub(r'\[/?q\].*?\[/q\]', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'https?://\S+', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def has_real_solution(messages: list[dict]) -> str | None:
|
||||
support_texts = []
|
||||
for m in messages:
|
||||
if m["role"] == "support":
|
||||
cleaned = clean_text(m["text"])
|
||||
if cleaned:
|
||||
support_texts.append(cleaned)
|
||||
if not support_texts:
|
||||
return None
|
||||
combined = "\n".join(support_texts)
|
||||
if len(combined) < 100:
|
||||
return None
|
||||
last = support_texts[-1]
|
||||
if NON_SOLUTION.search(last):
|
||||
return None
|
||||
if last.strip().endswith("?") and len(last) < 150:
|
||||
return None
|
||||
return combined
|
||||
|
||||
|
||||
def get_client_dialogue(messages: list[dict]) -> str:
|
||||
texts = []
|
||||
for m in messages:
|
||||
if m["role"] == "client":
|
||||
cleaned = clean_text(m["text"])
|
||||
if cleaned:
|
||||
texts.append(cleaned)
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def build_chunk(ticket: dict) -> dict | None:
|
||||
msgs = ticket.get("messages") or []
|
||||
if not msgs:
|
||||
return None
|
||||
description = clean_text(ticket.get("description", ""))
|
||||
solution = has_real_solution(msgs)
|
||||
if not solution:
|
||||
return None
|
||||
|
||||
client_msgs = get_client_dialogue(msgs)
|
||||
|
||||
# Текст для поиска — только описание + переписка клиента
|
||||
search_parts = []
|
||||
if description:
|
||||
search_parts.append(description)
|
||||
if client_msgs:
|
||||
search_parts.append(client_msgs)
|
||||
search_text = "\n".join(search_parts).strip()
|
||||
if not search_text:
|
||||
return None
|
||||
|
||||
# Полный текст для показа и LLM
|
||||
full_text = (
|
||||
f"Категория: {ticket['category']}\n"
|
||||
f"Проблема: {description}\n"
|
||||
f"Решение: {solution}"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"ticket_id": ticket["ticket_id"],
|
||||
"client": ticket["client"],
|
||||
"category": ticket["category"],
|
||||
"full_text": full_text,
|
||||
}
|
||||
return {"search_text": search_text, "metadata": metadata}
|
||||
|
||||
|
||||
def load_all_tickets() -> list:
|
||||
all_tickets = []
|
||||
for f in sorted(DATA_DIR.glob(TICKETS_PATTERN)):
|
||||
tickets = json.loads(f.read_text(encoding="utf-8"))
|
||||
print(f" {f.name}: {len(tickets)} тикетов")
|
||||
all_tickets.extend(tickets)
|
||||
return all_tickets
|
||||
|
||||
|
||||
def save_faiss_index(index, path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(faiss.serialize_index(index))
|
||||
|
||||
|
||||
def load_faiss_index(path: Path):
|
||||
return faiss.deserialize_index(np.frombuffer(path.read_bytes(), dtype=np.uint8))
|
||||
|
||||
|
||||
def main():
|
||||
print("Загрузка файлов tickets*.json ...")
|
||||
tickets = load_all_tickets()
|
||||
print(f"Всего тикетов: {len(tickets)}")
|
||||
|
||||
chunks = []
|
||||
skipped = 0
|
||||
for t in tickets:
|
||||
chunk = build_chunk(t)
|
||||
if chunk is not None:
|
||||
chunks.append(chunk)
|
||||
else:
|
||||
skipped += 1
|
||||
print(f"Сформировано чанков: {len(chunks)}")
|
||||
print(f"Пропущено (нет решения): {skipped}")
|
||||
|
||||
print(f"Загрузка модели эмбеддингов: {EMBED_MODEL} ...")
|
||||
embeddings = LocalEmbeddings(EMBED_MODEL)
|
||||
|
||||
search_texts = [c["search_text"] for c in chunks]
|
||||
metadatas = [c["metadata"] for c in chunks]
|
||||
|
||||
print("Создание эмбеддингов и FAISS индекса...")
|
||||
vectors = embeddings.embed_documents(search_texts)
|
||||
dim = len(vectors[0])
|
||||
index = faiss.IndexFlatIP(dim)
|
||||
index.add(np.array(vectors, dtype=np.float32))
|
||||
|
||||
save_faiss_index(index, VECTOR_INDEX)
|
||||
with open(VECTOR_META, "wb") as f:
|
||||
pickle.dump({"metadatas": metadatas}, f)
|
||||
|
||||
print(f"FAISS индекс сохранён: {VECTOR_DIR}")
|
||||
print(f"Размерность: {dim}")
|
||||
print(f"Векторов в индексе: {index.ntotal}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
langchain==0.2.15
|
||||
langchain-community==0.2.12
|
||||
langchain-chroma==0.1.4
|
||||
langchain-core==0.2.43
|
||||
chromadb==0.5.9
|
||||
sentence-transformers==3.2.0
|
||||
openai==1.54.3
|
||||
streamlit==1.58.0
|
||||
1
tickets-202511.json
Normal file
1
tickets-202511.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202512.json
Normal file
1
tickets-202512.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202601.json
Normal file
1
tickets-202601.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202602.json
Normal file
1
tickets-202602.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets-202603.json
Normal file
1
tickets-202603.json
Normal file
File diff suppressed because one or more lines are too long
1
tickets.json
Normal file
1
tickets.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue