Initial commit

This commit is contained in:
ed0ss 2026-06-20 16:07:21 +03:00
commit fa620edcf8
12 changed files with 928 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
__pycache__/
*.pyc
.env
chroma_db/
venv/
.venv/
*.egg-info/
dist/
build/
.streamlit/
vector_store/
*.faiss.bin

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

File diff suppressed because one or more lines are too long

1
tickets-202512.json Normal file

File diff suppressed because one or more lines are too long

1
tickets-202601.json Normal file

File diff suppressed because one or more lines are too long

1
tickets-202602.json Normal file

File diff suppressed because one or more lines are too long

1
tickets-202603.json Normal file

File diff suppressed because one or more lines are too long

1
tickets.json Normal file

File diff suppressed because one or more lines are too long