- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
449 lines
17 KiB
Markdown
449 lines
17 KiB
Markdown
# Стратегия кеширования визардов
|
||
|
||
**Дата:** 2025-01-XX
|
||
**Вопрос:** Как кешировать визарды, если они всегда индивидуальные?
|
||
|
||
---
|
||
|
||
## 🤔 Проблема
|
||
|
||
**Кажется, что визарды всегда индивидуальные:**
|
||
- Каждое описание проблемы уникально
|
||
- Разные детали, разные обстоятельства
|
||
- Как найти "похожий" визард?
|
||
|
||
**НО! На самом деле:**
|
||
- **Структура визарда** (вопросы, документы) часто **одинаковая** для похожих типов дел
|
||
- **Содержание** (ответы пользователя) - индивидуальное, но это не нужно кешировать
|
||
- **Типы дел** повторяются: "дефект товара", "некачественная услуга", "нарушение сроков"
|
||
|
||
---
|
||
|
||
## 💡 Решение: Многоуровневое кеширование
|
||
|
||
### Уровень 1: Кеш по типу дела (самый быстрый)
|
||
|
||
**Идея:** Визарды для одного типа дела имеют одинаковую структуру
|
||
|
||
**Как работает:**
|
||
```python
|
||
# После генерации визарда
|
||
case_type = classification["case_type"] # "product_defect", "service_issue", etc.
|
||
|
||
# Кешируем структуру визарда (без ответов!)
|
||
cache_key = f"wizard:template:{case_type}"
|
||
redis.set(cache_key, wizard_structure, ttl=86400) # 24 часа
|
||
|
||
# При следующем запросе
|
||
if cached := redis.get(cache_key):
|
||
# Используем кеш (0.001 сек)
|
||
return cached
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Мгновенно (0.001 сек)
|
||
- ✅ Просто реализовать
|
||
- ✅ Работает для 80% случаев
|
||
|
||
**Минусы:**
|
||
- ❌ Не учитывает нюансы описания
|
||
- ❌ Может быть слишком общим
|
||
|
||
**Когда использовать:**
|
||
- Стандартные типы дел (дефект товара, некачественная услуга)
|
||
- После апрува визарда администратором
|
||
|
||
---
|
||
|
||
### Уровень 2: Кеш по похожести описания (семантический поиск)
|
||
|
||
**Идея:** Находим похожие описания через векторизацию
|
||
|
||
**Как работает:**
|
||
```python
|
||
# 1. Векторизуем описание проблемы
|
||
description = "Купил смартфон в DNS, через неделю сломался экран"
|
||
embedding = get_text_embedding(description) # [0.1, 0.2, ...]
|
||
|
||
# 2. Ищем похожие описания в Elasticsearch/векторной БД
|
||
similar_cases = vector_search(embedding, limit=5, min_similarity=0.85)
|
||
|
||
# 3. Если нашли похожий (similarity > 0.85)
|
||
if similar_cases:
|
||
similar_wizard = similar_cases[0]["wizard_plan"]
|
||
# Используем его структуру (можем адаптировать под текущий случай)
|
||
return adapt_wizard(similar_wizard, current_description)
|
||
```
|
||
|
||
**Структура в БД:**
|
||
```json
|
||
{
|
||
"description": "Купил смартфон в DNS, через неделю сломался экран",
|
||
"description_embedding": [0.1, 0.2, ...],
|
||
"wizard_plan": {
|
||
"questions": [...],
|
||
"documents": [...]
|
||
},
|
||
"case_type": "product_defect",
|
||
"approved": true,
|
||
"created_at": "2025-01-15T10:00:00Z"
|
||
}
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Учитывает нюансы описания
|
||
- ✅ Находит действительно похожие случаи
|
||
- ✅ Можно использовать уже апрувленные визарды
|
||
|
||
**Минусы:**
|
||
- ❌ Требует векторную БД (Elasticsearch, Pinecone, Qdrant)
|
||
- ❌ Нужна векторизация каждого описания (0.5-1 сек)
|
||
- ❌ Поиск занимает время (0.1-0.5 сек)
|
||
|
||
**Когда использовать:**
|
||
- Сложные/уникальные случаи
|
||
- После апрува визарда администратором
|
||
- Для обучения системы на удачных примерах
|
||
|
||
---
|
||
|
||
### Уровень 3: Кеш по хешу описания (точное совпадение)
|
||
|
||
**Идея:** Если описание точно такое же (или очень похожее) - используем кеш
|
||
|
||
**Как работает:**
|
||
```python
|
||
# 1. Вычисляем хеш описания (первые 200-300 символов)
|
||
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||
|
||
# 2. Проверяем кеш
|
||
cache_key = f"wizard:hash:{description_hash}"
|
||
if cached := redis.get(cache_key):
|
||
return cached # Мгновенно!
|
||
|
||
# 3. Генерируем визард
|
||
wizard = generate_wizard(description)
|
||
|
||
# 4. Сохраняем в кеш
|
||
redis.set(cache_key, wizard, ttl=3600) # 1 час
|
||
```
|
||
|
||
**Плюсы:**
|
||
- ✅ Мгновенно (0.001 сек)
|
||
- ✅ Просто реализовать
|
||
- ✅ Работает для повторных запросов
|
||
|
||
**Минусы:**
|
||
- ❌ Только для точных совпадений
|
||
- ❌ Не учитывает синонимы/перефразировки
|
||
|
||
**Когда использовать:**
|
||
- Тестирование (повторные запросы)
|
||
- Защита от дубликатов
|
||
|
||
---
|
||
|
||
## 🎯 Комбинированная стратегия (рекомендуется)
|
||
|
||
### Алгоритм:
|
||
|
||
```python
|
||
async def get_wizard_cached(description: str) -> dict:
|
||
"""
|
||
Многоуровневое кеширование визардов
|
||
"""
|
||
|
||
# УРОВЕНЬ 1: Точное совпадение (хеш)
|
||
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||
cache_key_hash = f"wizard:hash:{description_hash}"
|
||
if cached := await redis.get(cache_key_hash):
|
||
logger.info("✅ Cache hit: hash")
|
||
return json.loads(cached)
|
||
|
||
# УРОВЕНЬ 2: Классификация + шаблон
|
||
classification = await classify_case(description) # ИИ: 5-10 сек
|
||
case_type = classification["case_type"]
|
||
|
||
cache_key_template = f"wizard:template:{case_type}"
|
||
if cached := await redis.get(cache_key_template):
|
||
logger.info("✅ Cache hit: template")
|
||
wizard = json.loads(cached)
|
||
# Адаптируем под текущий случай (автозаполнение)
|
||
wizard = adapt_wizard(wizard, classification["extracted_data"])
|
||
return wizard
|
||
|
||
# УРОВЕНЬ 3: Семантический поиск (похожие случаи)
|
||
embedding = await get_text_embedding(description) # 0.5-1 сек
|
||
similar_cases = await vector_search(embedding, limit=3, min_similarity=0.85)
|
||
|
||
if similar_cases and similar_cases[0]["similarity"] > 0.90:
|
||
logger.info("✅ Cache hit: similar case")
|
||
wizard = similar_cases[0]["wizard_plan"]
|
||
wizard = adapt_wizard(wizard, classification["extracted_data"])
|
||
return wizard
|
||
|
||
# УРОВЕНЬ 4: Генерация нового визарда
|
||
logger.info("🔄 Generating new wizard")
|
||
wizard = await generate_wizard(description) # 30-40 сек
|
||
|
||
# Сохраняем в кеши всех уровней
|
||
await save_to_cache(wizard, description, classification, embedding)
|
||
|
||
return wizard
|
||
|
||
|
||
async def save_to_cache(wizard, description, classification, embedding):
|
||
"""Сохраняем визард во все уровни кеша"""
|
||
|
||
# 1. Хеш (точное совпадение)
|
||
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||
await redis.set(
|
||
f"wizard:hash:{description_hash}",
|
||
json.dumps(wizard),
|
||
ttl=3600 # 1 час
|
||
)
|
||
|
||
# 2. Шаблон (по типу дела) - только если визард апрувлен
|
||
# (это делается вручную администратором)
|
||
|
||
# 3. Векторная БД (для семантического поиска)
|
||
await vector_db.insert({
|
||
"description": description,
|
||
"description_embedding": embedding,
|
||
"wizard_plan": wizard,
|
||
"case_type": classification["case_type"],
|
||
"approved": False, # Станет True после апрува
|
||
"created_at": datetime.now().isoformat()
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 Когда что использовать
|
||
|
||
### Сценарий 1: Первый запрос (нет кеша)
|
||
```
|
||
Описание → Классификация (5-10 сек) → Генерация (30-40 сек) → Сохранение в кеш
|
||
```
|
||
**Время:** 35-50 секунд
|
||
|
||
### Сценарий 2: Повторный запрос (точное совпадение)
|
||
```
|
||
Описание → Хеш → Redis → Визард
|
||
```
|
||
**Время:** 0.001 секунды ⚡
|
||
|
||
### Сценарий 3: Похожий тип дела (шаблон)
|
||
```
|
||
Описание → Классификация (5-10 сек) → Redis (шаблон) → Адаптация → Визард
|
||
```
|
||
**Время:** 5-10 секунд ⚡⚡
|
||
|
||
### Сценарий 4: Похожее описание (семантический поиск)
|
||
```
|
||
Описание → Векторизация (0.5-1 сек) → Поиск (0.1-0.5 сек) → Адаптация → Визард
|
||
```
|
||
**Время:** 0.6-1.5 секунды ⚡⚡⚡
|
||
|
||
---
|
||
|
||
## ✅ Апрув визарда администратором
|
||
|
||
### Что происходит после апрува:
|
||
|
||
```python
|
||
async def approve_wizard(wizard_id: str):
|
||
"""
|
||
Администратор апрувит визард
|
||
"""
|
||
|
||
# 1. Получаем визард из БД
|
||
wizard = await db.get_wizard(wizard_id)
|
||
|
||
# 2. Сохраняем как шаблон для этого типа дела
|
||
case_type = wizard["case_type"]
|
||
await redis.set(
|
||
f"wizard:template:{case_type}",
|
||
json.dumps(wizard["wizard_plan"]),
|
||
ttl=None # Без срока (пока не обновим)
|
||
)
|
||
|
||
# 3. Помечаем в векторной БД как апрувленный
|
||
await vector_db.update(wizard_id, {"approved": True})
|
||
|
||
# 4. Теперь этот визард будет использоваться для всех похожих случаев
|
||
```
|
||
|
||
**Результат:**
|
||
- ✅ Все новые случаи этого типа будут использовать этот шаблон
|
||
- ✅ Время генерации: 5-10 сек (только классификация) вместо 30-40 сек
|
||
- ✅ Качество: гарантированно хороший визард (проверен администратором)
|
||
|
||
---
|
||
|
||
## 🗄️ Структура хранения
|
||
|
||
### Redis (быстрый кеш):
|
||
```
|
||
wizard:hash:{md5_hash} → Визард (TTL: 1 час)
|
||
wizard:template:{case_type} → Шаблон визарда (без TTL, обновляется вручную)
|
||
```
|
||
|
||
### Векторная БД (Elasticsearch/Pinecone/Qdrant):
|
||
```json
|
||
{
|
||
"id": "wizard_123",
|
||
"description": "Купил смартфон...",
|
||
"description_embedding": [0.1, 0.2, ...],
|
||
"wizard_plan": {
|
||
"questions": [...],
|
||
"documents": [...]
|
||
},
|
||
"case_type": "product_defect",
|
||
"approved": true,
|
||
"created_at": "2025-01-15T10:00:00Z",
|
||
"approved_at": "2025-01-15T11:00:00Z",
|
||
"approved_by": "admin@example.com"
|
||
}
|
||
```
|
||
|
||
### PostgreSQL (постоянное хранение):
|
||
```sql
|
||
CREATE TABLE wizard_cache (
|
||
id UUID PRIMARY KEY,
|
||
description TEXT,
|
||
description_hash VARCHAR(64),
|
||
case_type VARCHAR(50),
|
||
wizard_plan JSONB,
|
||
embedding VECTOR(1024), -- pgvector
|
||
approved BOOLEAN DEFAULT FALSE,
|
||
approved_at TIMESTAMP,
|
||
approved_by VARCHAR(255),
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
usage_count INTEGER DEFAULT 0
|
||
);
|
||
|
||
CREATE INDEX idx_wizard_hash ON wizard_cache(description_hash);
|
||
CREATE INDEX idx_wizard_case_type ON wizard_cache(case_type);
|
||
CREATE INDEX idx_wizard_approved ON wizard_cache(approved) WHERE approved = TRUE;
|
||
CREATE INDEX idx_wizard_embedding ON wizard_cache USING ivfflat (embedding vector_cosine_ops);
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Реализация
|
||
|
||
### Шаг 1: Добавить векторизацию описания
|
||
|
||
```python
|
||
# ticket_form/backend/app/services/embedding_service.py
|
||
from openai import OpenAI
|
||
|
||
class EmbeddingService:
|
||
async def get_embedding(self, text: str) -> list[float]:
|
||
"""Векторизация текста через OpenAI"""
|
||
client = OpenAI(api_key=settings.openai_api_key)
|
||
response = client.embeddings.create(
|
||
model="text-embedding-3-small", # Быстрая и дешёвая модель
|
||
input=text[:8000] # Ограничение длины
|
||
)
|
||
return response.data[0].embedding
|
||
```
|
||
|
||
### Шаг 2: Добавить векторный поиск
|
||
|
||
```python
|
||
# ticket_form/backend/app/services/wizard_cache_service.py
|
||
class WizardCacheService:
|
||
async def find_similar_wizards(
|
||
self,
|
||
embedding: list[float],
|
||
limit: int = 5,
|
||
min_similarity: float = 0.85
|
||
) -> list[dict]:
|
||
"""Поиск похожих визардов через векторный поиск"""
|
||
|
||
# Используем Elasticsearch (уже есть в проекте!)
|
||
query = {
|
||
"size": limit,
|
||
"query": {
|
||
"script_score": {
|
||
"query": {"match_all": {}},
|
||
"script": {
|
||
"source": "cosineSimilarity(params.query_vector, 'description_embedding') + 1.0",
|
||
"params": {"query_vector": embedding}
|
||
},
|
||
"min_score": min_similarity + 1.0
|
||
}
|
||
}
|
||
}
|
||
|
||
results = await elasticsearch.search(
|
||
index="wizard_cache",
|
||
body=query
|
||
)
|
||
|
||
return [
|
||
{
|
||
"wizard_plan": hit["_source"]["wizard_plan"],
|
||
"similarity": hit["_score"] - 1.0, # Нормализуем
|
||
"case_type": hit["_source"]["case_type"]
|
||
}
|
||
for hit in results["hits"]["hits"]
|
||
]
|
||
```
|
||
|
||
### Шаг 3: Интегрировать в генерацию визарда
|
||
|
||
```python
|
||
# ticket_form/backend/app/api/claims.py
|
||
@router.post("/wizard/generate")
|
||
async def generate_wizard(request: Request):
|
||
description = (await request.json())["description"]
|
||
|
||
# Многоуровневое кеширование
|
||
wizard = await wizard_cache_service.get_wizard_cached(description)
|
||
|
||
return {"wizard_plan": wizard}
|
||
```
|
||
|
||
---
|
||
|
||
## 📈 Ожидаемые результаты
|
||
|
||
### До кеширования:
|
||
- **Время:** 30-40 секунд для каждого запроса
|
||
- **Нагрузка:** Высокая (каждый раз обращение к ИИ)
|
||
|
||
### После кеширования:
|
||
- **Первый запрос:** 30-40 секунд (генерация)
|
||
- **Повторный запрос:** 0.001 секунды (хеш) ⚡
|
||
- **Похожий тип дела:** 5-10 секунд (шаблон) ⚡⚡
|
||
- **Похожее описание:** 0.6-1.5 секунды (семантический поиск) ⚡⚡⚡
|
||
|
||
### Экономия:
|
||
- **80% запросов** будут из кеша (0.001-10 сек вместо 30-40 сек)
|
||
- **Снижение нагрузки** на ИИ в 5-10 раз
|
||
- **Улучшение UX:** Пользователи получают визарды мгновенно
|
||
|
||
---
|
||
|
||
## ✅ Вывод
|
||
|
||
**Визарды не всегда индивидуальные!**
|
||
|
||
1. **Структура визарда** (вопросы, документы) повторяется для похожих типов дел
|
||
2. **Содержание** (ответы) - индивидуальное, но его не нужно кешировать
|
||
3. **Многоуровневое кеширование** позволяет использовать готовые визарды для похожих случаев
|
||
|
||
**Стратегия:**
|
||
- Кеш по хешу (точное совпадение) → 0.001 сек
|
||
- Кеш по типу дела (шаблон) → 5-10 сек
|
||
- Семантический поиск (похожие описания) → 0.6-1.5 сек
|
||
- Генерация нового → 30-40 сек (только если нет кеша)
|
||
|
||
**После апрува администратором:**
|
||
- Визард становится шаблоном для этого типа дела
|
||
- Все новые случаи используют этот шаблон (5-10 сек вместо 30-40 сек)
|
||
|