From 4c8fda5f557b932b59aedd85307829b268acd695 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Wed, 19 Nov 2025 18:46:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные --- PROJECT_TIMELINE.md | 1 + QUICK_START_DOCUMENTS.md | 1 + README.md | 18 +- SESSION_LOG_2025-11-01.md | 2 + SUMMARY_DOCUMENTS_API.md | 112 +++ TEST_ATTACH_DOCUMENT.md | 1 + TEST_QUICK.sh | 1 + backend/app/api/claims.py | 376 +++++++++- backend/app/api/events.py | 2 +- backend/app/api/n8n_proxy.py | 29 +- backend/app/main.py | 11 +- docs/CLAIMSAVE_FINAL_SQL.md | 210 ++++++ docs/CODE1_FIX.md | 103 +++ docs/CODE1_FIXED_CODE.js | 212 ++++++ docs/DATABASE_SCHEMA.md | 183 +++++ docs/FIXED_SQL_QUERY.md | 285 ++++++++ docs/N8N_CODE_NODE_RESPONSE.js | 38 + docs/N8N_CODE_NODE_RESPONSE_SAFE.js | 47 ++ docs/N8N_RESPONSE_FORMAT.md | 94 +++ docs/N8N_RESPONSE_WITH_UNIFIED_ID.md | 144 ++++ docs/N8N_USER_CREATION_INSTRUCTIONS.md | 133 ++++ docs/PERSONAL_CABINET_ARCHITECTURE.md | 431 ++++++++++++ docs/PROMPT_UPDATE_GUIDE.md | 73 ++ docs/REDIS_CLAIM_STORAGE_ANALYSIS.md | 191 ++++++ docs/REDIS_VS_POSTGRESQL_SPEED.md | 198 ++++++ docs/SESSION_LOG_2025-11-19.md | 72 ++ docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql | 114 +++ docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md | 131 ++++ docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql | 72 ++ docs/WIZARD_API_ALTERNATIVES.md | 261 +++++++ docs/WIZARD_CACHING_STRATEGY.md | 448 ++++++++++++ docs/WIZARD_OPTIMIZATION.md | 55 ++ docs/WIZARD_OPTIMIZATION_ANALYSIS.md | 264 +++++++ docs/WIZARD_SPEEDUP_GUIDE.md | 58 ++ docs/WORKFLOW_ANALYSIS.md | 211 ++++++ docs/WORKFLOW_OCR_ANALYSIS.md | 218 ++++++ docs/optimized_ai_agent_node.json | 61 ++ docs/optimized_wizard_prompt.txt | 60 ++ docs/wizard_prompt_n8n.txt | 113 +++ docs/wizard_prompt_simple.txt | 406 +++++++++++ frontend/src/App.css | 17 +- frontend/src/assets/ai-working.svg | 1 + frontend/src/components/DebugPanel.tsx | 43 +- frontend/src/components/form/Step1Phone.tsx | 35 +- frontend/src/components/form/Step1Policy.tsx | 12 +- frontend/src/components/form/Step2Details.tsx | 26 +- .../src/components/form/Step2EventType.tsx | 2 +- frontend/src/components/form/Step3Payment.tsx | 22 +- .../src/components/form/StepDescription.tsx | 6 +- .../components/form/StepDocumentUpload.tsx | 8 +- .../components/form/StepDraftSelection.tsx | 255 +++++++ .../src/components/form/StepWizardPlan.tsx | 648 ++++++++++++++---- frontend/src/index.css | 2 +- frontend/src/mocks/wizardPlanSample.ts | 1 + frontend/src/pages/ClaimForm.css | 16 +- frontend/src/pages/ClaimForm.tsx | 343 +++++++-- frontend/src/vite-env.d.ts | 1 + 57 files changed, 6574 insertions(+), 304 deletions(-) create mode 100644 SUMMARY_DOCUMENTS_API.md create mode 100644 docs/CLAIMSAVE_FINAL_SQL.md create mode 100644 docs/CODE1_FIX.md create mode 100644 docs/CODE1_FIXED_CODE.js create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/FIXED_SQL_QUERY.md create mode 100644 docs/N8N_CODE_NODE_RESPONSE.js create mode 100644 docs/N8N_CODE_NODE_RESPONSE_SAFE.js create mode 100644 docs/N8N_RESPONSE_FORMAT.md create mode 100644 docs/N8N_RESPONSE_WITH_UNIFIED_ID.md create mode 100644 docs/N8N_USER_CREATION_INSTRUCTIONS.md create mode 100644 docs/PERSONAL_CABINET_ARCHITECTURE.md create mode 100644 docs/PROMPT_UPDATE_GUIDE.md create mode 100644 docs/REDIS_CLAIM_STORAGE_ANALYSIS.md create mode 100644 docs/REDIS_VS_POSTGRESQL_SPEED.md create mode 100644 docs/SESSION_LOG_2025-11-19.md create mode 100644 docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql create mode 100644 docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md create mode 100644 docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql create mode 100644 docs/WIZARD_API_ALTERNATIVES.md create mode 100644 docs/WIZARD_CACHING_STRATEGY.md create mode 100644 docs/WIZARD_OPTIMIZATION.md create mode 100644 docs/WIZARD_OPTIMIZATION_ANALYSIS.md create mode 100644 docs/WIZARD_SPEEDUP_GUIDE.md create mode 100644 docs/WORKFLOW_ANALYSIS.md create mode 100644 docs/WORKFLOW_OCR_ANALYSIS.md create mode 100644 docs/optimized_ai_agent_node.json create mode 100644 docs/optimized_wizard_prompt.txt create mode 100644 docs/wizard_prompt_n8n.txt create mode 100644 docs/wizard_prompt_simple.txt create mode 100644 frontend/src/components/form/StepDraftSelection.tsx diff --git a/PROJECT_TIMELINE.md b/PROJECT_TIMELINE.md index fd67cc2..e29243d 100644 --- a/PROJECT_TIMELINE.md +++ b/PROJECT_TIMELINE.md @@ -550,3 +550,4 @@ Last commit: c049ed6 - "fix: Добавлены n8n webhook URLs в docker-compo + diff --git a/QUICK_START_DOCUMENTS.md b/QUICK_START_DOCUMENTS.md index af70674..a282c8b 100644 --- a/QUICK_START_DOCUMENTS.md +++ b/QUICK_START_DOCUMENTS.md @@ -114,3 +114,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/upload_documents.log Подробная документация: `DOCUMENT_ATTACH_API.md` + diff --git a/README.md b/README.md index 1fefa29..f8a9cff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 🚀 ERV Insurance Platform +# 🚀 Ticket Form Intake Platform -**Современная платформа для страховых обращений** +**Платформа цифровой приёмки обращений для other.clientright.ru** - **Backend**: Python FastAPI (async) - **Frontend**: React 18 + TypeScript @@ -18,13 +18,13 @@ ``` Frontend (форма): -http://147.45.146.17:5173/ +http://147.45.146.17:5175/ Backend API: -http://147.45.146.17:8100/ +http://147.45.146.17:8200/ API Документация (Swagger UI): -http://147.45.146.17:8100/docs ← Интерактивная! +http://147.45.146.17:8200/docs ← Интерактивная! Gitea (Git репозиторий): http://147.45.146.17:3002/ @@ -47,7 +47,7 @@ source venv/bin/activate pip install -r requirements.txt # Запускаем сервер -uvicorn app.main:app --reload --host 0.0.0.0 --port 8100 +uvicorn app.main:app --reload --host 0.0.0.0 --port 8200 ``` ### **Frontend (React):** @@ -59,7 +59,7 @@ cd frontend npm install # Запускаем dev сервер -npm run dev -- --host 0.0.0.0 --port 5173 +npm run dev -- --host 0.0.0.0 --port 5175 ``` --- @@ -69,7 +69,7 @@ npm run dev -- --host 0.0.0.0 --port 5173 ### **Поток данных:** ``` -React (5173) → FastAPI (8100) → [Redis, RabbitMQ, PostgreSQL] +React (5175) → FastAPI (8200) → [Redis, RabbitMQ, PostgreSQL] ↓ OCR Service (8001) OpenRouter AI @@ -99,7 +99,7 @@ React (5173) → FastAPI (8100) → [Redis, RabbitMQ, PostgreSQL] ## 📁 Структура проекта ``` -erv_platform/ +ticket_form/ ├─ backend/ ← Python FastAPI │ ├─ app/ │ │ ├─ main.py diff --git a/SESSION_LOG_2025-11-01.md b/SESSION_LOG_2025-11-01.md index f6c5a00..a59c828 100644 --- a/SESSION_LOG_2025-11-01.md +++ b/SESSION_LOG_2025-11-01.md @@ -1155,3 +1155,5 @@ HTTP 200 OK **Автор:** AI Assistant (Claude Sonnet 4.5) **Дата:** 01-02 ноября 2025, 21:00-01:15 MSK + + diff --git a/SUMMARY_DOCUMENTS_API.md b/SUMMARY_DOCUMENTS_API.md new file mode 100644 index 0000000..d37f575 --- /dev/null +++ b/SUMMARY_DOCUMENTS_API.md @@ -0,0 +1,112 @@ +# 📎 ИТОГ: API привязки документов готов! + +## ✅ Что сделано + +### 1️⃣ Backend Endpoint +**URL:** `POST https://crm.clientright.ru/api/n8n/documents/attach` + +**Возможности:** +- ✅ Batch-обработка массива документов +- ✅ Умный парсинг S3 путей (автоматически добавляет хост) +- ✅ Поддержка двух форматов полей (`file`/`file_url`, `filename`/`file_name`) +- ✅ Привязка к HelpDesk (заявке) или Project (проекту) +- ✅ Детальная статистика по каждому документу +- ✅ Полное логирование всех операций + +### 2️⃣ PHP Backend +**Файл:** `/var/www/fastuser/data/www/crm.clientright.ru/upload_documents_to_crm.php` + +**Доработки:** +- ✅ Поддержка `ticket_id` для привязки к HelpDesk +- ✅ Логика: если `ticket_id` → HelpDesk, иначе → Project +- ✅ Обновление S3 метаданных в базе vTiger +- ✅ Прямая привязка через `relateEntities` если webservice не работает + +### 3️⃣ Документация +- 📄 `DOCUMENT_ATTACH_API.md` - полная документация API +- 📄 `QUICK_START_DOCUMENTS.md` - краткая шпаргалка +- 📄 `TEST_ATTACH_DOCUMENT.md` - примеры тестирования + +### 4️⃣ Тесты +- 🧪 `TEST_REAL_DATA.sh` - тест с реальными данными +- 🧪 `TEST_QUICK.sh` - быстрые тесты + +--- + +## 🚀 Формат входных данных + +```json +[ + { + "claim_id": "CLM-2025-11-02-WNRZZZ", + "event_type": "delay_flight", + "contact_id": "320096", + "project_id": "396868", + "ticket_id": "396936", + "filename": "boarding_pass.pdf", + "file_type": "flight_delay_boarding_or_ticket", + "file": "/bucket/path/to/file.pdf" + } +] +``` + +**Важно:** +- Всегда массив `[...]` (даже для одного документа) +- Поле `file` без хоста → автоматически добавится `https://s3.twcstorage.ru` +- `ticket_id` опционально (если есть → HelpDesk, иначе → Project) + +--- + +## 📊 Формат ответа + +```json +{ + "success": true, + "total_processed": 1, + "successful": 1, + "failed": 0, + "results": [ + { + "document_id": "15x396941", + "document_numeric_id": "396941", + "attached_to": "ticket", + "attached_to_id": "396936", + "file_name": "boarding_pass.pdf", + "file_type": "flight_delay_boarding_or_ticket", + "s3_bucket": "f9825c87-...", + "s3_key": "crm2/CRM_Active_Files/...", + "file_size": 85320, + "message": "Документ создан и привязан..." + } + ], + "errors": null +} +``` + +--- + +## 🧪 Тестирование + +```bash +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform +./TEST_REAL_DATA.sh +``` + +--- + +## 📝 Git коммиты + +``` +ec44f43 - docs: Добавлена краткая шпаргалка для быстрого старта +efb0cd6 - feat: Поддержка batch-обработки документов и умного парсинга S3 путей +e27280e - docs: Добавлена полная документация API привязки документов +936cea6 - feat: Добавлен эндпоинт для привязки документов к проекту/заявке +d3b7b3b - feat: Добавлены все N8N webhook URLs в config.py +5f4f992 - feat: Добавлена поддержка привязки документов к HelpDesk (CRM) +``` + +--- + +## 🎯 Готово к боевому использованию! + +Эндпоинт протестирован и готов к интеграции в n8n workflow! 🚀 diff --git a/TEST_ATTACH_DOCUMENT.md b/TEST_ATTACH_DOCUMENT.md index 16f941b..5115f5d 100644 --- a/TEST_ATTACH_DOCUMENT.md +++ b/TEST_ATTACH_DOCUMENT.md @@ -127,3 +127,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/upload_documents.log Эндпоинт готов к интеграции в n8n workflow! + diff --git a/TEST_QUICK.sh b/TEST_QUICK.sh index c0cef1e..e1c9b24 100755 --- a/TEST_QUICK.sh +++ b/TEST_QUICK.sh @@ -31,3 +31,4 @@ curl -X POST "https://crm.clientright.ru/api/n8n/documents/attach" \ echo "" echo "✅ Тесты завершены!" + diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py index 4257cdb..d4754b0 100644 --- a/backend/app/api/claims.py +++ b/backend/app/api/claims.py @@ -1,7 +1,9 @@ """ Claims API Routes - Обработка заявок """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request, Query +from typing import Optional, List +import httpx from .models import ( ClaimCreateRequest, ClaimResponse, @@ -12,42 +14,374 @@ from datetime import datetime import json import logging from ..services.redis_service import redis_service +from ..services.database import db from ..config import settings router = APIRouter(prefix="/api/v1/claims", tags=["Claims"]) logger = logging.getLogger(__name__) +N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3" -@router.post("/create", response_model=ClaimResponse) -async def create_claim(claim: ClaimCreateRequest): + +@router.post("/wizard") +async def submit_wizard(request: Request): """ - Создать новую заявку - - Принимает данные формы и создает заявку в системе + Отправка данных визарда (вопросы + файлы) в n8n через multipart/form-data. + + Вход: multipart/form-data с полями (stage=wizard, form_id, session_id, claim_id, ...), + JSON-строками (wizard_plan, wizard_answers, files_meta, ...) и файлами. """ try: - # Генерируем ID и номер заявки - claim_id = str(uuid.uuid4()) - claim_number = f"ERV-{datetime.now().strftime('%Y%m%d')}-{claim_id[:8].upper()}" - - # TODO: Сохранить в PostgreSQL - # TODO: Отправить в очередь RabbitMQ для обработки - # TODO: Интеграция с CRM - - return ClaimResponse( - success=True, - claim_id=claim_id, - claim_number=claim_number, - message=f"Заявка {claim_number} успешно создана" + form = await request.form() + + data: dict[str, str] = {} + files: dict[str, tuple] = {} + + for key, value in form.multi_items(): + # В starlette UploadFile — это другой класс, чем fastapi.UploadFile, + # поэтому проверяем по наличию атрибутов, а не по isinstance. + if hasattr(value, "filename") and hasattr(value, "read"): + file_bytes = await value.read() + files[key] = (value.filename, file_bytes, value.content_type) + else: + # Приводим всё к строкам, включая JSON-строки + data[key] = str(value) + + logger.info( + "📨 TicketForm wizard submit received", + extra={ + "claim_id": data.get("claim_id"), + "session_id": data.get("session_id"), + "files": list(files.keys()), + }, ) - + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + N8N_TICKET_FORM_FINAL_WEBHOOK, + data=data, + files=files or None, + ) + + text = response.text or "" + + if response.status_code == 200: + logger.info( + "✅ TicketForm wizard webhook OK", + extra={"response_preview": text[:500]}, + ) + try: + return json.loads(text) + except Exception: + return { + "success": True, + "message": "Wizard workflow started (non-JSON response from n8n)", + "raw": text, + } + + logger.error( + "❌ TicketForm wizard webhook error", + extra={"status_code": response.status_code, "body": text[:500]}, + ) + raise HTTPException( + status_code=response.status_code, + detail=f"n8n error: {text}", + ) + + except httpx.TimeoutException: + logger.error("⏱️ n8n wizard webhook timeout") + raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (wizard)") except Exception as e: + logger.exception("❌ Ошибка при отправке визарда") raise HTTPException( status_code=500, - detail=f"Ошибка при создании заявки: {str(e)}" + detail=f"Ошибка при отправке визарда: {str(e)}", ) +@router.post("/create") +async def create_claim(request: Request): + """ + Финальное создание заявки Ticket Form + + Принимает данные формы от фронтенда и пробрасывает их в n8n webhook. + """ + try: + body = await request.json() + + logger.info( + "📨 TicketForm final submit received", + extra={ + "claim_id": body.get("claim_id"), + "event_type": body.get("event_type"), + }, + ) + + # Проксируем запрос к n8n + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + N8N_TICKET_FORM_FINAL_WEBHOOK, + json=body, + headers={"Content-Type": "application/json"}, + ) + + text = response.text or "" + + if response.status_code == 200: + logger.info( + "✅ TicketForm final webhook OK", + extra={"response_preview": text[:500]}, + ) + # Если n8n вернул JSON — пробрасываем как есть + try: + return json.loads(text) + except Exception: + # Если не JSON, возвращаем обёртку + return { + "success": True, + "message": "Workflow started (non-JSON response from n8n)", + "raw": text, + } + + logger.error( + "❌ TicketForm final webhook error", + extra={ + "status_code": response.status_code, + "body": text[:500], + }, + ) + raise HTTPException( + status_code=response.status_code, + detail=f"n8n error: {text}", + ) + + except httpx.TimeoutException: + logger.error("⏱️ n8n final webhook timeout") + raise HTTPException(status_code=504, detail="Таймаут подключения к n8n") + except Exception as e: + logger.exception("❌ Ошибка при финальной отправке заявки") + raise HTTPException( + status_code=500, + detail=f"Ошибка при создании заявки: {str(e)}", + ) + + +@router.get("/drafts/list") +async def list_drafts( + unified_id: Optional[str] = Query(None, description="Unified ID пользователя для поиска черновиков"), + phone: Optional[str] = Query(None, description="Номер телефона для поиска (fallback, если unified_id не указан)"), + session_id: Optional[str] = Query(None, description="Session ID для поиска (fallback, если unified_id не указан)") +): + """ + Получить список всех заявок для пользователя (все статусы) + + Приоритет поиска: + 1. unified_id (основной способ) - ищет по clpr_claims.unified_id + 2. phone (fallback) - ищет через clpr_user_accounts и clpr_users + 3. session_id (fallback) - ищет по session_token + + Возвращает все заявки с колонкой status_code для фильтрации на фронтенде + """ + try: + if not unified_id and not phone and not session_id: + raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id") + + query = """ + SELECT + c.id, + c.payload->>'claim_id' as claim_id, + c.session_token, + c.status_code, + c.channel, + c.payload, + c.created_at, + c.updated_at + FROM clpr_claims c + WHERE 1=1 + """ + params = [] + + if unified_id: + # Основной способ - поиск по unified_id + query += " AND c.unified_id = $1" + params.append(unified_id) + elif phone: + # Fallback: ищем через clpr_user_accounts и clpr_users + query += """ + AND c.unified_id = ( + SELECT u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = $1 + LIMIT 1 + ) + """ + params.append(phone) + elif session_id: + # Fallback: поиск по session_token + query += " AND c.session_token = $1" + params.append(session_id) + + query += " ORDER BY c.updated_at DESC LIMIT 20" + + # Простой тест: проверяем, что unified_id вообще есть в базе + test_count = 0 + if unified_id: + try: + test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id) + except Exception as e: + logger.error(f"❌ Ошибка тестового COUNT: {e}") + + rows = await db.fetch_all(query, *params) + + # ВРЕМЕННО: возвращаем тестовые данные для отладки + debug_info = { + "unified_id": unified_id, + "test_count": test_count, + "rows_found": len(rows), + "query": query[:100] if len(query) > 100 else query, + "params": params + } + + drafts = [] + for row in rows: + # Обрабатываем payload - может быть строкой (JSONB) или уже dict + payload_raw = row.get('payload') + if isinstance(payload_raw, str): + try: + payload = json.loads(payload_raw) if payload_raw else {} + except (json.JSONDecodeError, TypeError): + payload = {} + elif isinstance(payload_raw, dict): + payload = payload_raw + else: + payload = {} + + drafts.append({ + "id": str(row['id']), + "claim_id": row.get('claim_id'), + "session_token": row.get('session_token'), + "status_code": row.get('status_code'), + "channel": row.get('channel'), # Добавляем канал в ответ + "created_at": row['created_at'].isoformat() if row.get('created_at') else None, + "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, + "problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None, + "wizard_plan": payload.get('wizard_plan') is not None, + "wizard_answers": payload.get('answers') is not None, + "has_documents": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False, + }) + + return { + "success": True, + "count": len(drafts), + "drafts": drafts + } + + except HTTPException: + raise + except Exception as e: + logger.exception("❌ Ошибка при получении списка черновиков") + raise HTTPException(status_code=500, detail=f"Ошибка при получении черновиков: {str(e)}") + + +@router.get("/drafts/{claim_id}") +async def get_draft(claim_id: str): + """ + Получить полные данные черновика по claim_id + + Возвращает все данные формы для продолжения заполнения + """ + try: + query = """ + SELECT + id, + payload->>'claim_id' as claim_id, + session_token, + status_code, + payload, + created_at, + updated_at + FROM clpr_claims + WHERE payload->>'claim_id' = $1 + AND status_code = 'draft' + AND channel = 'web_form' + LIMIT 1 + """ + + row = await db.fetch_one(query, claim_id) + + if not row: + raise HTTPException(status_code=404, detail="Черновик не найден") + + # Обрабатываем payload - может быть строкой (JSONB) или уже dict + payload_raw = row.get('payload') + if isinstance(payload_raw, str): + try: + payload = json.loads(payload_raw) if payload_raw else {} + except (json.JSONDecodeError, TypeError): + payload = {} + elif isinstance(payload_raw, dict): + payload = payload_raw + else: + payload = {} + + return { + "success": True, + "claim": { + "id": str(row['id']), + "claim_id": row.get('claim_id'), + "session_token": row.get('session_token'), + "status_code": row.get('status_code'), + "created_at": row['created_at'].isoformat() if row.get('created_at') else None, + "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, + "payload": payload + } + } + + except HTTPException: + raise + except Exception as e: + logger.exception("❌ Ошибка при получении черновика") + raise HTTPException(status_code=500, detail=f"Ошибка при получении черновика: {str(e)}") + + +@router.delete("/drafts/{claim_id}") +async def delete_draft(claim_id: str): + """ + Удалить черновик по claim_id + + Удаляет только черновики (status_code = 'draft') + """ + try: + query = """ + DELETE FROM clpr_claims + WHERE payload->>'claim_id' = $1 + AND status_code = 'draft' + AND channel = 'web_form' + RETURNING id + """ + + deleted_id = await db.fetch_val(query, claim_id) + + if not deleted_id: + raise HTTPException(status_code=404, detail="Черновик не найден или уже удален") + + logger.info(f"✅ Черновик удален: {claim_id}") + + return { + "success": True, + "message": "Черновик успешно удален", + "claim_id": claim_id + } + + except HTTPException: + raise + except Exception as e: + logger.exception("❌ Ошибка при удалении черновика") + raise HTTPException(status_code=500, detail=f"Ошибка при удалении черновика: {str(e)}") + + @router.get("/{claim_id}") async def get_claim(claim_id: str): """Получить информацию о заявке по ID""" diff --git a/backend/app/api/events.py b/backend/app/api/events.py index d7ce8ab..7e7e5b2 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -98,7 +98,7 @@ async def stream_events(task_id: str): # Слушаем события while True: logger.info(f"⏳ Waiting for message on {channel}...") - message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=30.0) + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=60.0) # Увеличено для RAG обработки if message: logger.info(f"📥 Received message type: {message['type']}") diff --git a/backend/app/api/n8n_proxy.py b/backend/app/api/n8n_proxy.py index 83ccbb5..80d2443 100644 --- a/backend/app/api/n8n_proxy.py +++ b/backend/app/api/n8n_proxy.py @@ -36,6 +36,7 @@ async def proxy_policy_check(request: Request): try: # Получаем JSON body от фронтенда body = await request.json() + body.setdefault('form_id', 'ticket_form') logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}") @@ -85,7 +86,12 @@ async def proxy_create_contact(request: Request): try: body = await request.json() - logger.info(f"🔄 Proxy create contact: phone={body.get('phone', 'unknown')}, session_id={body.get('session_id', 'unknown')}") + logger.info( + "🔄 Proxy create contact: phone=%s, session_id=%s, form_id=%s", + body.get('phone', 'unknown'), + body.get('session_id', 'unknown'), + body.get('form_id', 'missing') + ) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( @@ -175,8 +181,27 @@ async def proxy_file_upload( ) if response.status_code == 200: + response_text = response.text logger.info(f"✅ File upload success") - return response.json() + + if not response_text or response_text.strip() == '': + # n8n может вернуть пустой ответ, возвращаем заглушку + logger.warning("⚠️ N8N upload webhook вернул пустой ответ, подставляю default payload") + return {"success": True, "message": "n8n: empty response"} + + try: + return response.json() + except Exception as e: + logger.error(f"❌ Не удалось распарсить JSON от n8n: {e}. Response: {response_text[:500]}") + # Возвращаем текстовое содержимое чтобы фронт мог показать пользователю + return JSONResponse( + status_code=200, + content={ + "success": True, + "message": "n8n upload returned non-JSON response", + "raw": response_text + } + ) else: logger.error(f"❌ N8N returned {response.status_code}: {response.text}") raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index 53ac12d..d1e962a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ """ Ticket Form Intake Platform - FastAPI Backend """ -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import logging @@ -189,6 +189,15 @@ async def test(): } +@app.get("/api/v1/utils/client-ip") +async def get_client_ip(request: Request): + """Возвращает IP-адрес клиента по HTTP-запросу""" + client_host = request.client.host if request.client else None + return { + "ip": client_host + } + + @app.get("/api/v1/info") async def info(): """Информация о платформе""" diff --git a/docs/CLAIMSAVE_FINAL_SQL.md b/docs/CLAIMSAVE_FINAL_SQL.md new file mode 100644 index 0000000..e856b0c --- /dev/null +++ b/docs/CLAIMSAVE_FINAL_SQL.md @@ -0,0 +1,210 @@ +# Исправленный SQL для ноды `claimsave_final` + +## Текущая проблема + +Нода `claimsave_final` использует `$2::uuid`, но получает строку `"CLM-2025-11-18-GEQ3KL"`, что вызывает ошибку. + +## Особенности `claimsave_final` + +1. Используется **после конвертации файлов в PDF** и загрузки в S3 +2. Работает с `file_url` (URL файла в S3) +3. Обновляет только `documents_meta` в payload (не трогает `answers`) +4. Использует динамический префикс таблицы (для разных схем) + +## Исправленный SQL запрос + +```sql +-- $1 = payload_partial_json (jsonb) +-- $2 = claim_id (text, например "CLM-2025-11-18-GEQ3KL") + +WITH partial AS ( + SELECT $1::jsonb AS p, $2::text AS claim_id_str +), + +-- Находим UUID по строковому claim_id +claim_lookup AS ( + SELECT + COALESCE( + (SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1), + gen_random_uuid() + ) AS claim_uuid + FROM partial +), + +-- Если записи нет, создаем её (на всякий случай) +claim_created AS ( + INSERT INTO clpr_claims ( + id, + session_token, + channel, + type_code, + status_code, + payload, + created_at, + updated_at, + expires_at + ) + SELECT + claim_lookup.claim_uuid, + COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text), + 'web_form', + COALESCE(partial.p->>'type_code', 'consumer'), + 'draft', + jsonb_build_object( + 'claim_id', partial.claim_id_str, + 'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb) + ), + now(), + now(), + now() + interval '14 days' + FROM partial, claim_lookup + WHERE NOT EXISTS ( + SELECT 1 FROM clpr_claims WHERE id = claim_lookup.claim_uuid + ) + ON CONFLICT (id) DO NOTHING + RETURNING id +), + +-- Получаем финальный UUID +claim_final AS ( + SELECT + CASE + WHEN EXISTS (SELECT 1 FROM claim_created) + THEN (SELECT id FROM claim_created LIMIT 1) + ELSE claim_lookup.claim_uuid + END AS claim_uuid + FROM claim_lookup +), + +-- Извлекаем документы из payload +docs AS ( + SELECT + claim_final.claim_uuid::text AS claim_id, -- преобразуем UUID в строку для clpr_claim_documents + doc.field_name::text, + doc.file_id::text, + doc.file_name::text, + doc.original_file_name::text, + (doc.uploaded_at)::timestamptz AS uploaded_at, + doc.file_url::text + FROM partial, claim_final + CROSS JOIN LATERAL jsonb_to_recordset( + COALESCE(partial.p->'documents_meta','[]'::jsonb) + ) AS doc( + field_name text, + file_id text, + file_name text, + original_file_name text, + uploaded_at text, + file_url text + ) +), + +-- Сохраняем/обновляем документы +upsert_docs AS ( + INSERT INTO clpr_claim_documents + (claim_id, field_name, file_id, uploaded_at, file_name, original_file_name) + SELECT + claim_id, + field_name, + file_id, + uploaded_at, + file_name, + original_file_name + FROM docs + ON CONFLICT (claim_id, field_name) DO UPDATE + SET file_id = EXCLUDED.file_id, + uploaded_at = EXCLUDED.uploaded_at, + file_name = EXCLUDED.file_name, + original_file_name = EXCLUDED.original_file_name + RETURNING id, claim_id, field_name, file_id +), + +-- Обновляем payload (только documents_meta, не трогаем answers) +upd_claim AS ( + UPDATE clpr_claims c + SET + payload = jsonb_set( + COALESCE(c.payload, '{}'::jsonb), + '{documents_meta}', + COALESCE((SELECT p->'documents_meta' FROM partial), '[]'::jsonb), + true + ), + updated_at = now(), + expires_at = now() + interval '14 days' + FROM partial, claim_final + WHERE c.id = claim_final.claim_uuid + RETURNING c.id, c.payload +) + +SELECT + (SELECT jsonb_build_object( + 'claim_id', u.id::text, + 'claim_id_str', (u.payload->>'claim_id'), + 'payload', u.payload + ) FROM upd_claim u LIMIT 1) AS claim, + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', u.id, + 'field_name', u.field_name, + 'file_id', u.file_id, + 'file_url', d.file_url, + 'file_name', d.file_name, + 'original_file_name', d.original_file_name, + 'uploaded_at', d.uploaded_at, + -- имя, которое безопасно отдавать во внешний API + 'filename_for_upload', + COALESCE( + NULLIF(d.original_file_name, ''), + NULLIF(d.file_name, ''), + regexp_replace(d.file_id, '^.*/', '') -- хвост пути как запасной + ) + ) + ) + FROM upsert_docs u + JOIN docs d + ON d.claim_id = u.claim_id + AND d.field_name = u.field_name + WHERE d.file_url IS NOT NULL AND d.file_url <> '' -- не показываем без URL + ) AS documents; +``` + +## Изменения + +1. **`$2::text` вместо `$2::uuid`**: Принимает строковый `claim_id` +2. **`claim_lookup` CTE**: Находит UUID по строковому `claim_id` из `payload->>'claim_id'` +3. **`claim_created` CTE**: Создает запись, если её нет (на всякий случай) +4. **`claim_final` CTE**: Получает финальный UUID (из созданной или существующей записи) +5. **`docs` CTE**: Преобразует UUID в строку для `clpr_claim_documents` (т.к. там `claim_id` имеет тип `character varying`) +6. **Убраны динамические префиксы**: Используется `clpr_claims` и `clpr_claim_documents` напрямую + +## Параметры запроса + +В n8n PostgreSQL Node: +``` +Parameters: +$1 = {{ $json.payload_partial_json }} (JSONB) +$2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3KL") +``` + +## Если нужен динамический префикс + +Если всё-таки нужен динамический префикс таблицы (как в оригинале), можно использовать: + +```sql +-- Вместо clpr_claims использовать: +{{ $('Edit Fields').item.json.propertyName.prefix }}claims + +-- Вместо clpr_claim_documents использовать: +{{ $('Edit Fields').item.json.propertyName.prefix }}claim_documents +``` + +Но для `ticket_form` это не нужно, т.к. мы всегда работаем с `clpr_*` таблицами. + +## Отличия от `claimsave` + +1. **`claimsave`**: Сохраняет данные визарда (answers, wizard_plan, wizard_answers) +2. **`claimsave_final`**: Обновляет только `documents_meta` после обработки файлов, добавляет `file_url` + +Оба запроса теперь используют строковый `claim_id` и правильно находят UUID. + diff --git a/docs/CODE1_FIX.md b/docs/CODE1_FIX.md new file mode 100644 index 0000000..ba61edb --- /dev/null +++ b/docs/CODE1_FIX.md @@ -0,0 +1,103 @@ +# Исправление ошибки в Code1: mapDialogHistory + +## Проблема + +**Ошибка:** +``` +Cannot read properties of null (reading 'map') [line 69] +``` + +**Причина:** +Функция `mapDialogHistory` получает `null` вместо массива, когда `src.dialog_history` равен `null`. + +## Исправление + +### Текущий код (строка 69): + +```javascript +function mapDialogHistory(h = []) { + return h.map(m => ({ + id: toNullish(m.id), + role: toNullish(m.role), + message: toNullish(m.message), + message_type: toNullish(m.message_type), + tg_message_id: toNullish(m.tg_message_id), + created_at: toNullish(m.created_at), + })); +} +``` + +### Исправленный код: + +```javascript +function mapDialogHistory(h = []) { + // Проверяем, что h не null и является массивом + if (!h || !Array.isArray(h)) return []; + return h.map(m => ({ + id: toNullish(m.id), + role: toNullish(m.role), + message: toNullish(m.message), + message_type: toNullish(m.message_type), + tg_message_id: toNullish(m.tg_message_id), + created_at: toNullish(m.created_at), + })); +} +``` + +## Альтернативное решение + +Можно также исправить в месте вызова: + +```javascript +// В функции normalizeOne, строка ~172 +dialog_history: mapDialogHistory(src.dialog_history || []), +``` + +Но лучше исправить саму функцию, чтобы она была более устойчивой. + +## Полный исправленный код функции mapDialogHistory + +```javascript +function mapDialogHistory(h = []) { + // Проверяем, что h не null и является массивом + if (!h || !Array.isArray(h)) return []; + return h.map(m => ({ + id: toNullish(m.id), + role: toNullish(m.role), + message: toNullish(m.message), + message_type: toNullish(m.message_type), + tg_message_id: toNullish(m.tg_message_id), + created_at: toNullish(m.created_at), + })); +} +``` + +## Почему это происходит + +Когда SQL запрос в ноде `give_data1` возвращает `null` для `dialog_history` (если нет записей в `clpr_dialog_history_tg`), функция `mapDialogHistory` получает `null` вместо массива. + +PostgreSQL `jsonb_agg` возвращает `null`, если нет строк для агрегации, а не пустой массив `[]`. + +## Дополнительные проверки + +Можно также добавить проверки для других функций, которые работают с массивами: + +```javascript +function mapDocuments(docs = []) { + if (!docs || !Array.isArray(docs)) return []; + return docs.map(d => ({...})); +} + +function mapVisionDocs(vds = []) { + if (!vds || !Array.isArray(vds)) return []; + return vds.map(v => ({...})); +} + +function mapCombinedDocs(cds = []) { + if (!cds || !Array.isArray(cds)) return []; + return cds.map(c => ({...})); +} +``` + +Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает. + diff --git a/docs/CODE1_FIXED_CODE.js b/docs/CODE1_FIXED_CODE.js new file mode 100644 index 0000000..147d855 --- /dev/null +++ b/docs/CODE1_FIXED_CODE.js @@ -0,0 +1,212 @@ +// Code node (JavaScript). Input: items[0].json = либо объект, либо массив таких объектов, как ты прислал. +// Output: по одному нормализованному объекту на кейс. +// Никаких внешних зависимостей, всё на ванильном JS. + +function toNullish(v) { + if (v === undefined || v === null) return null; + if (typeof v === 'string' && v.trim() === '') return null; + return v; +} + +function pick(o, path, def = null) { + try { + return toNullish(path.split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), o)); + } catch { + return def; + } +} + +function mapDocuments(docs = []) { + // Проверяем, что docs не null и является массивом + if (!docs || !Array.isArray(docs)) return []; + return docs.map(d => ({ + id: toNullish(d.id), + claim_document_id: toNullish(d.id), // у тебя id = claim_document_id + file_id: toNullish(d.file_id), + file_url: toNullish(d.file_url), + file_name: toNullish(d.file_name), + original_file_name: toNullish(d.original_file_name), + field_name: toNullish(d.field_name), + upload_description: toNullish(d.upload_description), + uploaded_at: toNullish(d.uploaded_at), + filename_for_upload: toNullish(d.filename_for_upload), + })); +} + +function mapVisionDocs(vds = []) { + // Проверяем, что vds не null и является массивом + if (!vds || !Array.isArray(vds)) return []; + return vds.map(v => ({ + claim_document_id: toNullish(v.claim_document_id), + vision_document_id: toNullish(v.vision_document_id), + pages: toNullish(v.pages), + content_sha256: toNullish(v.content_sha256), + vision_text: toNullish(v.vision_text), + vision_pages: Array.isArray(v.vision_pages) + ? v.vision_pages.map(p => ({ + page: toNullish(p.page), + uid: toNullish(p.uid), + })) + : null, + })); +} + +function mapCombinedDocs(cds = []) { + // Проверяем, что cds не null и является массивом + if (!cds || !Array.isArray(cds)) return []; + return cds.map(c => ({ + claim_document_id: toNullish(c.claim_document_id), + combined_document_id: toNullish(c.combined_document_id), + pages: toNullish(c.pages), + content_sha256: toNullish(c.content_sha256), + combined_text: toNullish(c.combined_text), + page_summaries: Array.isArray(c.page_summaries) + ? c.page_summaries.map(ps => ({ + page: toNullish(ps.page), + chars: toNullish(ps.chars), + uid: toNullish(ps.uid), + image_url: toNullish(ps.image_url), + })) + : null, + })); +} + +function mapDialogHistory(h = []) { + // ИСПРАВЛЕНО: Проверяем, что h не null и является массивом + if (!h || !Array.isArray(h)) return []; + return h.map(m => ({ + id: toNullish(m.id), + role: toNullish(m.role), + message: toNullish(m.message), + message_type: toNullish(m.message_type), + tg_message_id: toNullish(m.tg_message_id), + created_at: toNullish(m.created_at), + })); +} + +function mapCoverageReport(cr = null) { + if (!cr) return null; + return { + questions: Array.isArray(cr.questions) + ? cr.questions.map(q => ({ + name: toNullish(q.name), + value: toNullish(q.value), + status: toNullish(q.status), + source: toNullish(q.source), + confidence: toNullish(q.confidence), + })) + : null, + docs_missing: Array.isArray(cr.docs_missing) ? cr.docs_missing : null, + docs_received: Array.isArray(cr.docs_received) ? cr.docs_received : null, + }; +} + +function normalizeOne(src) { + const claim = src.claim ?? {}; + const userInfo = src.user_info ?? {}; + const propertyName = claim.propertyName ?? {}; + + // answers_parsed уже есть в claim; не мудрим — возвращаем как есть, пустоты -> null + const answersParsed = claim.answers_parsed + ? Object.fromEntries( + Object.entries(claim.answers_parsed).map(([k, v]) => [k, toNullish(v)]) + ) + : null; + + // wizard план (часто нужен на фронте) — оставим ключевые поля + let wizard = null; + try { + const parsed = typeof claim.wizard_plan === 'string' + ? JSON.parse(claim.wizard_plan) + : (claim.wizard_plan_parsed ?? null); + if (parsed) { + wizard = { + version: toNullish(parsed.version), + case_type: toNullish(parsed.case_type), + goals: Array.isArray(parsed.goals) ? parsed.goals : null, + documents: Array.isArray(parsed.documents) ? parsed.documents : null, + questions: Array.isArray(parsed.questions) ? parsed.questions : null, + risks: Array.isArray(parsed.risks) ? parsed.risks : null, + deadlines: Array.isArray(parsed.deadlines) ? parsed.deadlines : null, + ask_order: Array.isArray(parsed.ask_order) ? parsed.ask_order : null, + notes: toNullish(parsed.notes), + user_text: toNullish(parsed.user_text), + }; + } + } catch { + wizard = null; + } + + // Склеиваем user — берём user_info, плюс propertyName на всякий, и то, что лежит в диалогах + const user = { + channel: toNullish(userInfo.channel ?? propertyName.channel), + user_id: toNullish(userInfo.user_id ?? propertyName.user_id), + unified_id: toNullish(userInfo.unified_id ?? propertyName.unified_id), + telegram_id: toNullish(userInfo.telegram_id ?? propertyName.telegram_id ?? claim.telegram_id), + session_token: toNullish(userInfo.session_token ?? propertyName.session_token ?? claim.session_token), + }; + + // Собираем + const out = { + case: { + id: toNullish(pick(claim, 'id')), + prefix: toNullish(pick(claim, 'prefix')), + channel: toNullish(pick(claim, 'channel')), + type_code: toNullish(pick(claim, 'type_code')), + status_code: toNullish(pick(claim, 'status_code')), + created_at: toNullish(pick(claim, 'created_at')), + updated_at: toNullish(pick(claim, 'updated_at')), + telegram_id: toNullish(pick(claim, 'telegram_id')), + session_token: toNullish(pick(claim, 'session_token')), + unified_id: toNullish(pick(claim, 'unified_id')), + case_type: toNullish(pick(claim, 'case_type')), + }, + + user, // см. выше + + answers: answersParsed, + + // что загрузили + documents: mapDocuments(src.documents), + + // OCR/Vision/Combined, если есть + vision_docs: mapVisionDocs(src.vision_docs), + combined_docs: mapCombinedDocs(src.combined_docs), + + // что там в "coverage_report" (кто что заполнил/не заполнил в мастере) + coverage_report: mapCoverageReport(pick(claim, 'coverage_report')), + + // история чата (ID, роли, тексты) + dialog_history: mapDialogHistory(src.dialog_history), + + // на всякий — куда и что складывали на S3 в момент сохранения + s3_manifest: { + session_token: toNullish(pick(claim, 'session_token')), + documents_meta: Array.isArray(claim.documents_meta) ? claim.documents_meta : null, + }, + + // флаги/риски, что засетили при сохранении + risks: Array.isArray(claim.risks) ? claim.risks : null, + + // план (wizard), как есть — пригодится фронту и валидаторам + wizard_plan: wizard, + }; + + return out; +} + +// === entrypoint === +const raw = items[0]?.json ?? {}; +const arr = Array.isArray(raw) ? raw : [raw]; + +// опциональный фильтр по claim_id, если в item передадут { claim_id: "..." } +const claimIdFilter = items[0]?.json?.claim_id || items[0]?.json?.claimId || null; + +// Прогоняем всё, отдаём по одному Item на кейс +const results = arr + .map(normalizeOne) + .filter(obj => (claimIdFilter ? obj.case.id === claimIdFilter : true)) + .map(obj => ({ json: obj })); + +return results.length ? results : [{ json: null }]; + diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..ed16706 --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,183 @@ +# Схема базы данных clpr_* + +## Основные таблицы + +### 1. `clpr_users` - Основная таблица пользователей +``` +id (integer, PK) +universal_id (uuid) +unified_id (varchar) ← КЛЮЧЕВОЕ ПОЛЕ для связи +phone (varchar) +created_at, updated_at +``` + +### 2. `clpr_user_accounts` - Связь пользователей с каналами +``` +id (integer, PK) +user_id (integer) → FK на clpr_users.id +channel (text) - 'telegram', 'web_form' +channel_user_id (text) - ID в канале (telegram_id для telegram, phone для web_form) +``` + +**Связь:** +- `clpr_user_accounts.user_id` → `clpr_users.id` +- Уникальность: `(channel, channel_user_id)` - один пользователь может быть в нескольких каналах + +### 3. `clpr_claims` - Заявки/черновики +``` +id (uuid, PK) +session_token (varchar) +unified_id (varchar) ← СВЯЗЬ С clpr_users.unified_id (должен заполняться n8n!) +telegram_id (bigint) +channel (text) - 'telegram', 'web_form' +user_id (integer) - возможно FK на clpr_users.id +type_code (text) +status_code (text) - 'draft', 'in_work', etc. +policy_number (text) +payload (jsonb) - содержит phone, claim_id, wizard_plan, answers, documents_meta и т.д. +is_confirmed (boolean) +created_at, updated_at, expires_at +``` + +**Связь:** +- `clpr_claims.unified_id` → `clpr_users.unified_id` (логическая связь) +- `clpr_claims.user_id` → `clpr_users.id` (возможно, не всегда заполнено) + +### 4. `clpr_users_tg` - Данные Telegram пользователей +``` +telegram_id (bigint, PK) +unified_id (varchar) → clpr_users.unified_id +phone_number (varchar) +first_name_tg, last_name_tg, username, language_code, is_premium +first_name, last_name, middle_name, birth_date, etc. +``` + +### 5. `clpr_claim_documents` - Документы заявок +``` +id (uuid, PK) +claim_id (varchar) → clpr_claims.id (логическая связь через payload->>'claim_id') +field_name (text) +file_id (text) +uploaded_at (timestamp) +file_name, original_file_name +``` + +### 6. `clpr_documents` - Хранилище документов +``` +id (uuid, PK) +source (text) +content (text) +metadata (jsonb) +created_at +``` + +## Логика работы с черновиками для web_form + +### Шаг 1: Проверка пользователя в CRM +- n8n вызывает `CreateWebContact` с phone +- Получает `contact_id` из CRM + +### Шаг 2: Поиск/создание пользователя в PostgreSQL +SQL запрос (аналогично Telegram): +```sql +WITH existing AS ( + SELECT u.id AS user_id, u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = '{phone}' + LIMIT 1 +), +create_user AS ( + INSERT INTO clpr_users (unified_id, phone, created_at, updated_at) + SELECT 'usr_' || gen_random_uuid()::text, '{phone}', now(), now() + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id AS user_id, unified_id +), +final_user AS ( + SELECT * FROM existing + UNION ALL + SELECT * FROM create_user +), +create_account AS ( + INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id) + SELECT + (SELECT user_id FROM final_user), + 'web_form', + '{phone}' + ON CONFLICT (channel, channel_user_id) DO NOTHING +) +SELECT unified_id FROM final_user LIMIT 1; +``` + +### Шаг 3: Создание/обновление заявки +- n8n создает/обновляет запись в `clpr_claims` +- **ВАЖНО:** заполняет `unified_id` из результата шага 2 +- Сохраняет `phone` в `payload->>'phone'` +- `channel = 'web_form'` +- `status_code = 'draft'` для черновиков + +### Шаг 4: Поиск черновиков +```sql +SELECT + c.id, + c.payload->>'claim_id' as claim_id, + c.session_token, + c.status_code, + c.payload, + c.created_at, + c.updated_at +FROM clpr_claims c +WHERE c.status_code = 'draft' + AND c.channel = 'web_form' + AND c.unified_id = '{unified_id}' -- ← ПОИСК ПО unified_id! +ORDER BY c.updated_at DESC +LIMIT 20; +``` + +## Проблема в текущей реализации + +**Текущее состояние:** +- В `clpr_claims` поле `unified_id` **ПУСТОЕ** для всех черновиков web_form +- Поиск идет по `payload->>'phone'` или `session_token`, что не надежно + +**Решение:** +- n8n должен заполнять `unified_id` при создании/обновлении заявки +- Backend должен искать черновики по `unified_id`, а не по phone/session_id + +## Связи между таблицами + +``` +clpr_users (unified_id) + ↑ + | (через unified_id) + | +clpr_claims (unified_id) + | + | (через user_id) + ↓ +clpr_user_accounts (user_id → clpr_users.id) + | + | (channel='web_form', channel_user_id=phone) + ↓ +clpr_claims (payload->>'phone') +``` + +## Для Telegram (для сравнения) + +``` +clpr_users (unified_id) + ↑ + | (через unified_id) + | +clpr_users_tg (unified_id) + | + | (telegram_id) + ↓ +clpr_user_accounts (channel='telegram', channel_user_id=telegram_id) + | + | (user_id) + ↓ +clpr_users (id) +``` + diff --git a/docs/FIXED_SQL_QUERY.md b/docs/FIXED_SQL_QUERY.md new file mode 100644 index 0000000..d502bbc --- /dev/null +++ b/docs/FIXED_SQL_QUERY.md @@ -0,0 +1,285 @@ +# Исправленный SQL запрос для сохранения заявки + +## Проблема + +Оригинальный SQL запрос использует `$2::uuid`, но передается строка `"CLM-2025-11-18-GEQ3KL"`, что вызывает ошибку: +``` +invalid input syntax for type uuid: "CLM-2025-11-18-GEQ3KL" +``` + +## Решение + +Изменить SQL запрос так, чтобы он: +1. Принимал `claim_id` как строку (VARCHAR) +2. Искал запись в `clpr_claims` по `payload->>'claim_id'` или создавал новую +3. Использовал найденный UUID для дальнейших операций + +## Исправленный SQL запрос + +```sql +WITH partial AS ( + SELECT $1::jsonb AS p, $2::text AS claim_id_str +), + +-- Сначала находим существующую запись или создаем новую +claim_lookup AS ( + SELECT + COALESCE( + (SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1), + gen_random_uuid() + ) AS claim_uuid + FROM partial +), + +-- Если записи нет, создаем её +claim_created AS ( + INSERT INTO clpr_claims ( + id, + session_token, + channel, + type_code, + status_code, + payload, + created_at, + updated_at, + expires_at + ) + SELECT + claim_lookup.claim_uuid, + COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text), + 'web_form', + COALESCE(partial.p->>'type_code', 'consumer'), + 'draft', + jsonb_build_object( + 'claim_id', partial.claim_id_str, + 'answers', + CASE + -- В корне + WHEN partial.p->>'wizard_answers' IS NOT NULL + THEN (partial.p->>'wizard_answers')::jsonb + -- В edit_fields_raw.body + WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL + THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb + -- В edit_fields_parsed.body + WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL + THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb + -- Если уже объект + WHEN partial.p->'wizard_answers' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_answers') = 'object' + THEN partial.p->'wizard_answers' + ELSE '{}'::jsonb + END, + 'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb), + 'wizard_plan', + CASE + -- В корне + WHEN partial.p->>'wizard_plan' IS NOT NULL + THEN (partial.p->>'wizard_plan')::jsonb + -- В edit_fields_raw.body + WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL + THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb + -- В edit_fields_parsed.body + WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_plan' IS NOT NULL + THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_plan')::jsonb + -- Если уже объект + WHEN partial.p->'wizard_plan' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_plan') = 'object' + THEN partial.p->'wizard_plan' + ELSE NULL + END + ), + now(), + now(), + now() + interval '14 days' + FROM partial, claim_lookup + WHERE NOT EXISTS ( + SELECT 1 FROM clpr_claims WHERE id = claim_lookup.claim_uuid + ) + ON CONFLICT (id) DO NOTHING + RETURNING id +), + +-- Получаем финальный UUID (из существующей записи или только что созданной) +claim_final AS ( + SELECT + CASE + WHEN EXISTS (SELECT 1 FROM claim_created) + THEN (SELECT id FROM claim_created LIMIT 1) + ELSE claim_lookup.claim_uuid + END AS claim_uuid + FROM claim_lookup +), + +inserted_docs AS ( + INSERT INTO clpr_claim_documents + (claim_id, field_name, file_id, uploaded_at, file_name, original_file_name) + SELECT + claim_final.claim_uuid::text AS claim_id, + doc.field_name, + doc.file_id, + (doc.uploaded_at)::timestamptz AS uploaded_at, + doc.file_name, + doc.original_file_name + FROM partial, claim_final + CROSS JOIN LATERAL jsonb_to_recordset( + COALESCE(partial.p->'documents_meta','[]'::jsonb) + ) AS doc( + field_name text, + file_id text, + file_name text, + original_file_name text, + uploaded_at text + ) + ON CONFLICT (claim_id, field_name) DO UPDATE + SET file_id = EXCLUDED.file_id, + uploaded_at = EXCLUDED.uploaded_at, + file_name = EXCLUDED.file_name, + original_file_name = EXCLUDED.original_file_name + RETURNING id, claim_id, field_name, file_id +), + +existing AS ( + SELECT c.id, c.payload + FROM clpr_claims c, claim_final + WHERE c.id = claim_final.claim_uuid + FOR UPDATE +), + +old AS ( + SELECT + COALESCE( + (SELECT payload FROM existing LIMIT 1), + '{}'::jsonb + ) AS old_payload + FROM claim_final +), + +-- Парсим wizard_answers из строки в JSON объект +-- Ищем в разных местах: корень, edit_fields_raw.body, edit_fields_parsed.body +wizard_answers_parsed AS ( + SELECT + CASE + -- В корне payload_partial_json + WHEN partial.p->>'wizard_answers' IS NOT NULL + THEN (partial.p->>'wizard_answers')::jsonb + -- В edit_fields_raw.body.wizard_answers + WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL + THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb + -- В edit_fields_parsed.body.wizard_answers + WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL + THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb + -- Если уже объект (не строка) + WHEN partial.p->'wizard_answers' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_answers') = 'object' + THEN partial.p->'wizard_answers' + ELSE '{}'::jsonb + END AS answers + FROM partial +), + +-- Парсим wizard_plan из строки в JSON объект +-- Ищем в разных местах: корень, edit_fields_raw.body, edit_fields_parsed.body +wizard_plan_parsed AS ( + SELECT + CASE + -- В корне payload_partial_json + WHEN partial.p->>'wizard_plan' IS NOT NULL + THEN (partial.p->>'wizard_plan')::jsonb + -- В edit_fields_raw.body.wizard_plan + WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL + THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb + -- В edit_fields_parsed.body.wizard_plan + WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_plan' IS NOT NULL + THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_plan')::jsonb + -- Если уже объект (не строка) + WHEN partial.p->'wizard_plan' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_plan') = 'object' + THEN partial.p->'wizard_plan' + ELSE NULL + END AS wizard_plan + FROM partial +), + +-- Объединяем documents_meta без дублирования (используем новый, если есть) +docs_merged AS ( + SELECT + COALESCE( + NULLIF(partial.p->'documents_meta', 'null'::jsonb), + old.old_payload->'documents_meta', + '[]'::jsonb + ) AS documents_meta + FROM old, partial +), + +-- Формируем чистый payload (без лишних полей) +clean_payload AS ( + SELECT jsonb_build_object( + 'claim_id', partial.claim_id_str, + 'answers', (SELECT answers FROM wizard_answers_parsed LIMIT 1), + 'documents_meta', (SELECT documents_meta FROM docs_merged LIMIT 1), + 'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed LIMIT 1) + ) AS clean + FROM partial +), + +upd AS ( + UPDATE clpr_claims c + SET + payload = ( + -- Сохраняем только нужные поля из старого payload + COALESCE(old.old_payload, '{}'::jsonb) - 'answers' - 'documents_meta' - 'wizard_plan' - 'wizard_answers' - 'form_data' - 'edit_fields_raw' - 'edit_fields_parsed' + -- Добавляем чистый payload + || (SELECT clean FROM clean_payload LIMIT 1) + ), + status_code = CASE + WHEN ( (SELECT answers->>'docs_exist' FROM wizard_answers_parsed LIMIT 1) = 'true' ) + THEN 'in_work' + ELSE COALESCE(c.status_code, 'draft') + END, + updated_at = now(), + expires_at = now() + interval '14 days' + FROM partial, old, claim_final, clean_payload + WHERE c.id = claim_final.claim_uuid + RETURNING c.id, c.status_code, c.payload +) + +SELECT + (SELECT jsonb_build_object( + 'claim_id', u.id::text, + 'claim_id_str', (u.payload->>'claim_id'), + 'status_code', u.status_code, + 'payload', u.payload + ) FROM upd u) AS claim, + (SELECT jsonb_agg(jsonb_build_object( + 'id', id, + 'field_name', field_name, + 'file_id', file_id + )) FROM inserted_docs) AS documents; +``` + +## Изменения + +1. **`claim_id_str` вместо `uuid`**: `$2::text AS claim_id_str` вместо `$2::uuid AS cid` +2. **`claim_lookup` CTE**: Находит существующую запись по `payload->>'claim_id'` или генерирует новый UUID +3. **`claim_created` CTE**: Создает новую запись, если её нет +4. **Использование `claim_uuid`**: Во всех местах используется UUID из `claim_lookup`, а не строка +5. **`claim_id` в `clpr_claim_documents`**: Преобразуется в строку `claim_uuid::text`, т.к. в таблице `claim_id` имеет тип `character varying` + +## Параметры запроса + +```javascript +// В n8n PostgreSQL Node +Parameters: +$1 = JSONB с данными (payload_partial_json) +$2 = TEXT с claim_id ("CLM-2025-11-18-GEQ3KL") +``` + +## Альтернативное решение (если не хотите менять SQL) + +Если не хотите менять SQL запрос, можно изменить логику в n8n: + +1. **Перед SQL запросом** добавить Code Node, который: + - Находит запись в `clpr_claims` по `payload->>'claim_id'` + - Если найдена - использует её `id` (UUID) + - Если не найдена - создает новую запись и возвращает её `id` + +2. **Передавать UUID** вместо строки `claim_id` в SQL запрос + +Но первый вариант (изменение SQL) более надежный и правильный. + diff --git a/docs/N8N_CODE_NODE_RESPONSE.js b/docs/N8N_CODE_NODE_RESPONSE.js new file mode 100644 index 0000000..8d1fc50 --- /dev/null +++ b/docs/N8N_CODE_NODE_RESPONSE.js @@ -0,0 +1,38 @@ +// ======================================== +// Code Node: Формирование Response для фронта +// (перед финальной Response нодой) +// ======================================== + +// Получаем данные из предыдущих шагов +const claimResult = $node["CreateWebContact"].json.result; +const sessionData = JSON.parse($('Code in JavaScript1').first().json.redis_value); +const userData = $node["user_get"].json; // ← Данные из PostgreSQL: Find or Create User + +// Формируем ответ в формате, который ожидает фронт +return { + success: true, + result: { + claim_id: sessionData.claim_id, + contact_id: sessionData.contact_id, + project_id: sessionData.project_id, + + // Unified ID из PostgreSQL (обязательно!) + unified_id: userData.unified_id || userData.unified_id, // из ноды user_get + + // Данные заявки + ticket_id: claimResult.ticket_id, + ticket_number: claimResult.ticket_number, + title: claimResult.title, + category: claimResult.category, + status: claimResult.status, + + // Метаданные + event_type: sessionData.event_type, + current_step: sessionData.current_step, + updated_at: sessionData.updated_at, + + // Дополнительно + is_new_contact: claimResult.is_new_contact || false + } +}; + diff --git a/docs/N8N_CODE_NODE_RESPONSE_SAFE.js b/docs/N8N_CODE_NODE_RESPONSE_SAFE.js new file mode 100644 index 0000000..033a4ec --- /dev/null +++ b/docs/N8N_CODE_NODE_RESPONSE_SAFE.js @@ -0,0 +1,47 @@ +// ======================================== +// Code Node: Формирование Response для фронта (безопасная версия с проверками) +// (перед финальной Response нодой) +// ======================================== + +// Получаем данные из предыдущих шагов +const claimResult = $node["CreateWebContact"]?.json?.result || {}; +const sessionDataItem = $('Code in JavaScript1')?.first(); +const sessionData = sessionDataItem?.json?.redis_value + ? JSON.parse(sessionDataItem.json.redis_value) + : {}; +const userData = $node["user_get"]?.json || {}; // ← Данные из PostgreSQL: Find or Create User + +// Проверяем наличие unified_id (критически важно!) +if (!userData.unified_id) { + console.error('❌ ОШИБКА: unified_id не получен из ноды user_get!'); + // Можно либо выбросить ошибку, либо продолжить без unified_id (не рекомендуется) +} + +// Формируем ответ в формате, который ожидает фронт +return { + success: true, + result: { + claim_id: sessionData.claim_id || claimResult.claim_id, + contact_id: sessionData.contact_id || claimResult.contact_id, + project_id: sessionData.project_id, + + // Unified ID из PostgreSQL (обязательно!) + unified_id: userData.unified_id, // из ноды user_get (PostgreSQL: Find or Create User) + + // Данные заявки + ticket_id: claimResult.ticket_id, + ticket_number: claimResult.ticket_number, + title: claimResult.title, + category: claimResult.category, + status: claimResult.status, + + // Метаданные + event_type: sessionData.event_type, + current_step: sessionData.current_step || 1, + updated_at: sessionData.updated_at || new Date().toISOString(), + + // Дополнительно + is_new_contact: claimResult.is_new_contact || false + } +}; + diff --git a/docs/N8N_RESPONSE_FORMAT.md b/docs/N8N_RESPONSE_FORMAT.md new file mode 100644 index 0000000..7b8c14e --- /dev/null +++ b/docs/N8N_RESPONSE_FORMAT.md @@ -0,0 +1,94 @@ +# Формат ответа n8n после проверки телефона + +## Текущий формат (неполный) + +```json +{ + "success": true, + "result": { + "claim_id": "CLM-2025-11-19-7O55SP", + "contact_id": "398644", + "event_type": null, + "current_step": 1, + "updated_at": "2025-11-19T15:15:07.323Z" + } +} +``` + +## Требуемый формат (с unified_id) + +```json +{ + "success": true, + "result": { + "claim_id": "CLM-2025-11-19-7O55SP", + "contact_id": "398644", + "unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c", // ← ДОБАВИТЬ! + "event_type": null, + "current_step": 1, + "updated_at": "2025-11-19T15:15:07.323Z", + "is_new_contact": false // опционально + } +} +``` + +## Где добавить unified_id в n8n workflow + +### Шаг 1: После CreateWebContact +- Получен `contact_id` из CRM +- Есть `phone` из запроса + +### Шаг 2: PostgreSQL Node - Find or Create User +- Выполнить SQL запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql` +- Параметр: `$1 = {{$json.phone}}` (нормализованный телефон) +- Результат: `unified_id` и `user_id` + +### Шаг 3: Response Node или Code Node +Вернуть ответ с unified_id: + +```javascript +return { + success: true, + result: { + claim_id: $('CreateWebContact').item.json.claim_id || $('GenerateClaimId').item.json.claim_id, + contact_id: $('CreateWebContact').item.json.contact_id, + unified_id: $('PostgreSQL_FindOrCreateUser').item.json.unified_id, // ← ВАЖНО! + event_type: null, + current_step: 1, + updated_at: new Date().toISOString(), + is_new_contact: $('CreateWebContact').item.json.is_new_contact || false + } +}; +``` + +## Важно! + +1. **unified_id обязателен** - frontend использует его для поиска черновиков +2. **Формат unified_id**: `usr_{UUID}` (например, `usr_90599ff2-ac79-4236-b950-0df85395096c`) +3. **Если unified_id отсутствует** - frontend не сможет найти черновики пользователя +4. **При создании/обновлении черновика** - обязательно заполнять `clpr_claims.unified_id = unified_id` + +## Проверка в frontend + +Frontend уже готов принимать unified_id: + +```typescript +// Step1Phone.tsx, строка 132 +updateFormData({ + phone, + smsCode: code, + contact_id: result.contact_id, + unified_id: result.unified_id, // ✅ Уже ожидается! + claim_id: result.claim_id, + is_new_contact: result.is_new_contact +}); +``` + +## Пример полного workflow в n8n + +1. **Webhook** → получает `{phone, session_id, form_id}` +2. **CreateWebContact** → создает/находит контакт в CRM → возвращает `contact_id` +3. **GenerateClaimId** → генерирует `claim_id` (если нужно) +4. **PostgreSQL: Find or Create User** → выполняет SQL запрос → возвращает `unified_id` +5. **Response** → возвращает полный ответ с `unified_id` + diff --git a/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md b/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md new file mode 100644 index 0000000..6e6084f --- /dev/null +++ b/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md @@ -0,0 +1,144 @@ +# Обновление Response Node в n8n: Добавление unified_id + +## Проблема +В текущем Response Node отсутствует `unified_id`, который необходим для поиска черновиков на фронтенде. + +## Решение + +### Шаг 1: Убедитесь, что есть нода `user_get` +Это PostgreSQL нода, которая выполняет SQL запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql`. + +**Настройки ноды:** +- **Name**: `user_get` (или другое имя, но должно совпадать в коде) +- **Operation**: Execute Query +- **Query**: SQL из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql` +- **Parameters**: `$1 = {{$json.phone}}` (нормализованный телефон) + +**Результат ноды:** +```json +{ + "unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c", + "user_id": 1 +} +``` + +### Шаг 2: Обновите Code Node перед Response + +**Вариант 1: Простая версия** +```javascript +// ======================================== +// Code Node: Формирование Response для фронта +// (перед финальной Response нодой) +// ======================================== + +const claimResult = $node["CreateWebContact"].json.result; +const sessionData = JSON.parse($('Code in JavaScript1').first().json.redis_value); +const userData = $node["user_get"].json; // ← Данные из PostgreSQL + +return { + success: true, + result: { + claim_id: sessionData.claim_id, + contact_id: sessionData.contact_id, + project_id: sessionData.project_id, + + // Unified ID из PostgreSQL (обязательно!) + unified_id: userData.unified_id, // ← ДОБАВЛЕНО! + + ticket_id: claimResult.ticket_id, + ticket_number: claimResult.ticket_number, + title: claimResult.title, + category: claimResult.category, + status: claimResult.status, + + event_type: sessionData.event_type, + current_step: sessionData.current_step, + updated_at: sessionData.updated_at, + + is_new_contact: claimResult.is_new_contact || false + } +}; +``` + +**Вариант 2: Безопасная версия с проверками** +```javascript +// ======================================== +// Code Node: Формирование Response для фронта (безопасная версия) +// ======================================== + +const claimResult = $node["CreateWebContact"]?.json?.result || {}; +const sessionDataItem = $('Code in JavaScript1')?.first(); +const sessionData = sessionDataItem?.json?.redis_value + ? JSON.parse(sessionDataItem.json.redis_value) + : {}; +const userData = $node["user_get"]?.json || {}; // ← Данные из PostgreSQL + +// Проверяем наличие unified_id (критически важно!) +if (!userData.unified_id) { + console.error('❌ ОШИБКА: unified_id не получен из ноды user_get!'); + // Можно либо выбросить ошибку, либо продолжить без unified_id (не рекомендуется) +} + +return { + success: true, + result: { + claim_id: sessionData.claim_id || claimResult.claim_id, + contact_id: sessionData.contact_id || claimResult.contact_id, + project_id: sessionData.project_id, + + // Unified ID из PostgreSQL (обязательно!) + unified_id: userData.unified_id, // ← ДОБАВЛЕНО! + + ticket_id: claimResult.ticket_id, + ticket_number: claimResult.ticket_number, + title: claimResult.title, + category: claimResult.category, + status: claimResult.status, + + event_type: sessionData.event_type, + current_step: sessionData.current_step || 1, + updated_at: sessionData.updated_at || new Date().toISOString(), + + is_new_contact: claimResult.is_new_contact || false + } +}; +``` + +## Порядок нод в workflow + +1. **Webhook** → получает `{phone, session_id, form_id}` +2. **Code in JavaScript1** → получает данные из Redis +3. **CreateWebContact** → создает/находит контакт в CRM +4. **user_get** (PostgreSQL) → находит/создает пользователя → возвращает `unified_id` +5. **Code Node** (этот код) → формирует финальный ответ +6. **Response** → возвращает ответ фронтенду + +## Важно! + +1. **Имя ноды**: Убедитесь, что имя ноды PostgreSQL совпадает с `$node["user_get"]` в коде +2. **unified_id обязателен**: Без него фронтенд не сможет найти черновики +3. **Проверка**: Добавьте проверку на наличие `unified_id` перед возвратом ответа + +## Ожидаемый формат ответа + +```json +{ + "success": true, + "result": { + "claim_id": "CLM-2025-11-19-7O55SP", + "contact_id": "398644", + "project_id": "12345", + "unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c", // ← ОБЯЗАТЕЛЬНО! + "ticket_id": "45678", + "ticket_number": "HD001234", + "title": "Заявка", + "category": "Категория", + "status": "Новая", + "event_type": null, + "current_step": 1, + "updated_at": "2025-11-19T15:15:07.323Z", + "is_new_contact": false + } +} +``` + diff --git a/docs/N8N_USER_CREATION_INSTRUCTIONS.md b/docs/N8N_USER_CREATION_INSTRUCTIONS.md new file mode 100644 index 0000000..49c9538 --- /dev/null +++ b/docs/N8N_USER_CREATION_INSTRUCTIONS.md @@ -0,0 +1,133 @@ +# Инструкция для n8n: Создание/поиск пользователя web_form + +## Контекст +После создания контакта в CRM через `CreateWebContact`, нужно найти или создать пользователя в PostgreSQL и получить `unified_id` для связи с черновиками. + +## Шаги в n8n workflow + +### 1. После CreateWebContact +- Получен `contact_id` из CRM +- Есть `phone` из запроса + +### 2. PostgreSQL Node: Find or Create User + +**Настройки:** +- **Operation**: Execute Query +- **Query**: Использовать запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql` +- **Parameters**: + - `$1` = `{{$json.phone}}` (или `{{$('CreateWebContact').item.json.phone}}`) + +**Запрос:** +```sql +WITH existing AS ( + SELECT u.id AS user_id, u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = $1 + LIMIT 1 +), +create_user AS ( + INSERT INTO clpr_users (unified_id, phone, created_at, updated_at) + SELECT + 'usr_' || gen_random_uuid()::text, + $1, + now(), + now() + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id AS user_id, unified_id +), +final_user AS ( + SELECT * FROM existing + UNION ALL + SELECT * FROM create_user +), +update_unified AS ( + UPDATE clpr_users + SET unified_id = COALESCE( + unified_id, + 'usr_' || gen_random_uuid()::text + ), + updated_at = now() + WHERE id = (SELECT user_id FROM final_user LIMIT 1) + AND unified_id IS NULL + RETURNING id AS user_id, unified_id +), +final_unified_id AS ( + SELECT unified_id FROM update_unified + UNION ALL + SELECT unified_id FROM final_user + WHERE NOT EXISTS (SELECT 1 FROM update_unified) + LIMIT 1 +), +create_account AS ( + INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id) + SELECT + (SELECT user_id FROM final_user LIMIT 1), + 'web_form', + $1 + ON CONFLICT (channel, channel_user_id) DO UPDATE + SET user_id = EXCLUDED.user_id + RETURNING user_id, channel, channel_user_id +) +SELECT + (SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id, + (SELECT user_id FROM final_user LIMIT 1) AS user_id; +``` + +**Результат:** +```json +{ + "unified_id": "usr_b2fd7f73-c238-4fde-949b-c404cded12f3", + "user_id": 106 +} +``` + +### 3. Сохранение unified_id в Redis + +**Set Node (Redis)** или **Code Node**: +```javascript +const unified_id = $input.item.json.unified_id; +const claim_id = $('CreateWebContact').item.json.claim_id; // или откуда берете claim_id + +// Сохранить в Redis +await redis.set(`claim:${claim_id}`, JSON.stringify({ + ...existing_data, + unified_id: unified_id +})); +``` + +### 4. Возврат unified_id в ответе frontend + +**Response Node** или в **Code Node** перед возвратом: +```javascript +return { + success: true, + result: { + contact_id: $('CreateWebContact').item.json.contact_id, + claim_id: $('CreateWebContact').item.json.claim_id, + unified_id: $('PostgreSQL').item.json.unified_id, // ← ВАЖНО! + is_new_contact: $('CreateWebContact').item.json.is_new_contact + } +}; +``` + +## Важно! + +1. **unified_id должен быть в ответе** - frontend сохраняет его в `formData.unified_id` +2. **При создании/обновлении черновика** - заполнять `clpr_claims.unified_id = unified_id` +3. **Формат телефона**: `79991234567` (11 цифр, начинается с 7) + +## Проверка работы + +После выполнения запроса проверьте: +```sql +SELECT u.unified_id, u.phone, ua.channel, ua.channel_user_id +FROM clpr_users u +JOIN clpr_user_accounts ua ON u.id = ua.user_id +WHERE ua.channel = 'web_form' + AND ua.channel_user_id = '79991234567'; +``` + +Должна быть запись с `unified_id` в формате `usr_...`. + diff --git a/docs/PERSONAL_CABINET_ARCHITECTURE.md b/docs/PERSONAL_CABINET_ARCHITECTURE.md new file mode 100644 index 0000000..88515fc --- /dev/null +++ b/docs/PERSONAL_CABINET_ARCHITECTURE.md @@ -0,0 +1,431 @@ +# Архитектура личного кабинета и возобновления заполнения формы + +## Сценарии использования + +### 1. Пользователь начинает заполнять форму +``` +1. Вводит телефон → SMS верификация +2. Заполняет шаг 1 (полис) +3. Заполняет шаг 2 (визард) +4. Закрывает браузер (не завершил) +``` + +### 2. Пользователь возвращается через час/день/неделю +``` +1. Заходит в личный кабинет +2. Видит список незавершенных заявок +3. Нажимает "Продолжить заполнение" +4. Форма должна быстро загрузиться с сохраненным состоянием +``` + +--- + +## Варианты архитектуры + +### Вариант 1: Только PostgreSQL (простой) + +**Как работает:** +``` +Личный кабинет → Запрос в PostgreSQL → Получение данных → Отображение формы +``` + +**Плюсы:** +- ✅ Просто (один источник данных) +- ✅ Всегда актуальные данные +- ✅ Нет рассинхронизации + +**Минусы:** +- ❌ Каждый раз запрос к PostgreSQL (1-10 мс) +- ❌ Нагрузка на БД при частых обращениях + +**Когда использовать:** +- Небольшая нагрузка +- Простота важнее скорости + +--- + +### Вариант 2: PostgreSQL + Redis кеш (рекомендую) + +**Как работает:** + +#### При сохранении данных: +``` +1. Сохраняем в PostgreSQL (основное хранилище) +2. Сохраняем в Redis с TTL 24 часа (быстрый доступ) +``` + +#### При чтении данных: +``` +1. Пробуем Redis (быстро, 0.1-1 мс) +2. Если нет в кеше → PostgreSQL (1-10 мс) +3. Загружаем в Redis на 24 часа (для следующих обращений) +``` + +**Плюсы:** +- ✅ Быстрый доступ (если есть в кеше) +- ✅ Fallback на PostgreSQL (если кеш пуст) +- ✅ Автоматическая очистка (TTL 24 часа) +- ✅ Lazy loading (загружаем в Redis при первом обращении) + +**Минусы:** +- ⚠️ Нужно обновлять оба хранилища +- ⚠️ Риск устаревших данных (если забыли обновить кеш) + +**Когда использовать:** +- Средняя/высокая нагрузка +- Важна скорость загрузки +- Пользователи часто возвращаются к формам + +--- + +### Вариант 3: Только Redis с периодической синхронизацией + +**Как работает:** +``` +1. Основное хранилище - Redis (TTL 7 дней) +2. Периодически синхронизируем с PostgreSQL (раз в час/день) +3. При завершении формы - сохраняем в PostgreSQL +``` + +**Плюсы:** +- ✅ Очень быстрый доступ +- ✅ Автоматическая очистка старых сессий + +**Минусы:** +- ❌ Риск потери данных (если Redis упал) +- ❌ Сложнее синхронизация +- ❌ Нет истории изменений + +**Когда использовать:** +- Не рекомендуется (рискованно) + +--- + +## Рекомендуемая архитектура (Вариант 2) + +### Структура данных в Redis: + +**Ключ:** `claim:CLM-2025-11-18-GEQ3KL` + +**Значение:** +```json +{ + "claim_id": "CLM-2025-11-18-GEQ3KL", + "contact_id": "398523", + "phone": "72352352352", + "status": "draft", + "current_step": 3, + "payload": { + "answers": {...}, + "wizard_plan": {...}, + "documents_meta": [...] + }, + "created_at": "2025-11-18T20:43:47.033Z", + "updated_at": "2025-11-18T20:44:59.217Z" +} +``` + +**TTL:** 24 часа (86400 секунд) + +--- + +### Алгоритм работы: + +#### 1. При сохранении данных (claimsave): + +```python +# В n8n workflow после SQL запроса + +# 1. Сохраняем в PostgreSQL (уже сделано) +# 2. Сохраняем в Redis для быстрого доступа +redis_key = f"claim:{claim_id}" +redis_value = { + "claim_id": claim_id, + "contact_id": contact_id, + "phone": phone, + "status": "draft", + "current_step": current_step, + "payload": { + "answers": answers, + "wizard_plan": wizard_plan, + "documents_meta": documents_meta + }, + "updated_at": datetime.now().isoformat() +} + +await redis.set_json( + redis_key, + redis_value, + expire=86400 # 24 часа +) +``` + +#### 2. При чтении данных (личный кабинет): + +```python +async def get_claim_for_resume(claim_id: str): + # 1. Пробуем Redis (быстро) + cached = await redis.get_json(f"claim:{claim_id}") + if cached: + logger.info(f"✅ Cache hit: {claim_id}") + return cached + + # 2. Если нет в кеше - из PostgreSQL + logger.info(f"🔄 Cache miss: {claim_id}, loading from PostgreSQL") + claim = await db.get_claim_by_claim_id(claim_id) + + if not claim: + return None + + # 3. Формируем данные для Redis + redis_data = { + "claim_id": claim_id, + "contact_id": claim.payload.get("contact_id"), + "phone": claim.payload.get("phone"), + "status": claim.status_code, + "current_step": calculate_current_step(claim.payload), + "payload": { + "answers": claim.payload.get("answers", {}), + "wizard_plan": claim.payload.get("wizard_plan"), + "documents_meta": claim.payload.get("documents_meta", []) + }, + "updated_at": claim.updated_at.isoformat() + } + + # 4. Сохраняем в Redis на 24 часа (lazy loading) + await redis.set_json(f"claim:{claim_id}", redis_data, expire=86400) + + return redis_data +``` + +#### 3. При обновлении данных: + +```python +async def update_claim(claim_id: str, data: dict): + # 1. Обновляем PostgreSQL (основное хранилище) + await db.update_claim(claim_id, data) + + # 2. Обновляем Redis кеш (если есть) + redis_key = f"claim:{claim_id}" + if await redis.exists(redis_key): + cached = await redis.get_json(redis_key) + if cached: + # Мерджим данные + cached.update(data) + cached["updated_at"] = datetime.now().isoformat() + await redis.set_json(redis_key, cached, expire=86400) + + # Или просто удаляем кеш (при следующем чтении загрузится из PostgreSQL) + # await redis.delete(redis_key) +``` + +--- + +## Стратегии TTL + +### Вариант A: Фиксированный TTL (24 часа) + +**Плюсы:** +- ✅ Просто +- ✅ Автоматическая очистка старых данных + +**Минусы:** +- ❌ Может истечь, даже если пользователь активен + +### Вариант B: Продлеваем TTL при обращении + +**Плюсы:** +- ✅ Активные заявки не истекают +- ✅ Старые заявки автоматически очищаются + +**Минусы:** +- ⚠️ Нужно продлевать TTL при каждом чтении + +**Реализация:** +```python +async def get_claim_with_refresh(claim_id: str): + cached = await redis.get_json(f"claim:{claim_id}") + if cached: + # Продлеваем TTL на 24 часа + await redis.expire(f"claim:{claim_id}", 86400) + return cached + # ... загрузка из PostgreSQL +``` + +### Вариант C: Длинный TTL для незавершенных заявок + +**Плюсы:** +- ✅ Незавершенные заявки хранятся долго (7 дней) +- ✅ Завершенные заявки удаляются быстро (1 час) + +**Реализация:** +```python +ttl = 604800 if status == "draft" else 3600 # 7 дней или 1 час +await redis.set_json(redis_key, data, expire=ttl) +``` + +--- + +## Личный кабинет: Список незавершенных заявок + +### Как получить список: + +**Вариант 1: Из PostgreSQL (рекомендую)** +```sql +SELECT + id, + payload->>'claim_id' as claim_id, + status_code, + payload->'answers' as answers, + updated_at +FROM clpr_claims +WHERE + payload->>'claim_id' LIKE 'CLM-%' + AND status_code IN ('draft', 'in_work') + AND channel = 'web_form' + AND updated_at > NOW() - INTERVAL '30 days' +ORDER BY updated_at DESC +LIMIT 20; +``` + +**Вариант 2: Из Redis (если нужно очень быстро)** +```python +# Ищем все ключи claim:CLM-* +keys = await redis.keys("claim:CLM-*") +claims = [] +for key in keys: + claim = await redis.get_json(key) + if claim and claim.get("status") in ["draft", "in_work"]: + claims.append(claim) +``` + +**Проблема:** Redis не предназначен для поиска по паттернам (медленно) + +**Решение:** Использовать индекс в PostgreSQL: +```sql +CREATE INDEX idx_clpr_claims_status_channel +ON clpr_claims(status_code, channel) +WHERE status_code IN ('draft', 'in_work'); +``` + +--- + +## Рекомендуемая архитектура + +### Для веб-формы: + +1. **Основное хранилище:** PostgreSQL (`clpr_claims`) + - Полные данные + - История изменений + - Надежность + +2. **Кеш:** Redis (`claim:CLM-...`) + - Быстрый доступ + - TTL 24 часа + - Lazy loading (загружаем при первом обращении) + +3. **Алгоритм:** + ``` + Чтение: + 1. Redis (если есть) → возврат + 2. PostgreSQL → загрузка → сохранение в Redis → возврат + + Запись: + 1. PostgreSQL (основное) + 2. Redis (обновление кеша или удаление) + ``` + +4. **TTL стратегия:** + - Незавершенные заявки (`draft`, `in_work`): 7 дней + - Завершенные заявки (`submitted`): 1 час + - Продлеваем TTL при обращении + +--- + +## Реализация в n8n + +### После `claimsave`: + +```javascript +// Code Node: Save to Redis +const claim = $json.claim; +const channel = $json.channel || 'web_form'; + +if (channel === 'web_form') { + // Определяем TTL в зависимости от статуса + const status = claim.status_code || 'draft'; + const ttl = (status === 'draft' || status === 'in_work') + ? 604800 // 7 дней для незавершенных + : 3600; // 1 час для завершенных + + return { + redis_key: `claim:${claim.claim_id_str}`, + redis_value: JSON.stringify({ + claim_id: claim.claim_id_str, + contact_id: claim.payload?.contact_id, + phone: claim.payload?.phone, + status: status, + current_step: calculateStep(claim.payload), + payload: { + answers: claim.payload?.answers, + wizard_plan: claim.payload?.wizard_plan, + documents_meta: claim.payload?.documents_meta + }, + updated_at: new Date().toISOString() + }), + ttl: ttl + }; +} + +// Redis Node: SET with TTL +// Key: {{ $json.redis_key }} +// Value: {{ $json.redis_value }} +// TTL: {{ $json.ttl }} +``` + +### При чтении (личный кабинет): + +```javascript +// Code Node: Get claim with cache +const claim_id = $json.claim_id; + +// 1. Пробуем Redis +const cached = await redis.get(`claim:${claim_id}`); +if (cached) { + return JSON.parse(cached); +} + +// 2. Если нет - из PostgreSQL +// (выполняется SQL запрос) +const claim = await postgres.get_claim(claim_id); + +// 3. Сохраняем в Redis +if (claim) { + await redis.set(`claim:${claim_id}`, JSON.stringify(claim), 'EX', 86400); +} + +return claim; +``` + +--- + +## Итог + +### Рекомендуемая архитектура: + +1. **PostgreSQL** - основное хранилище (источник истины) +2. **Redis** - кеш для быстрого доступа (TTL 24 часа, продлеваем при обращении) +3. **Lazy loading** - загружаем в Redis при первом обращении +4. **Инвалидация** - обновляем или удаляем кеш при изменении данных + +### Преимущества: +- ✅ Быстрый доступ (если есть в кеше) +- ✅ Надежность (данные в PostgreSQL) +- ✅ Автоматическая очистка (TTL) +- ✅ Гибкость (можно отключить кеш, если не нужен) + +### Когда использовать: +- ✅ Личный кабинет (список незавершенных заявок) +- ✅ Возобновление заполнения формы +- ✅ Быстрая загрузка состояния формы + diff --git a/docs/PROMPT_UPDATE_GUIDE.md b/docs/PROMPT_UPDATE_GUIDE.md new file mode 100644 index 0000000..7cdf47f --- /dev/null +++ b/docs/PROMPT_UPDATE_GUIDE.md @@ -0,0 +1,73 @@ +# Инструкция по обновлению промпта в n8n + +## Текущая ситуация + +**Используется:** `optimized_wizard_prompt.txt` (включает RAG) +**Время генерации:** 23-35 секунд + +**Новый промпт:** `wizard_prompt_simple.txt` (без RAG) +**Ожидаемое время:** 5-10 секунд (без RAG) + +## Шаги для обновления + +### 1. Открыть workflow в n8n + +1. Зайти в n8n: https://n8n.clientright.pro +2. Найти workflow с ID `b4K4u851b4JFivyD` (или тот, который обрабатывает `ticket_form:description`) +3. Найти ноду **AI Agent** или **OpenAI** (которая генерирует визард) + +### 2. Обновить промпт + +**Старый промпт (с RAG):** +``` +Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска. + +ВХОД: +- USER_MESSAGE: "{{ $json.chatInput }}" +- RAG_ANSWER: "{{ $json.output }}" +- FORM_STEPS: {{ $json.questions_numbered_html }} +``` + +**Новый промпт (без RAG):** +``` +# Роль + +Ты — юридический ассистент по защите прав потребителей. Ты помогаешь людям понять, какие необходимо собрать документы и сообщить дополнительные сведения, для решения их проблемы. + +# Задача: Построение динамического визарда + +Твоя задача — проанализировать описание проблемы пользователя и создать **динамический визард** — структурированный набор вопросов и списка документов, которые помогут собрать всю необходимую информацию для подготовки претензии или иска. + +## Входные данные + +Ты получаешь только: +- **USER_DESCRIPTION**: "{{ $json.chatInput }}" + +[Далее весь текст из wizard_prompt_simple.txt] +``` + +### 3. Убрать RAG из workflow (опционально) + +Если RAG не нужен, можно: +1. Удалить ноду RAG/поиска +2. Убрать `RAG_ANSWER` из промпта +3. Упростить входные данные до одного поля: `USER_DESCRIPTION` + +### 4. Протестировать + +1. Отправить тестовое описание через форму +2. Проверить время генерации (должно быть 5-10 сек вместо 23-35 сек) +3. Проверить качество визарда (вопросы и документы должны быть релевантными) + +## Ожидаемый результат + +- ⚡ **Время генерации:** 5-10 секунд (вместо 23-35) +- 📝 **Качество:** такое же или лучше (более структурированный промпт) +- 💰 **Стоимость:** ниже (нет RAG запросов) + +## Откат (если что-то пошло не так) + +1. Вернуть старый промпт из `optimized_wizard_prompt.txt` +2. Восстановить RAG ноду (если удаляли) +3. Проверить, что всё работает как раньше + diff --git a/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md b/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md new file mode 100644 index 0000000..c86f58b --- /dev/null +++ b/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md @@ -0,0 +1,191 @@ +# Анализ: Нужно ли хранить данные заявки в Redis? + +## Текущая ситуация + +### Что сейчас в Redis: + +**Ключ:** `claim:CLM-2025-11-18-GEQ3KL` + +**Значение:** +```json +{ + "claim_id": "CLM-2025-11-18-GEQ3KL", + "contact_id": "398523", + "phone": "72352352352", + "is_new_contact": true, + "status": "draft", + "current_step": 2, + "created_at": "2025-11-18T20:43:47.033Z", + "updated_at": "2025-11-18T20:44:59.217Z", + "voucher": null, + "event_type": null, + "documents": {}, + "email": null, + "bank_name": null, + "project_id": "398524", + "is_new_project": true +} +``` + +**TTL:** ~6.5 дней (563566 секунд) + +--- + +## Для чего использовался Redis (Telegram бот) + +### Исторически: +1. **Быстрый доступ к сессии** - Telegram бот не имеет постоянного состояния +2. **Хранение промежуточных данных** - пока пользователь заполняет форму +3. **TTL 7 дней** - автоматическая очистка старых сессий +4. **Легковесное хранилище** - не нужна полная БД для временных данных + +### Проблемы: +- ❌ Дублирование данных (есть в PostgreSQL) +- ❌ Нужно синхронизировать Redis и PostgreSQL +- ❌ Риск рассинхронизации данных +- ❌ Дополнительная сложность + +--- + +## Текущая архитектура (веб-форма) + +### PostgreSQL (основное хранилище): +- ✅ `clpr_claims` - полные данные заявки в `payload` (JSONB) +- ✅ `clpr_claim_documents` - документы +- ✅ Постоянное хранилище +- ✅ Транзакции и целостность данных +- ✅ История изменений (updated_at) + +### Redis (только Pub/Sub): +- ✅ `ocr_events:{claim_id}` - события обработки файлов (SSE) +- ✅ Временные события, не хранятся постоянно + +--- + +## Нужно ли хранить в Redis для веб-формы? + +### ❌ НЕТ, не нужно! + +**Причины:** + +1. **Данные уже в PostgreSQL** + - Все данные заявки хранятся в `clpr_claims.payload` + - Полная информация доступна из БД + - Нет необходимости дублировать + +2. **Веб-форма != Telegram бот** + - Telegram бот: нет постоянного состояния, нужен быстрый доступ к сессии + - Веб-форма: состояние хранится в React (useState), данные в PostgreSQL + - Не нужен промежуточный кеш + +3. **Риск рассинхронизации** + - Если данные в Redis и PostgreSQL расходятся - проблемы + - Сложнее поддерживать консистентность + - Дополнительная точка отказа + +4. **Усложнение архитектуры** + - Нужно обновлять и Redis, и PostgreSQL + - Больше кода для поддержки + - Больше мест, где может что-то сломаться + +--- + +## Что делать с существующими данными в Redis? + +### Вариант 1: Оставить как есть (для совместимости) +- ✅ Не ломает существующий Telegram бот +- ✅ Можно использовать для быстрого доступа к базовым данным +- ❌ Дублирование данных +- ❌ Нужно синхронизировать + +### Вариант 2: Убрать для веб-формы, оставить для Telegram +- ✅ Чистая архитектура для веб-формы +- ✅ Telegram бот продолжает работать +- ✅ Нет дублирования для веб-формы +- ⚠️ Нужно различать источник (channel: 'web_form' vs 'telegram') + +### Вариант 3: Полностью убрать (миграция на PostgreSQL) +- ✅ Единый источник истины (PostgreSQL) +- ✅ Проще архитектура +- ❌ Нужно мигрировать Telegram бот +- ❌ Может сломать существующую логику + +--- + +## Рекомендация + +### Для веб-формы (`channel: 'web_form'`): + +**НЕ сохранять в Redis**, потому что: + +1. ✅ Данные уже в PostgreSQL (`clpr_claims`) +2. ✅ Состояние формы в React (`useState`) +3. ✅ Нет необходимости в промежуточном кеше +4. ✅ Меньше сложности, меньше багов + +### Для Telegram бота (`channel: 'telegram'`): + +**Оставить Redis** (если используется), потому что: + +1. ✅ Telegram бот может нуждаться в быстром доступе к сессии +2. ✅ Нет постоянного состояния в боте +3. ✅ TTL автоматически очищает старые сессии + +--- + +## Итог + +**Для веб-формы (`ticket_form`):** +- ❌ **НЕ нужно** сохранять в Redis `claim:CLM-...` +- ✅ Все данные в PostgreSQL (`clpr_claims`) +- ✅ Redis используется только для Pub/Sub (`ocr_events:{claim_id}`) + +**Для Telegram бота:** +- ✅ Можно оставить Redis для совместимости +- ⚠️ Но лучше тоже мигрировать на PostgreSQL для единообразия + +--- + +## Что делать в n8n workflow? + +### В ноде `claimsave` и `claimsave_final`: + +**НЕ добавлять сохранение в Redis**, если: +- `channel = 'web_form'` (веб-форма) +- Данные уже сохранены в PostgreSQL + +**Можно добавить сохранение в Redis**, если: +- `channel = 'telegram'` (Telegram бот) +- Нужна обратная совместимость + +### Пример проверки в n8n: + +```javascript +// После SQL запроса (claimsave) +const channel = $json.channel || 'web_form'; + +if (channel === 'telegram') { + // Сохраняем в Redis для Telegram бота + return { + redis_key: `claim:${$json.claim_id}`, + redis_value: JSON.stringify({ + claim_id: $json.claim_id, + contact_id: $json.contact_id, + // ... остальные поля + }), + ttl: 604800 // 7 дней + }; +} else { + // Для веб-формы - не сохраняем в Redis + return $json; +} +``` + +--- + +## Вывод + +**Для веб-формы НЕ нужно сохранять в Redis `claim:CLM-...`** + +Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`). + diff --git a/docs/REDIS_VS_POSTGRESQL_SPEED.md b/docs/REDIS_VS_POSTGRESQL_SPEED.md new file mode 100644 index 0000000..cc27a2f --- /dev/null +++ b/docs/REDIS_VS_POSTGRESQL_SPEED.md @@ -0,0 +1,198 @@ +# Redis vs PostgreSQL: Когда что использовать? + +## Скорость доступа + +### Redis: +- ⚡ **0.1-1 мс** (данные в памяти) +- Мгновенный доступ +- Идеально для частых чтений + +### PostgreSQL: +- 🐢 **1-10 мс** (с индексами) +- Зависит от нагрузки и индексов +- Но всё равно очень быстро + +--- + +## Когда Redis имеет смысл + +### ✅ Используй Redis, если: + +1. **Очень частые чтения** (каждый запрос, каждый клик) + - Например: счетчики, rate limiting, сессии + +2. **Временные данные** (TTL, автоочистка) + - Например: SMS коды, временные токены + +3. **Кеширование результатов запросов** + - Например: результаты AI классификации, шаблоны визардов + +4. **Pub/Sub события** (реал-тайм) + - Например: `ocr_events:{claim_id}` для SSE + +--- + +## Когда PostgreSQL достаточно + +### ✅ Используй только PostgreSQL, если: + +1. **Данные читаются не так часто** + - Загрузка страницы, переход между шагами + - Пользователь не заметит разницу 1-10 мс + +2. **Важна консистентность** + - Нужна гарантия актуальности данных + - Нет риска рассинхронизации + +3. **Данные уже в PostgreSQL** + - Не нужно дублировать + - Проще архитектура + +--- + +## Для веб-формы: Анализ использования + +### Когда читаются данные заявки: + +1. **При загрузке страницы** (1 раз) + - Пользователь открывает форму + - Можно загрузить из PostgreSQL (10 мс) - не критично + +2. **При переходах между шагами** (редко) + - Пользователь нажимает "Далее" + - Можно загрузить из PostgreSQL (10 мс) - не критично + +3. **При обновлении данных** (редко) + - Пользователь заполняет форму + - Сохраняется в PostgreSQL + +### Вывод: +- ❌ **НЕ критично по скорости** - пользователь не заметит разницу +- ✅ **Важнее консистентность** - данные всегда актуальные +- ✅ **Проще архитектура** - один источник истины + +--- + +## Компромиссное решение + +### Вариант: Кеширование в Redis с инвалидацией + +```python +# При чтении данных заявки +async def get_claim(claim_id: str): + # 1. Пробуем Redis (быстро) + cached = await redis.get(f"claim:{claim_id}") + if cached: + return json.loads(cached) + + # 2. Если нет в кеше - из PostgreSQL + claim = await db.get_claim(claim_id) + + # 3. Сохраняем в кеш на 1 час + await redis.set(f"claim:{claim_id}", json.dumps(claim), ttl=3600) + + return claim + +# При обновлении данных +async def update_claim(claim_id: str, data: dict): + # 1. Обновляем PostgreSQL + await db.update_claim(claim_id, data) + + # 2. Инвалидируем кеш (удаляем из Redis) + await redis.delete(f"claim:{claim_id}") + + # Или обновляем кеш сразу + await redis.set(f"claim:{claim_id}", json.dumps(data), ttl=3600) +``` + +### Плюсы: +- ✅ Быстрый доступ (если есть в кеше) +- ✅ Актуальные данные (инвалидация при обновлении) +- ✅ Fallback на PostgreSQL (если кеш пуст) + +### Минусы: +- ❌ Дополнительная сложность +- ❌ Нужно инвалидировать кеш при каждом обновлении +- ❌ Риск устаревших данных (если забыли инвалидировать) + +--- + +## Рекомендация для веб-формы + +### Вариант 1: Только PostgreSQL (рекомендую) + +**Когда использовать:** +- Данные читаются не так часто (загрузка страницы, переходы) +- Важна консистентность +- Простота архитектуры важнее скорости + +**Плюсы:** +- ✅ Просто (один источник данных) +- ✅ Всегда актуальные данные +- ✅ Нет рассинхронизации +- ✅ PostgreSQL с индексами всё равно быстро (1-10 мс) + +**Минусы:** +- ❌ Чуть медленнее, чем Redis (но не критично) + +--- + +### Вариант 2: PostgreSQL + Redis кеш (если нужна скорость) + +**Когда использовать:** +- Очень частые чтения (каждый запрос) +- Критична скорость (но для веб-формы это не так) + +**Плюсы:** +- ✅ Быстрый доступ (0.1-1 мс) +- ✅ Меньше нагрузки на PostgreSQL + +**Минусы:** +- ❌ Сложнее (нужна инвалидация кеша) +- ❌ Риск устаревших данных +- ❌ Больше кода для поддержки + +--- + +## Итог + +### Для веб-формы: + +**Рекомендую: Только PostgreSQL** + +**Почему:** +1. ⚡ PostgreSQL с индексами быстро (1-10 мс) - пользователь не заметит +2. ✅ Всегда актуальные данные (нет рассинхронизации) +3. ✅ Проще архитектура (один источник истины) +4. ✅ Данные читаются не так часто (не каждый запрос) + +**Redis используй только для:** +- ✅ Pub/Sub (`ocr_events:{claim_id}`) - события в реальном времени +- ✅ Кеширование AI ответов (классификация, визарды) - если нужно +- ✅ SMS коды, временные токены - с TTL + +**НЕ используй Redis для:** +- ❌ Основных данных заявки (есть в PostgreSQL) +- ❌ Документов (есть в PostgreSQL) +- ❌ Ответов визарда (есть в PostgreSQL) + +--- + +## Если всё-таки нужен Redis кеш + +Можно добавить опциональное кеширование: + +```python +# В n8n workflow после claimsave +if (channel === 'web_form' && enable_cache === true) { + // Опционально: кешируем в Redis на 1 час + await redis.set( + `claim:${claim_id}`, + JSON.stringify(claim_data), + ttl=3600 + ); +} +``` + +Но это опционально и не обязательно для веб-формы. + diff --git a/docs/SESSION_LOG_2025-11-19.md b/docs/SESSION_LOG_2025-11-19.md new file mode 100644 index 0000000..97dc266 --- /dev/null +++ b/docs/SESSION_LOG_2025-11-19.md @@ -0,0 +1,72 @@ +# Лог сессии разработки - 19 ноября 2025 + +## Проблема +После верификации телефона не отображается список черновиков, хотя в базе данных есть заявки с `unified_id = 'usr_90599ff2-ac79-4236-b950-0df85395096c'`. + +## Что было сделано + +### 1. Добавлено логирование в frontend +- В `ClaimForm.tsx` добавлены логи для отслеживания: + - Вызов `onNext` с `unified_id` + - Проверка условий для показа черновиков + - Запрос к API `/api/v1/claims/drafts/list` + - Ответ от API + +### 2. Добавлено логирование в backend +- В `claims.py` добавлены логи для отладки запроса черновиков: + - Тестовый COUNT запрос для проверки наличия данных в БД + - Количество найденных строк + - Детали первой строки + +### 3. Проверка данных в БД +- Проверено напрямую через psql: есть 17 заявок для `unified_id = 'usr_90599ff2-ac79-4236-b950-0df85395096c'` +- Из них 3 со статусом `draft` +- Все заявки с каналом `telegram` (не `web_form`) + +### 4. Проблема +- API `/api/v1/claims/drafts/list?unified_id=...` возвращает `{"success":true,"count":0,"drafts":[]}` +- Логи в backend не появляются (logger.info не выводится в консоль) +- SQL запрос напрямую в psql работает и возвращает данные + +## Текущее состояние + +### Frontend +- `unified_id` приходит от n8n и отображается в консоли браузера +- `unified_id` передается в `onNext` callback +- `checkDrafts` вызывается с правильным `unified_id` +- Но API возвращает 0 черновиков + +### Backend +- Endpoint `/api/v1/claims/drafts/list` существует +- Запрос к БД должен работать (проверено через psql) +- Но логи не появляются, что странно + +## Что нужно проверить дальше + +1. **Почему логи не появляются?** + - Проверить настройки логирования в FastAPI + - Возможно, нужно использовать `print()` вместо `logger.info()` + +2. **Почему запрос возвращает 0 результатов?** + - Проверить, что `asyncpg` правильно выполняет запрос + - Возможно, проблема с параметрами запроса + - Проверить, что `unified_id` правильно передается в SQL + +3. **Проверить в браузере:** + - Открыть консоль разработчика + - Проверить логи `🔥 onNext вызван с unified_id:` + - Проверить логи `🔍 Запрос черновиков:` + - Проверить ответ API `🔍 Ответ API черновиков:` + +## Файлы изменены + +1. `frontend/src/pages/ClaimForm.tsx` - добавлено логирование +2. `backend/app/api/claims.py` - добавлено логирование и тестовые запросы + +## Следующие шаги + +1. Проверить логи в браузере после перезагрузки +2. Проверить, что API действительно вызывается +3. Если API вызывается, но возвращает 0 - проверить SQL запрос в backend +4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров + diff --git a/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql new file mode 100644 index 0000000..7e6da9c --- /dev/null +++ b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- SQL запрос для n8n: Поиск/создание пользователя для web_form +-- ============================================================================ +-- Назначение: Найти существующего пользователя по телефону или создать нового +-- в PostgreSQL для канала web_form +-- +-- Параметры: +-- $1 = phone (номер телефона в любом формате: '79991234567', '+79991234567', '89991234567', '9991234567') +-- +-- Возвращает: +-- unified_id - уникальный идентификатор пользователя (usr_...) +-- user_id - внутренний ID пользователя в clpr_users +-- +-- Нормализация телефона: +-- - Убирает все нецифровые символы +-- - Если начинается с 8, заменяет на 7 +-- - Если 10 цифр, добавляет 7 в начало +-- - Результат: 7XXXXXXXXXX (11 цифр) +-- +-- Использование в n8n: +-- 1. PostgreSQL node +-- 2. Query Type: Execute Query +-- 3. Parameters: $1 = {{$json.phone}} +-- ============================================================================ + +WITH normalized_phone AS ( + -- Нормализуем телефон: убираем все нецифры, приводим к формату 7XXXXXXXXXX + SELECT + CASE + WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 11 + AND SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 1 FOR 1) = '8' + THEN '7' || SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 2) + WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 10 + THEN '7' || REGEXP_REPLACE($1, '[^0-9]', '', 'g') + WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 11 + AND SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 1 FOR 1) = '7' + THEN REGEXP_REPLACE($1, '[^0-9]', '', 'g') + ELSE REGEXP_REPLACE($1, '[^0-9]', '', 'g') + END AS phone_normalized +), + +existing AS ( + -- Шаг 1: Ищем существующего пользователя по нормализованному телефону в clpr_users + -- НЕ ищем в clpr_user_accounts для web_form, чтобы не трогать существующие записи других каналов + SELECT u.id AS user_id, u.unified_id + FROM normalized_phone np + JOIN clpr_users u ON u.phone = np.phone_normalized + LIMIT 1 +), + +create_user AS ( + -- Шаг 2: Создаем нового пользователя, если не найден (с нормализованным телефоном) + INSERT INTO clpr_users (unified_id, phone, created_at, updated_at) + SELECT + 'usr_' || gen_random_uuid()::text AS unified_id, + np.phone_normalized AS phone, + now() AS created_at, + now() AS updated_at + FROM normalized_phone np + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id AS user_id, unified_id +), + +final_user AS ( + -- Шаг 3: Объединяем существующего и созданного пользователя + SELECT * FROM existing + UNION ALL + SELECT * FROM create_user +), + +update_unified AS ( + -- Шаг 4: Обновляем unified_id, если он NULL (для старых записей) + UPDATE clpr_users + SET unified_id = COALESCE( + unified_id, + 'usr_' || gen_random_uuid()::text + ), + updated_at = now() + WHERE id = (SELECT user_id FROM final_user LIMIT 1) + AND unified_id IS NULL + RETURNING id AS user_id, unified_id +), + +final_unified_id AS ( + -- Шаг 5: Получаем финальный unified_id (из update или из final_user) + SELECT unified_id FROM update_unified + UNION ALL + SELECT unified_id FROM final_user + WHERE NOT EXISTS (SELECT 1 FROM update_unified) + LIMIT 1 +), + +create_account AS ( + -- Шаг 6: Создаем запись в clpr_user_accounts для web_form только если её еще нет + -- НЕ обновляем существующие записи других каналов (telegram и т.д.) + INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id) + SELECT + (SELECT user_id FROM final_user LIMIT 1) AS user_id, + 'web_form' AS channel, + np.phone_normalized AS channel_user_id -- нормализованный телефон + FROM normalized_phone np + WHERE NOT EXISTS ( + SELECT 1 FROM clpr_user_accounts + WHERE channel = 'web_form' + AND channel_user_id = np.phone_normalized + ) + ON CONFLICT (channel, channel_user_id) DO NOTHING + RETURNING user_id, channel, channel_user_id +) + +-- Шаг 7: Возвращаем unified_id и user_id +SELECT + (SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id, + (SELECT user_id FROM final_user LIMIT 1) AS user_id; diff --git a/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md new file mode 100644 index 0000000..0c91b70 --- /dev/null +++ b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md @@ -0,0 +1,131 @@ +# SQL запрос для n8n: Поиск/создание пользователя web_form + +## Назначение +Поиск существующего пользователя по телефону или создание нового пользователя в PostgreSQL для канала `web_form`. + +## Параметры +- `$1` (или `{{$json.phone}}` в n8n) - номер телефона (например, `79991234567`) + +## Возвращает +- `unified_id` - уникальный идентификатор пользователя (например, `usr_203595f0-b70a-41d3-955f-80b4b2571469`) +- `user_id` - внутренний ID пользователя в таблице `clpr_users` + +## Логика работы + +1. **Поиск существующего пользователя** (`existing`): + - Ищет в `clpr_user_accounts` запись с `channel='web_form'` и `channel_user_id=phone` + - Получает `user_id` и `unified_id` из связанной таблицы `clpr_users` + +2. **Создание нового пользователя** (`create_user`): + - Если пользователь не найден, создает новую запись в `clpr_users` + - Генерирует `unified_id` в формате `usr_{UUID}` + - Сохраняет телефон + +3. **Обновление unified_id** (`update_unified`): + - Если у пользователя `unified_id` = NULL (старые записи), обновляет его + +4. **Создание/обновление аккаунта** (`create_account`): + - Создает запись в `clpr_user_accounts` с `channel='web_form'` и `channel_user_id=phone` + - При конфликте (уже существует) обновляет `user_id` + +5. **Возврат результата**: + - Возвращает `unified_id` и `user_id` + +## Использование в n8n + +### Вариант 1: PostgreSQL node с параметрами +```sql +WITH existing AS ( + SELECT u.id AS user_id, u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = $1 + LIMIT 1 +), +create_user AS ( + INSERT INTO clpr_users (unified_id, phone, created_at, updated_at) + SELECT + 'usr_' || gen_random_uuid()::text, + $1, + now(), + now() + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id AS user_id, unified_id +), +final_user AS ( + SELECT * FROM existing + UNION ALL + SELECT * FROM create_user +), +update_unified AS ( + UPDATE clpr_users + SET unified_id = COALESCE( + unified_id, + 'usr_' || gen_random_uuid()::text + ), + updated_at = now() + WHERE id = (SELECT user_id FROM final_user LIMIT 1) + AND unified_id IS NULL + RETURNING id AS user_id, unified_id +), +final_unified_id AS ( + SELECT unified_id FROM update_unified + UNION ALL + SELECT unified_id FROM final_user + WHERE NOT EXISTS (SELECT 1 FROM update_unified) + LIMIT 1 +), +create_account AS ( + INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id) + SELECT + (SELECT user_id FROM final_user LIMIT 1), + 'web_form', + $1 + ON CONFLICT (channel, channel_user_id) DO UPDATE + SET user_id = EXCLUDED.user_id + RETURNING user_id, channel, channel_user_id +) +SELECT + (SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id, + (SELECT user_id FROM final_user LIMIT 1) AS user_id; +``` + +**Параметры в n8n:** +- `$1` = `{{$json.phone}}` (номер телефона из предыдущего шага) + +### Вариант 2: С подстановкой через n8n expressions +```sql +WITH existing AS ( + SELECT u.id AS user_id, u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = '{{$json.phone}}' + LIMIT 1 +), +-- ... остальной запрос аналогично, но везде $1 заменяется на '{{$json.phone}}' +``` + +## Пример ответа +```json +{ + "unified_id": "usr_203595f0-b70a-41d3-955f-80b4b2571469", + "user_id": 123 +} +``` + +## Важные замечания + +1. **Формат телефона**: Должен быть в формате `79991234567` (11 цифр, начинается с 7) +2. **Уникальность**: `(channel, channel_user_id)` в `clpr_user_accounts` должны быть уникальными +3. **unified_id**: Генерируется автоматически в формате `usr_{UUID}` +4. **Идемпотентность**: Запрос можно выполнять многократно - он вернет существующего пользователя или создаст нового + +## Интеграция с workflow + +После выполнения этого запроса: +1. Сохранить `unified_id` в Redis (например, в ключ `claim:{claim_id}`) +2. Вернуть `unified_id` в ответе frontend (в `result.unified_id`) +3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id` + diff --git a/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql b/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql new file mode 100644 index 0000000..3e4ceb1 --- /dev/null +++ b/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql @@ -0,0 +1,72 @@ +-- ============================================================================ +-- SQL запрос: Получить все заявки пользователя по unified_id +-- ============================================================================ +-- Назначение: Получить список всех заявок (все статусы) для пользователя +-- +-- Параметры: +-- $1 = unified_id (например: 'usr_90599ff2-ac79-4236-b950-0df85395096c') +-- +-- Возвращает: +-- - Все заявки с разными статусами (draft, active, in_work, etc.) +-- - Все каналы (web_form, telegram) +-- - Колонка status_code для фильтрации на фронтенде +-- ============================================================================ + +SELECT + c.id, + c.payload->>'claim_id' as claim_id, + c.session_token, + c.status_code, + c.channel, + c.payload, + c.created_at, + c.updated_at +FROM clpr_claims c +WHERE c.unified_id = $1 +ORDER BY c.updated_at DESC +LIMIT 20; + +-- ============================================================================ +-- Fallback: Поиск по телефону (через clpr_user_accounts и clpr_users) +-- ============================================================================ +-- Если unified_id неизвестен, можно найти через телефон: + +SELECT + c.id, + c.payload->>'claim_id' as claim_id, + c.session_token, + c.status_code, + c.channel, + c.payload, + c.created_at, + c.updated_at +FROM clpr_claims c +WHERE c.unified_id = ( + SELECT u.unified_id + FROM clpr_user_accounts ua + JOIN clpr_users u ON u.id = ua.user_id + WHERE ua.channel = 'web_form' + AND ua.channel_user_id = $1 -- phone (нормализованный) + LIMIT 1 +) +ORDER BY c.updated_at DESC +LIMIT 20; + +-- ============================================================================ +-- Fallback: Поиск по session_token +-- ============================================================================ + +SELECT + c.id, + c.payload->>'claim_id' as claim_id, + c.session_token, + c.status_code, + c.channel, + c.payload, + c.created_at, + c.updated_at +FROM clpr_claims c +WHERE c.session_token = $1 -- session_id +ORDER BY c.updated_at DESC +LIMIT 20; + diff --git a/docs/WIZARD_API_ALTERNATIVES.md b/docs/WIZARD_API_ALTERNATIVES.md new file mode 100644 index 0000000..c7dd2e4 --- /dev/null +++ b/docs/WIZARD_API_ALTERNATIVES.md @@ -0,0 +1,261 @@ +# Готовые API и решения для построения визардов + +**Дата:** 2025-01-XX +**Цель:** Найти готовые API/сервисы для генерации структуры визарда + +--- + +## 🔍 Результаты поиска + +### ❌ Готовых API для генерации структуры визарда НЕТ + +**Что найдено:** +- Библиотеки для **рендеринга** визардов на фронтенде (React, Vue, JS) +- Сервисы для **создания форм** программно (Form.io, Typeform) +- Но **НЕТ** API, который принимает описание проблемы и возвращает структуру визарда + +--- + +## 📦 Найденные решения (для рендеринга) + +### 1. **React-jsonschema-form** / **@rjsf/core** +**Что это:** Библиотека для рендеринга форм из JSON Schema + +**Плюсы:** +- ✅ Готовая библиотека для React +- ✅ Поддержка валидации +- ✅ Условная логика (show/hide полей) +- ✅ Кастомизация виджетов + +**Минусы:** +- ❌ Нужно самому генерировать JSON Schema +- ❌ Не решает проблему генерации структуры + +**Использование:** +```typescript +import Form from "@rjsf/core"; + +const schema = { + type: "object", + properties: { + item: { type: "string", title: "Название товара" }, + purchase_date: { type: "string", format: "date", title: "Дата покупки" } + } +}; + +
+``` + +**Вывод:** Полезно для рендеринга, но структуру всё равно нужно генерировать самим. + +--- + +### 2. **Form.io** (платный сервис) +**Что это:** Платформа для создания форм с API + +**Плюсы:** +- ✅ Есть API для создания форм программно +- ✅ Поддержка условной логики +- ✅ Готовые компоненты + +**Минусы:** +- ❌ Платный (от $99/месяц) +- ❌ Нет генерации структуры из описания +- ❌ Нужно самому создавать формы через API + +**API пример:** +```javascript +// Создание формы через API +POST https://api.form.io/v1/form +{ + "title": "Claim Form", + "components": [ + { + "type": "textfield", + "key": "item", + "label": "Название товара" + } + ] +} +``` + +**Вывод:** Дорого и не решает задачу генерации структуры. + +--- + +### 3. **Typeform API** +**Что это:** API для создания Typeform форм + +**Плюсы:** +- ✅ Есть API +- ✅ Красивый UI + +**Минусы:** +- ❌ Платный (от $25/месяц) +- ❌ Нет генерации структуры +- ❌ Своя экосистема (не встраивается в наш проект) + +**Вывод:** Не подходит для нашей задачи. + +--- + +### 4. **JSON Schema Form Generators** + +**Библиотеки:** +- `react-jsonschema-form` +- `@rjsf/core` +- `formik` + `yup` (схемы валидации) + +**Плюсы:** +- ✅ Стандарт JSON Schema +- ✅ Гибкость в описании форм +- ✅ Валидация из коробки + +**Минусы:** +- ❌ Нужно самому генерировать схему +- ❌ Не решает задачу генерации структуры + +**Пример JSON Schema:** +```json +{ + "type": "object", + "properties": { + "item": { + "type": "string", + "title": "Название товара", + "required": true + }, + "purchase_date": { + "type": "string", + "format": "date", + "title": "Дата покупки" + }, + "documents_available": { + "type": "array", + "title": "Какие документы есть?", + "items": { + "type": "string", + "enum": ["receipt", "contract", "photos"] + }, + "uniqueItems": true + } + } +} +``` + +**Вывод:** Можно использовать для рендеринга, но генерацию структуры нужно делать самим. + +--- + +## 🎯 Вывод: Нет готового решения + +### Почему нет готового API? + +1. **Специфичность задачи:** Генерация визарда на основе описания проблемы - это очень специфичная задача для юридической сферы +2. **Контекст:** Нужно понимать контекст дела, типы документов, требования законодательства +3. **Кастомизация:** Каждый проект имеет свои требования к структуре визарда + +### Что есть: +- ✅ Библиотеки для **рендеринга** форм (React, Vue, JS) +- ✅ Сервисы для **создания** форм программно (Form.io, Typeform) +- ❌ API для **генерации структуры** визарда из описания - **НЕТ** + +--- + +## 💡 Рекомендации + +### Вариант 1: Свой генератор (рекомендуется) + +**Архитектура:** +``` +Описание → ИИ (классификация) → Бэкенд (шаблоны) → JSON Schema → Фронтенд (рендеринг) +``` + +**Плюсы:** +- ✅ Полный контроль +- ✅ Оптимизация под наши нужды +- ✅ Нет зависимости от внешних сервисов +- ✅ Бесплатно + +**Реализация:** +1. ИИ классифицирует случай +2. Бэкенд выбирает шаблон +3. Генерируем JSON Schema или наш формат +4. Фронтенд рендерит через `react-jsonschema-form` или свой компонент + +--- + +### Вариант 2: Гибридный подход + +**Использовать готовые библиотеки для рендеринга:** +- `@rjsf/core` для рендеринга форм +- Свой генератор JSON Schema в бэкенде + +**Плюсы:** +- ✅ Готовая валидация и UI +- ✅ Меньше кода на фронтенде +- ✅ Стандартный формат (JSON Schema) + +**Минусы:** +- ❌ Нужно адаптировать под наш формат визарда +- ❌ Может быть избыточно + +--- + +### Вариант 3: Использовать Form.io (если бюджет есть) + +**Если готовы платить $99+/месяц:** +- Использовать Form.io API для создания форм +- Но генерацию структуры всё равно делать самим через ИИ + +**Вывод:** Не стоит того, так как генерацию структуры всё равно нужно делать самим. + +--- + +## 🚀 Итоговая рекомендация + +### Использовать свой генератор + готовые библиотеки для рендеринга + +**Стек:** +1. **Генерация структуры:** Свой бэкенд (ИИ + шаблоны) +2. **Формат:** JSON Schema или наш формат +3. **Рендеринг:** `@rjsf/core` или свой компонент + +**Почему:** +- ✅ Нет готовых API для генерации структуры +- ✅ Готовые библиотеки для рендеринга есть +- ✅ Полный контроль над процессом +- ✅ Оптимизация под наши нужды + +--- + +## 📚 Полезные ссылки + +### Библиотеки для рендеринга: +- [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form) +- [@rjsf/core](https://github.com/rjsf-team/react-jsonschema-form) +- [Formik](https://formik.org/) - управление формами в React +- [React Hook Form](https://react-hook-form.com/) - производительные формы + +### JSON Schema: +- [JSON Schema Specification](https://json-schema.org/) +- [JSON Schema Examples](https://json-schema.org/learn/examples-guide) + +### Сервисы (для справки): +- [Form.io](https://form.io/) - платный, от $99/мес +- [Typeform API](https://developer.typeform.com/) - платный, от $25/мес + +--- + +## ✅ Вывод + +**Готовых API для генерации структуры визарда нет.** +**Нужно делать свой генератор**, но можно использовать готовые библиотеки для рендеринга. + +**Рекомендуемый подход:** +1. ИИ классифицирует случай (5-10 сек) +2. Бэкенд генерирует структуру из шаблонов (0.1 сек) +3. Фронтенд рендерит через `@rjsf/core` или свой компонент + +**Это оптимальный баланс скорости, контроля и стоимости.** + diff --git a/docs/WIZARD_CACHING_STRATEGY.md b/docs/WIZARD_CACHING_STRATEGY.md new file mode 100644 index 0000000..f54847b --- /dev/null +++ b/docs/WIZARD_CACHING_STRATEGY.md @@ -0,0 +1,448 @@ +# Стратегия кеширования визардов + +**Дата:** 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 сек) + diff --git a/docs/WIZARD_OPTIMIZATION.md b/docs/WIZARD_OPTIMIZATION.md new file mode 100644 index 0000000..94685a7 --- /dev/null +++ b/docs/WIZARD_OPTIMIZATION.md @@ -0,0 +1,55 @@ +# Оптимизация генерации визарда + +## Проблема +AI Agent генерирует визард за ~40 секунд, что слишком долго для UX. + +## Варианты оптимизации + +### 1. Сократить промпт (приоритет: ВЫСОКИЙ) +Текущий промпт ~2000+ символов. Можно сократить до ~800-1000, убрав: +- Повторения инструкций +- Детальные объяснения форматов (оставить только примеры) +- Лишние поля в ответе (если не используются) + +**Ожидаемый эффект:** -15-20 секунд + +### 2. Использовать более быструю модель +- `gpt-4o-mini` вместо `gpt-4.1-mini` (быстрее в 2-3 раза) +- Или `gpt-3.5-turbo` для простых случаев + +**Ожидаемый эффект:** -20-25 секунд + +### 3. Streaming ответа +Начать обрабатывать JSON по частям, как только начинают приходить данные. + +**Ожидаемый эффект:** UX улучшится (показываем прогресс), но общее время не изменится + +### 4. Кэширование для похожих запросов +Кэшировать результаты для похожих описаний (по хэшу первых 200 символов). + +**Ожидаемый эффект:** -35-40 секунд для повторных запросов + +### 5. Упростить схему ответа +Убрать неиспользуемые поля: +- `coverage_report.questions` (если не используется) +- `risks`, `deadlines` (если не критично) +- Детальные `rationale` для каждого вопроса + +**Ожидаемый эффект:** -5-10 секунд + +### 6. Разбить на этапы +1. Быстро генерировать базовый план (5-7 вопросов, список документов) - 10-15 сек +2. Параллельно/асинхронно дорабатывать prefill и coverage_report + +**Ожидаемый эффект:** UX улучшится (показываем план быстрее) + +## Рекомендуемый подход + +**Комбинация 1 + 2 + 5:** +- Сократить промпт до минимума +- Переключиться на `gpt-4o-mini` +- Убрать неиспользуемые поля + +**Ожидаемый результат:** 40 сек → 10-15 сек + + diff --git a/docs/WIZARD_OPTIMIZATION_ANALYSIS.md b/docs/WIZARD_OPTIMIZATION_ANALYSIS.md new file mode 100644 index 0000000..f35c239 --- /dev/null +++ b/docs/WIZARD_OPTIMIZATION_ANALYSIS.md @@ -0,0 +1,264 @@ +# Анализ оптимизации генерации визарда + +**Дата:** 2025-01-XX +**Текущее время генерации:** ~30-40 секунд +**Цель:** Сократить до 5-15 секунд + +--- + +## 🎯 Вариант 1: Двухэтапный подход (твоя идея) + +### Концепция: +1. **ИИ только классифицирует** случай и выдаёт список нужных документов/полей +2. **Бэкенд строит визард** по шаблонам на основе классификации + +### Архитектура: + +``` +Описание → ИИ (классификация) → Бэкенд (шаблоны) → Визард +``` + +**ИИ возвращает:** +```json +{ + "case_type": "product_defect", // или "service_issue", "delay", "conflict" + "required_fields": ["item", "purchase_date", "purchase_amount", "warranty_info"], + "required_documents": ["contract", "payment", "photos"], + "optional_documents": ["correspondence", "diagnosis"], + "extracted_data": { + "item": "Смартфон", + "seller": "DNS", + "purchase_date": "2024-12-15" + } +} +``` + +**Бэкенд использует шаблоны:** +```python +WIZARD_TEMPLATES = { + "product_defect": { + "questions": [ + {"name": "item", "label": "Как называется товар?", ...}, + {"name": "purchase_date", "label": "Когда купили?", "control": "input[type=\"date\"]", ...}, + {"name": "purchase_amount", "label": "Сколько стоил?", ...}, + {"name": "warranty_info", "label": "Есть ли гарантия?", ...}, + {"name": "problem_description", "label": "Опишите проблему", "control": "textarea", ...}, + {"name": "documents_available", "label": "Какие документы есть?", "control": "input[type=\"checkbox\"]", ...}, + {"name": "desired_outcome", "label": "Что хотите получить?", "control": "input[type=\"radio\"]", ...} + ], + "documents": [ + {"id": "contract", "name": "Договор", "required": true, ...}, + {"id": "payment", "name": "Чек", "required": true, ...}, + {"id": "photos", "name": "Фото дефекта", "required": true, ...} + ] + }, + "service_issue": { ... }, + "delay": { ... }, + "conflict": { ... } +} +``` + +### Плюсы: +✅ **Скорость:** ИИ только классифицирует (5-10 сек) + бэкенд мгновенно (0.1 сек) = **5-10 сек всего** +✅ **Предсказуемость:** Визарды всегда структурированы одинаково +✅ **Контроль:** Легко менять вопросы/документы без изменения промпта +✅ **Кеширование:** Можно кешировать шаблоны в памяти +✅ **Тестирование:** Легко тестировать шаблоны отдельно от ИИ + +### Минусы: +❌ **Гибкость:** Сложные/уникальные случаи могут не попасть в шаблоны +❌ **Разработка:** Нужно создать и поддерживать библиотеку шаблонов +❌ **Классификация:** ИИ должен точно определить тип дела + +### Реализация: +1. Создать `wizard_templates.py` в бэкенде с шаблонами +2. Упростить промпт для ИИ (только классификация + список полей/документов) +3. Создать `WizardBuilder` сервис, который собирает визард из шаблона +4. Обновить n8n workflow для упрощённого ответа + +**Ожидаемое время:** 5-10 секунд + +--- + +## 🚀 Вариант 2: Гибридный подход + +### Концепция: +1. **ИИ классифицирует** и выдаёт список полей/документов (быстро) +2. **Бэкенд использует шаблоны** для стандартных случаев +3. **ИИ достраивает** уникальные вопросы для сложных случаев (опционально) + +### Плюсы: +✅ **Баланс:** Скорость + гибкость +✅ **Fallback:** Если шаблон не подходит, ИИ достраивает + +### Минусы: +❌ **Сложность:** Нужно решать, когда использовать шаблон, а когда ИИ + +**Ожидаемое время:** 5-15 секунд (зависит от сложности) + +--- + +## ⚡ Вариант 3: Кеширование готовых визардов + +### Концепция: +1. **Кешировать** готовые визарды по типу дела +2. **ИИ только извлекает** данные из описания для автозаполнения + +### Плюсы: +✅ **Максимальная скорость:** 1-2 секунды для стандартных случаев +✅ **Простота:** Минимальные изменения в коде + +### Минусы: +❌ **Ограниченность:** Только для типовых случаев +❌ **Хранение:** Нужно хранить кеш визардов + +**Ожидаемое время:** 1-2 секунды (кеш) или 30 сек (первый раз) + +--- + +## 🔥 Вариант 4: Упрощение промпта + быстрая модель + +### Концепция: +1. **Сократить промпт** до минимума (убрать примеры, оставить только структуру) +2. **Использовать `gpt-4o-mini`** вместо `gpt-4.1-mini` +3. **Убрать неиспользуемые поля** из ответа + +### Плюсы: +✅ **Простота:** Минимальные изменения +✅ **Скорость:** 10-15 секунд + +### Минусы: +❌ **Качество:** Может снизиться качество визардов +❌ **Ограничение:** Всё ещё зависит от скорости ИИ + +**Ожидаемое время:** 10-15 секунд + +--- + +## 🎨 Вариант 5: Предгенерированные шаблоны + ИИ только для извлечения + +### Концепция: +1. **Все визарды предгенерированы** в бэкенде (шаблоны) +2. **ИИ только извлекает** данные из описания для автозаполнения +3. **Бэкенд выбирает** подходящий шаблон на основе ключевых слов + +### Плюсы: +✅ **Максимальная скорость:** 1-3 секунды +✅ **Предсказуемость:** Всегда одинаковые визарды + +### Минусы: +❌ **Ограниченность:** Только для типовых случаев +❌ **Классификация:** Нужна простая классификация (можно без ИИ) + +**Ожидаемое время:** 1-3 секунды + +--- + +## 📊 Сравнение вариантов + +| Вариант | Время | Гибкость | Сложность | Рекомендация | +|---------|------|----------|-----------|--------------| +| **1. Двухэтапный** | 5-10 сек | ⭐⭐⭐⭐ | ⭐⭐⭐ | ✅ **Лучший баланс** | +| **2. Гибридный** | 5-15 сек | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Хорошо для сложных случаев | +| **3. Кеширование** | 1-2 сек | ⭐⭐ | ⭐⭐ | ✅ Для типовых случаев | +| **4. Упрощение** | 10-15 сек | ⭐⭐⭐⭐ | ⭐ | ✅ Быстрая реализация | +| **5. Предгенерированные** | 1-3 сек | ⭐⭐ | ⭐⭐ | ✅ Для простых случаев | + +--- + +## 🎯 Рекомендация + +### Для начала: **Вариант 1 (Двухэтапный)** + +**Почему:** +1. **Оптимальный баланс** скорости и гибкости +2. **Масштабируемость:** Легко добавлять новые типы дел +3. **Контроль:** Все визарды структурированы и предсказуемы +4. **Тестируемость:** Шаблоны легко тестировать + +### План реализации: + +#### Этап 1: Классификация (ИИ) +```python +# Упрощённый промпт для ИИ +""" +Проанализируй описание проблемы и определи: +1. Тип дела (product_defect, service_issue, delay, conflict, other) +2. Какие поля нужно собрать (item, purchase_date, purchase_amount, warranty_info, ...) +3. Какие документы нужны (contract, payment, photos, correspondence, ...) +4. Что уже известно из описания (для автозаполнения) + +Верни JSON: +{ + "case_type": "product_defect", + "required_fields": ["item", "purchase_date", "purchase_amount", "warranty_info"], + "required_documents": ["contract", "payment", "photos"], + "optional_documents": ["correspondence"], + "extracted_data": { + "item": "Смартфон", + "seller": "DNS" + } +} +""" +``` + +#### Этап 2: Шаблоны (Бэкенд) +```python +# ticket_form/backend/app/services/wizard_builder.py +class WizardBuilder: + TEMPLATES = { + "product_defect": { + "questions": [...], + "documents": [...] + }, + "service_issue": {...}, + "delay": {...}, + "conflict": {...} + } + + def build_wizard(self, classification: dict) -> dict: + template = self.TEMPLATES[classification["case_type"]] + # Собираем визард из шаблона + # Добавляем автозаполнение из extracted_data + return wizard_plan +``` + +#### Этап 3: Интеграция +- Обновить n8n workflow для упрощённого ответа +- Создать эндпоинт `/api/v1/wizard/build` в бэкенде +- Обновить фронтенд для работы с новым форматом + +--- + +## 💡 Дополнительные идеи + +### 1. Параллельная обработка +- ИИ классифицирует +- Параллельно бэкенд готовит шаблоны +- Собираем результат + +### 2. Инкрементальная генерация +- Сначала показываем базовые вопросы (из шаблона) +- Потом достраиваем уникальные (если нужно) + +### 3. Умное кеширование +- Кешировать классификации по хешу описания +- Кешировать готовые визарды по типу дела + +### 4. Предзагрузка шаблонов +- Загружать шаблоны в память при старте +- Не обращаться к БД/файлам каждый раз + +--- + +## 🚀 Следующие шаги + +1. **Создать шаблоны** для основных типов дел (5-7 типов) +2. **Упростить промпт** для классификации +3. **Реализовать WizardBuilder** в бэкенде +4. **Обновить n8n workflow** +5. **Протестировать** на реальных случаях +6. **Измерить скорость** и сравнить с текущей + +**Ожидаемый результат:** 5-10 секунд вместо 30-40 секунд + diff --git a/docs/WIZARD_SPEEDUP_GUIDE.md b/docs/WIZARD_SPEEDUP_GUIDE.md new file mode 100644 index 0000000..75cfa92 --- /dev/null +++ b/docs/WIZARD_SPEEDUP_GUIDE.md @@ -0,0 +1,58 @@ +# Как ускорить генерацию визарда с 40 до 10-15 секунд + +## Быстрое решение (рекомендуется) + +### Шаг 1: Заменить модель +В ноде `OpenAI Chat Model3`: +- **Было:** `gpt-4.1-mini-2025-04-14` +- **Стало:** `gpt-4o-mini` + +**Эффект:** -20-25 секунд (40 сек → 15-20 сек) + +### Шаг 2: Сократить промпт +Заменить промпт в `AI Agent3` на оптимизированную версию из `optimized_wizard_prompt.txt` + +**Эффект:** -10-15 секунд (15-20 сек → 10-15 сек) + +### Шаг 3: Добавить настройки модели +В `OpenAI Chat Model3` → `Options`: +- `temperature`: `0.3` (меньше креативности = быстрее) +- `maxTokens`: `2000` (ограничить длину ответа) + +**Эффект:** -2-5 секунд + +## Итого +**40 секунд → 10-15 секунд** (ускорение в 2.5-4 раза) + +## Дополнительные оптимизации (опционально) + +### Кэширование похожих запросов +Добавить ноду перед AI Agent: +1. Вычислить хэш первых 200 символов `chatInput` +2. Проверить Redis: есть ли кэш для этого хэша +3. Если есть — вернуть из кэша (0 сек) +4. Если нет — запустить AI Agent и сохранить результат в кэш на 1 час + +**Эффект:** Для повторных/похожих запросов — мгновенный ответ + +### Streaming (для UX) +Если n8n поддерживает streaming: +- Начать обрабатывать JSON по частям +- Показывать прогресс пользователю + +**Эффект:** UX улучшится, но общее время не изменится + +## Проверка результата +После применения оптимизаций: +1. Откройте форму +2. Введите описание проблемы +3. Засеките время до появления плана вопросов +4. Должно быть 10-15 секунд вместо 40 + +## Откат изменений +Если что-то пошло не так: +1. Верните модель `gpt-4.1-mini-2025-04-14` +2. Верните старый промпт +3. Уберите настройки `temperature` и `maxTokens` + + diff --git a/docs/WORKFLOW_ANALYSIS.md b/docs/WORKFLOW_ANALYSIS.md new file mode 100644 index 0000000..dd90426 --- /dev/null +++ b/docs/WORKFLOW_ANALYSIS.md @@ -0,0 +1,211 @@ +# Анализ workflow 8ZVMTsuH7Cmw7snw и предложения + +## Текущая структура + +### Основные ноды PostgreSQL: + +1. **`claimsave`** (строка 190-210) + - Использует обновленный SQL с `$2::text` (строка claim_id) + - **ПРОБЛЕМА**: SQL запрос не использует `claim_final` CTE, который я добавил в исправленной версии + - Это основная нода для сохранения данных визарда + +2. **`claimsave_final`** (строка 428-450) + - Использует другой SQL запрос с `$2::uuid` + - Используется после конвертации файлов в PDF + - **ПРОБЛЕМА**: Ожидает UUID, но может получать строку + +3. **`claimsave1`** (строка 634-655) + - Использует старый SQL запрос с `$2::uuid` + - **ПРОБЛЕМА**: Не работает со строковым claim_id + +## Проблемы + +### 1. SQL запрос в `claimsave` неполный + +Текущий SQL в ноде `claimsave`: +- ✅ Использует `$2::text` (правильно) +- ✅ Имеет `claim_lookup` и `claim_created` CTE +- ❌ **НЕ использует `claim_final` CTE** (который я добавил в исправленной версии) +- ❌ Использует `claim_lookup.claim_uuid` напрямую, что может не работать, если запись была создана в `claim_created` + +### 2. Несоответствие типов данных + +- `claimsave` ожидает строку (`$2::text`) +- `claimsave_final` ожидает UUID (`$2::uuid`) +- `claimsave1` ожидает UUID (`$2::uuid`) + +Но везде передается `claim_id` как строка `"CLM-2025-11-18-GEQ3KL"`. + +### 3. Проблема с `existing` CTE + +В текущем SQL запросе `existing` может не найти запись, если она была создана в `claim_created`, потому что: +- `claim_lookup` выполняется ДО `claim_created` +- `existing` использует `claim_lookup.claim_uuid`, но запись может быть создана в `claim_created` + +## Решения + +### Решение 1: Обновить SQL в ноде `claimsave` + +Заменить SQL запрос на исправленную версию из `FIXED_SQL_QUERY.md`: + +**Ключевые изменения:** +1. Добавить `claim_final` CTE для получения правильного UUID +2. Использовать `claim_final.claim_uuid` вместо `claim_lookup.claim_uuid` +3. Исправить `old` CTE, чтобы он всегда возвращал строку + +### Решение 2: Унифицировать типы данных + +**Вариант A**: Все ноды используют строку `claim_id` +- Изменить `claimsave_final` и `claimsave1` на `$2::text` +- Добавить логику поиска UUID по строке `claim_id` + +**Вариант B**: Все ноды используют UUID +- Перед SQL запросами добавить Code Node, который: + - Находит запись в `clpr_claims` по `payload->>'claim_id'` + - Извлекает её `id` (UUID) + - Передает UUID в SQL запрос + +**Рекомендую Вариант A** (использовать строку везде), т.к.: +- Проще реализовать +- Меньше изменений в workflow +- `claim_id` в формате `CLM-YYYY-MM-DD-XXXXXX` - это основной идентификатор + +### Решение 3: Упростить логику + +Можно упростить SQL запрос, убрав сложную логику слияния: + +```sql +WITH partial AS ( + SELECT $1::jsonb AS p, $2::text AS claim_id_str +), + +-- Находим или создаем запись +claim_final AS ( + SELECT + COALESCE( + (SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1), + gen_random_uuid() + ) AS claim_uuid + FROM partial +), + +-- Создаем запись, если её нет +claim_created AS ( + INSERT INTO clpr_claims ( + id, session_token, channel, type_code, status_code, payload, created_at, updated_at, expires_at + ) + SELECT + claim_final.claim_uuid, + COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text), + 'web_form', + COALESCE(partial.p->>'type_code', 'consumer'), + 'draft', + jsonb_build_object( + 'claim_id', partial.claim_id_str, + 'answers', COALESCE(partial.p->'answers', '{}'::jsonb), + 'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb), + 'wizard_plan', partial.p->'wizard_plan', + 'wizard_answers', partial.p->'wizard_answers', + 'form_data', partial.p + ), + now(), now(), now() + interval '14 days' + FROM partial, claim_final + WHERE NOT EXISTS (SELECT 1 FROM clpr_claims WHERE id = claim_final.claim_uuid) + ON CONFLICT (id) DO NOTHING + RETURNING id +), + +-- Сохраняем документы +inserted_docs AS ( + INSERT INTO clpr_claim_documents + (claim_id, field_name, file_id, uploaded_at, file_name, original_file_name) + SELECT + claim_final.claim_uuid::text, + doc.field_name, doc.file_id, + (doc.uploaded_at)::timestamptz, + doc.file_name, doc.original_file_name + FROM partial, claim_final + CROSS JOIN LATERAL jsonb_to_recordset( + COALESCE(partial.p->'documents_meta','[]'::jsonb) + ) AS doc(field_name text, file_id text, file_name text, original_file_name text, uploaded_at text) + ON CONFLICT (claim_id, field_name) DO UPDATE + SET file_id = EXCLUDED.file_id, + uploaded_at = EXCLUDED.uploaded_at, + file_name = EXCLUDED.file_name, + original_file_name = EXCLUDED.original_file_name + RETURNING id, claim_id, field_name, file_id +), + +-- Обновляем запись (простое слияние) +upd AS ( + UPDATE clpr_claims c + SET + payload = COALESCE(c.payload, '{}'::jsonb) || partial.p, + status_code = CASE + WHEN (partial.p->'answers'->>'docs_exist' = 'true') THEN 'in_work' + ELSE COALESCE(c.status_code, 'draft') + END, + updated_at = now(), + expires_at = now() + interval '14 days' + FROM partial, claim_final + WHERE c.id = claim_final.claim_uuid + RETURNING c.id, c.status_code, c.payload +) + +SELECT + (SELECT jsonb_build_object( + 'claim_id', u.id::text, + 'claim_id_str', (u.payload->>'claim_id'), + 'status_code', u.status_code, + 'payload', u.payload + ) FROM upd u LIMIT 1) AS claim, + (SELECT jsonb_agg(jsonb_build_object( + 'id', id, + 'field_name', field_name, + 'file_id', file_id + )) FROM inserted_docs) AS documents; +``` + +## Рекомендации + +### Немедленные действия: + +1. **Обновить SQL в ноде `claimsave`** + - Заменить на исправленную версию из `FIXED_SQL_QUERY.md` + - Или использовать упрощенную версию выше + +2. **Проверить параметры** + - Убедиться, что `queryReplacement` правильный: `={{ $json.payload_partial_json }}, {{ $json.claim_id }}` + - `payload_partial_json` должен быть JSON объектом + - `claim_id` должен быть строкой + +3. **Протестировать** + - Запустить workflow с тестовыми данными + - Проверить, что `claim` не возвращает `null` + - Проверить, что документы сохраняются правильно + +### Долгосрочные улучшения: + +1. **Унифицировать все SQL запросы** + - Привести `claimsave_final` и `claimsave1` к единому формату + - Использовать строковый `claim_id` везде + +2. **Добавить обработку ошибок** + - Проверять результат SQL запроса + - Логировать ошибки + - Возвращать понятные сообщения об ошибках + +3. **Оптимизировать workflow** + - Упростить логику слияния payload + - Использовать транзакции для атомарности операций + +## Готовый SQL для копирования + +Полный исправленный SQL запрос находится в файле `FIXED_SQL_QUERY.md`. + +Основные изменения: +- ✅ Использует `claim_final` CTE для правильного получения UUID +- ✅ `old` CTE всегда возвращает строку (даже если запись не найдена) +- ✅ Все подзапросы используют `LIMIT 1` для гарантии одной строки +- ✅ Правильное слияние `answers` и `documents_meta` + diff --git a/docs/WORKFLOW_OCR_ANALYSIS.md b/docs/WORKFLOW_OCR_ANALYSIS.md new file mode 100644 index 0000000..47a68ee --- /dev/null +++ b/docs/WORKFLOW_OCR_ANALYSIS.md @@ -0,0 +1,218 @@ +# Анализ workflow: шаг ?? ocr_jobs_clime (1IKe2PccqXLkD2KR) + +## Общая информация + +**ID:** `1IKe2PccqXLkD2KR` +**Название:** `шаг ?? ocr_jobs_clime` +**Статус:** Active +**Триггер:** Redis Pub/Sub на канале `clpr:ocr:jobs` + +--- + +## Архитектура workflow + +### 1. Триггер: Redis Pub/Sub + +**Канал:** `clpr:ocr:jobs` + +**Формат сообщения:** +```json +{ + "message": { + "job_id": "...", + "claim_id": "uuid", // UUID из clpr_claims.id + "prefix": "clpr_", + "telegram_id": "...", + "session_token": "...", + "channel": "telegram|web_form", + "created_at": "..." + } +} +``` + +--- + +### 2. Основной поток обработки + +#### Шаг 1: Получение файлов из PostgreSQL + +**Нода:** `Execute a SQL query` + +**Запрос:** +```sql +-- Получает документы из clpr_claim_documents по claim_id (UUID) +SELECT + cd.id AS claim_document_id, + cd.claim_id::text AS claim_id, + cd.field_name, + cd.file_id, + cd.uploaded_at, + m.file_url, + m.file_name, + m.original_file_name, + -- ... описание из payload +FROM clpr_claim_documents cd +JOIN clpr_claims c ON c.id = cd.claim_id::uuid +-- ... метаданные из payload.documents_meta +``` + +**Важно:** Использует `claim_id` как **UUID** (из `clpr_claims.id`) + +--- + +#### Шаг 2: Загрузка файла в S3 + +**Нода:** `Upload_OCR_File` + +**Путь:** `temp/{telegram_id}/{file_name}` + +--- + +#### Шаг 3: OCR обработка + +**Нода:** `HTTP Request2` → `http://147.45.146.17:8001/analyze-file` + +**Параметры:** +- `file_url` - URL файла из S3 +- `file_name` - имя файла + +--- + +#### Шаг 4: Обработка результатов OCR + +**Нода:** `Edit Fields6` + +**Извлекает:** +- `ocr_text` - текст OCR +- `vision_reason` - причина отправки в vision +- `nsfw` - флаг NSFW +- `page` - номер страницы +- `file_id` - ID документа из `claim_document_id` + +--- + +#### Шаг 5: Сохранение результатов + +**Нода:** `give_data1` (SQL запрос) + +**Запрос:** Получает полные данные заявки: +- `claim` - данные заявки +- `documents` - документы +- `ocr_pages` - страницы OCR +- `vision_docs` - результаты vision +- `combined_docs` - объединенные документы + +**Использует:** `claim_id` как **UUID** (из `clpr_claims.id`) + +--- + +#### Шаг 6: Публикация событий + +**Нода:** `Redis Publish (SendMessage)2` + +**Канал:** `events:SendMessage` + +**Сообщение:** JSON с результатами обработки + +--- + +## Интеграция с веб-формой + +### Текущая ситуация: + +1. **Веб-форма использует:** + - `claim_id` в формате `CLM-YYYY-MM-DD-XXXXXX` (строка) + - Сохраняет в `clpr_claims.payload->>'claim_id'` + +2. **SQL запросы возвращают:** + - `claim.claim_id` = **UUID** в виде строки (из `clpr_claims.id`) + - `claim.claim_id_str` = строка `CLM-...` (из `payload->>'claim_id'`) + +3. **Workflow ожидает:** + - `claim_id` как **UUID** (из `clpr_claims.id`) + - Использует `clpr_claims.id` для поиска + +### Решение: + +✅ **Ничего менять не нужно!** + +При публикации в Redis канал `clpr:ocr:jobs` используем `claim.claim_id` (UUID), который возвращается из SQL запроса. + +### Пример публикации в Redis: + +```javascript +// После claimsave или claimsave_final +const claim = $json.claim; + +// Публикуем в Redis канал clpr:ocr:jobs +await redis.publish('clpr:ocr:jobs', JSON.stringify({ + job_id: generateJobId(), + claim_id: claim.claim_id, // UUID из clpr_claims.id + prefix: 'clpr_', + channel: 'web_form', + session_token: claim.payload?.session_token, + created_at: new Date().toISOString() +})); +``` + +**Важно:** Используем `claim.claim_id` (UUID), а не `claim.claim_id_str` (CLM-...) + +--- + +## Рекомендации + +### Для интеграции с веб-формой: + +✅ **Ничего менять не нужно!** + +1. **SQL запросы уже возвращают UUID:** + - `claim.claim_id` = UUID из `clpr_claims.id` + - `claim.claim_id_str` = строка CLM-... (для отображения) + +2. **Публикация в Redis:** + - Используем `claim.claim_id` (UUID) при публикации в `clpr:ocr:jobs` + - Workflow будет работать без изменений + +3. **Workflow:** + - Остается без изменений + - Принимает UUID и работает как обычно + +--- + +## Текущие SQL запросы в workflow + +### Запрос 1: Получение файлов (строка 485) + +```sql +-- Использует: WHERE id = $1 (UUID) +FROM clpr_claims WHERE id = $1 +``` + +✅ **Работает как есть** - получаем UUID из `claim.claim_id` + +### Запрос 2: Получение полных данных (строка 1020) + +```sql +-- Использует: WHERE id = $1 (UUID) +FROM clpr_claims WHERE id = $1 +``` + +✅ **Работает как есть** - получаем UUID из `claim.claim_id` + +--- + +## Итог + +✅ **Ничего менять не нужно!** + +**Как это работает:** +1. Веб-форма сохраняет данные в PostgreSQL через `claimsave` +2. SQL запрос возвращает `claim.claim_id` (UUID из `clpr_claims.id`) +3. При публикации в Redis используем `claim.claim_id` (UUID) +4. Workflow получает UUID и работает без изменений + +**Преимущества:** +- ✅ Workflow остается без изменений +- ✅ Нет необходимости в дополнительных преобразованиях +- ✅ Единый формат (UUID) для всех систем + diff --git a/docs/optimized_ai_agent_node.json b/docs/optimized_ai_agent_node.json new file mode 100644 index 0000000..b42001e --- /dev/null +++ b/docs/optimized_ai_agent_node.json @@ -0,0 +1,61 @@ +{ + "nodes": [ + { + "parameters": { + "promptType": "define", + "text": "=Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска.\n\nВХОД:\n- USER_MESSAGE: \"{{ $json.chatInput }}\"\n- RAG_ANSWER: \"{{ $json.output }}\"\n- FORM_STEPS: {{ $json.questions_numbered_html }}\n\nПРАВИЛА:\n1. Извлекай ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Если нет — missing/needs_confirm.\n2. 5-7 вопросов (priority: 1=критично, 2=доп). Дополнительные помечай priority=2.\n3. Вопросы: name (snake_case), label (текст), control (input[type=\"text\"]|textarea|input[type=\"radio\"]), input_type (text|textarea|choice|file|confirm), required (bool), priority (1|2), ask_if ({field, op, value}|null), options ([{label,value}]|[]).\n4. Документы: id, name, required (bool), priority, accept (['pdf','jpg']), hints (подсказка).\n5. answers_prefill: [{name, value, confidence (0..1), needs_confirm (bool), source (\"user_message\"|\"rag_answer\"), evidence (≤120 chars)}] — только если явно есть в тексте.\n6. coverage_report.questions: [{name, status (\"covered\"|\"partial\"|\"missing\"), confidence, source?, value?}].\n7. Формат — строго JSON, без Markdown, без текста вне JSON.\n\nВЫХОД (JSON):\n{\n \"wizard_plan\": {\n \"version\": \"1.0\",\n \"case_type\": \"consumer\",\n \"questions\": [{\"order\": 1, \"name\": \"item\", \"label\": \"Что за товар/услуга?\", \"control\": \"input[type=\\\"text\\\"]\", \"input_type\": \"text\", \"required\": true, \"priority\": 1, \"ask_if\": null, \"options\": []}],\n \"documents\": [{\"id\": \"contract\", \"name\": \"Договор/заказ\", \"required\": true, \"priority\": 1, \"accept\": [\"pdf\", \"jpg\", \"png\"], \"hints\": \"Фото/скан договора\"}],\n \"user_text\": \"Краткое описание что потребуется и почему (2-3 предложения)\"\n },\n \"answers_prefill\": [{\"name\": \"item\", \"value\": \"...\", \"confidence\": 1, \"needs_confirm\": false, \"source\": \"user_message\", \"evidence\": \"...\"}],\n \"coverage_report\": {\n \"questions\": [{\"name\": \"item\", \"status\": \"covered\", \"confidence\": 1, \"source\": \"user_message\", \"value\": \"...\"}],\n \"docs_missing\": [\"contract\", \"payment\"]\n }\n}\n\nВыполни задачу и верни JSON.", + "options": { + "systemMessage": "Ты — эксперт по структурированию данных для юридических форм. Отвечай только валидным JSON без Markdown." + } + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 2.2, + "position": [3504, 224], + "id": "ea8d4e57-28c2-4944-ac1d-442d4b17a89d", + "name": "AI Agent3 (Optimized)" + }, + { + "parameters": { + "model": { + "__rl": true, + "value": "gpt-4o-mini", + "mode": "list", + "cachedResultName": "gpt-4o-mini" + }, + "options": { + "temperature": 0.3, + "maxTokens": 2000 + } + }, + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "position": [3488, 448], + "id": "6471d211-5728-4e2f-91cc-bc2316ec151c", + "name": "OpenAI Chat Model3 (Optimized)", + "credentials": { + "openAiApi": { + "id": "5qYqegZhVPdCfxxB", + "name": "OpenAi account" + } + } + } + ], + "connections": { + "AI Agent3 (Optimized)": { + "main": [[]] + }, + "OpenAI Chat Model3 (Optimized)": { + "ai_languageModel": [ + [ + { + "node": "AI Agent3 (Optimized)", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + } + } +} + + diff --git a/docs/optimized_wizard_prompt.txt b/docs/optimized_wizard_prompt.txt new file mode 100644 index 0000000..a101eb5 --- /dev/null +++ b/docs/optimized_wizard_prompt.txt @@ -0,0 +1,60 @@ +Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска. + +ВХОД: +- USER_MESSAGE: "{{ $json.chatInput }}" +- RAG_ANSWER: "{{ $json.output }}" +- FORM_STEPS: {{ $json.questions_numbered_html }} + +ПРАВИЛА: +1. Извлекай ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Если нет — missing/needs_confirm. +2. 5-7 вопросов (priority: 1=критично, 2=доп). Дополнительные помечай priority=2. +3. Вопросы: name (snake_case), label (текст), control (input[type="text"]|textarea|input[type="radio"]), input_type (text|textarea|choice|file|confirm), required (bool), priority (1|2), ask_if ({field, op, value}|null), options ([{label,value}]|[]). +4. Документы: id, name, required (bool), priority, accept (['pdf','jpg']), hints (подсказка). +5. answers_prefill: [{name, value, confidence (0..1), needs_confirm (bool), source ("user_message"|"rag_answer"), evidence (≤120 chars)}] — только если явно есть в тексте. +6. coverage_report.questions: [{name, status ("covered"|"partial"|"missing"), confidence, source?, value?}]. +7. Формат — строго JSON, без Markdown, без текста вне JSON. + +ВЫХОД (JSON): +{ + "wizard_plan": { + "version": "1.0", + "case_type": "consumer", + "questions": [ + { + "order": 1, + "name": "item", + "label": "Что за товар/услуга?", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "ask_if": null, + "options": [] + } + ], + "documents": [ + { + "id": "contract", + "name": "Договор/заказ", + "required": true, + "priority": 1, + "accept": ["pdf", "jpg", "png"], + "hints": "Фото/скан договора" + } + ], + "user_text": "Краткое описание что потребуется и почему (2-3 предложения)" + }, + "answers_prefill": [ + {"name": "item", "value": "...", "confidence": 1, "needs_confirm": false, "source": "user_message", "evidence": "..."} + ], + "coverage_report": { + "questions": [ + {"name": "item", "status": "covered", "confidence": 1, "source": "user_message", "value": "..."} + ], + "docs_missing": ["contract", "payment"] + } +} + +Выполни задачу и верни JSON. + + diff --git a/docs/wizard_prompt_n8n.txt b/docs/wizard_prompt_n8n.txt new file mode 100644 index 0000000..bcc471a --- /dev/null +++ b/docs/wizard_prompt_n8n.txt @@ -0,0 +1,113 @@ +Ты — аналитик/структуратор по делам защиты прав потребителей. Твоя задача: на входе у тебя есть + +1) USER_MESSAGE — письмо/описание ситуации от пользователя: "{{ $json.chatInput }}" + +2) RAG_ANSWER — аналитическая справка/правовой ответ (вытянутая из базы): "{{ $json.output }}" + +3) FORM_STEPS — текущий список шагов/поля формы (Google Sheets) в формате массива объектов: +1. Что за товар или услуга? (коротко) — name="item", input[type="text"] +2. Где и когда вы купили/заказали (магазин, сайт, дата)? — name="place_date", input[type="text"] +3. Сколько это стоило (примерно)? — name="price", input[type="text"] +4. В чём именно проблема? Опишите кратко. — name="problem", textarea +5. Какие шаги вы уже предпринимали для решения? — name="steps_taken", textarea +6. Есть ли у вас чеки/договор/акты? — name="docs_exist", input[type="radio"] [Да | Нет] +7. Есть ли у вас переписка (скриншоты, письма)? — name="correspondence_exist", input[type="radio"] [Да | Нет] +8. Что вы хотите получить? — name="expectation", input[type="radio"] [Возврат денег | Замена товара | услуги | Компенсация морального вреда | Другое] +9. Опишите ваше требование (если "Другое") — name="other_expectation", textarea + +**ВАЖНО: В FORM_STEPS НЕТ вопросов про загрузку файлов!** Загрузка файлов происходит автоматически через блоки документов в секции `documents`. НЕ создавай вопросы с `input[type="file"]`, `input_type: "file"` или именами `upload_*`. + +Задача: составить **динамический чек-лист** (5–7 ключевых уточняющих вопросов) + **список документов** для запроса у пользователя, чтобы: + +- собрать доказательственную базу для претензии и/или иска; +- минимизировать долги и непонятности (приоритеты, условия загрузки файлов и т.д.); +- предварительно заполнить (prefill) поля формы, если информация уже есть в USER_MESSAGE или RAG_ANSWER. + +**Правила работы (строго):** + +1. Извлекай информацию ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Не придумывай фактов. Если чего-то нет — указывай это как missing/needs_confirm. + +2. Выбирай 5–7 уточняющих вопросов (если нужно больше — добавь, но пометь дополнительные с priority=2). Приоритет 1 = критично для претензии; 2 = доп. полезно. + +3. Вопросы должны быть написаны «юзер-дружелюбно» и соответствовать HTML controls (input[type="text"], textarea, input[type="radio"], input[type="checkbox"]). **НЕ используй input[type="file"]** — загрузка файлов происходит через блоки документов. + +4. Для каждого вопроса вернуть: name (кодовое имя, латиницей или snake_case), label (текст вопроса), control (html-тип), input_type (text|textarea|choice|multi_choice), required (bool), priority (1|2), rationale (короткое объяснение — 1 предложение), ask_if (условие показа — nullable; формат: { "field":"name", "op":"==", "value":"Да" }), options (если choice — массив {label,value}). + +5. Для документов вернуть: id, name, required(bool), priority, accept (['pdf','jpg'...]), hints (короткая подсказка). + +6. Сформируй answers_prefill — массив объектов { name, value, confidence (0..1), needs_confirm(bool), source: "user_message"|"rag_answer", evidence (<=120 chars) } — если в USER_MESSAGE/RAG есть явный ответ; иначе пусто. + +7. Сделай coverage_report.questions — для каждого вопроса: name, status: "covered"|"partial"|"missing", confidence (0..1), source (если есть), value (если есть). + +8. Укажи risks (кратко — коды: DOCS_STATUS_UNKNOWN, EXPECTATION_UNSET, DATE_AMBIGUOUS и т.д.) и deadlines: включи USER_UPLOAD_TTL=48h и USER_APPROVAL_TTL=24h минимум. + +9. Формат вывода — **строго JSON** ровно по описанной ниже внешней схеме. Никаких объяснений, текста вне JSON и никакого Markdown. Если не уверены в каком-то поле — ставьте null или пустой массив. + +10. Тон — полезный, краткий; при предзаполнении ставьте realistic confidence (1 — явно в тексте; 0.7 — подразумевается; 0.4 — косвенно). + +**КРИТИЧЕСКИ ВАЖНО: НЕ создавай вопросы про загрузку документов!** +- ❌ НЕ создавай вопросы типа "Пожалуйста, загрузите фото или сканы документов" +- ❌ НЕ создавай текстовые поля (text/textarea) для загрузки документов +- ❌ НЕ создавай поля типа `input[type="file"]` или `input_type: "file"` для загрузки документов +- ❌ НЕ создавай вопросы с именами `upload_*` или `upload_docs`, `upload_correspondence` и т.п. +- ✅ Вместо этого используй блоки документов (documents) в секции documents +- ✅ Если нужно узнать наличие документов, используй `multi_choice` с чекбоксами (`input[type="checkbox"]` и `input_type: "multi_choice"`) +- ✅ Загрузка файлов происходит автоматически через блоки документов, не нужно создавать для этого отдельные вопросы + +**Дополнительно:** если вы добавляете новые поля в questions/documents — это допустимо, но не убирайте обязательные поля из схемы. Поле `name` должно совпадать с теми, что есть в FORM_STEPS, если вопрос — трансформация существующего шага; если новый — дайте уникальное name. + +**Пример минимального ожидаемого выхода (фрагмент):** + +{ + "wizard_plan": { + "version":"1.0", + "case_type":"consumer", + "goals":[ "...", ... ], + "questions":[ + { + "order": 1, + "name": "item", + "label": "Что за товар или услуга? (коротко)", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "rationale": "...", + "ask_if": null, + "options": [] + } + // ... вопросы (БЕЗ upload_* и input[type="file"]!) + ], + "documents":[ + { + "id":"contract", + "name":"Договор/заказ", + "required": true, + "priority": 1, + "accept":["pdf","jpg","png"], + "hints":"Фото/скан подписанного договора" + } + // ... + ], + "ask_order":[ "item","place_date", ... ], + "user_text":"<пара предложений для вывода пользователю: что потребуется и почему>", + "notes":"короткая заметка", + "risks":[ "DOCS_STATUS_UNKNOWN", "EXPECTATION_UNSET" ], + "deadlines":[ {"type":"USER_UPLOAD_TTL","duration_hours":48}, {"type":"USER_APPROVAL_TTL","duration_hours":24} ] + }, + "answers_prefill":[ + { "name":"item","value":"кровать-podium...","confidence":1,"needs_confirm":false,"source":"user_message","evidence":"9 августа оформили заказ ..."} + // ... + ], + "coverage_report":{ + "questions":[ + { "name":"item","status":"covered","confidence":1,"source":"user_message","value":"..." } + // ... + ], + "docs_received": [], // при наличии + "docs_missing": ["contract","payment","correspondence"] + } +} + +Выполни задачу прямо сейчас и верни JSON согласно схеме. + diff --git a/docs/wizard_prompt_simple.txt b/docs/wizard_prompt_simple.txt new file mode 100644 index 0000000..a0d5f9e --- /dev/null +++ b/docs/wizard_prompt_simple.txt @@ -0,0 +1,406 @@ +# Роль + +Ты — юридический ассистент по защите прав потребителей. Ты помогаешь людям понять, какие необходимо собрать документы и сообщить дополнительные сведения, для решения их проблемы. + +# Задача: Построение динамического визарда + +Твоя задача — проанализировать описание проблемы пользователя и создать **динамический визард** — структурированный набор вопросов и списка документов, которые помогут собрать всю необходимую информацию для подготовки претензии или иска. + +## Что такое визард? + +Визард — это пошаговая форма, которая: +1. **Задаёт вопросы** пользователю для уточнения деталей дела +2. **Требует документы**, необходимые для доказательства фактов +3. **Автоматически заполняет** поля, если информация уже есть в описании +4. **Адаптируется** — показывает дополнительные вопросы в зависимости от ответов + +## Входные данные + +Ты получаешь только: +- **USER_DESCRIPTION**: Описание проблемы от пользователя (текст) + +## Правила построения визарда + +### 1. Анализ описания + +Внимательно прочитай описание проблемы и определи: +- **Тип дела** (покупка товара, услуга, конфликт с продавцом, нарушение сроков и т.д.) +- **Что уже известно** из описания (товар/услуга, дата, место, сумма, проблема) +- **Что нужно уточнить** (детали, документы, шаги пользователя) + +### 2. Вопросы (questions) + +Создай **5-8 вопросов**, которые помогут собрать недостающую информацию. + +**Обязательные вопросы для большинства дел (priority: 1):** +- **Что** — название товара/услуги (item) — **ВСЕГДА включай** +- **Кто** — продавец/исполнитель (seller) — **ВСЕГДА включай** +- **Где** — место покупки/заказа (purchase_place) — **ВСЕГДА включай** +- **Когда** — дата покупки/заказа (purchase_date) — **ВСЕГДА включай для товаров/услуг** +- **Сколько** — сумма покупки (purchase_amount) — **ВСЕГДА включай для товаров/услуг, критично для оценки ущерба** +- **Проблема** — описание дефекта/нарушения (problem_description) — **ВСЕГДА включай** +- **Действия** — что уже сделано (actions_taken) — **ВСЕГДА включай** +- **Гарантия** — есть ли гарантия и какой срок (warranty_info) — **ВСЕГДА включай для товаров, даже если не упомянуто в описании** + +**Дополнительные вопросы (priority: 2):** +- Наличие документов (лучше сделать multi_choice с чекбоксами, а не текстовое поле) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"` для множественного выбора** +- Желаемый результат (возврат денег, замена, ремонт, компенсация) — вместо прямого вопроса про суд — используй `input[type="radio"]` для выбора одного варианта + +**ВАЖНО: НЕ создавай вопросы про загрузку документов!** +- ❌ НЕ создавай вопросы типа "Пожалуйста, загрузите фото или сканы документов" +- ❌ НЕ создавай текстовые поля (text/textarea) для загрузки документов +- ❌ НЕ создавай поля типа `input[type="file"]` или `input_type: "file"` для загрузки документов +- ❌ НЕ создавай вопросы с именами `upload_*` или `upload_docs`, `upload_correspondence` и т.п. +- ✅ Вместо этого используй блоки документов (documents) в секции documents +- ✅ Если нужно узнать наличие документов, используй `multi_choice` с чекбоксами +- ✅ Загрузка файлов происходит автоматически через блоки документов, не нужно создавать для этого отдельные вопросы + +**Приоритеты:** +- **priority: 1** — критически важные вопросы (что, где, когда, сколько, кто, проблема, действия, гарантия) +- **priority: 2** — дополнительные вопросы (детали, уточнения, факультативные) + +**Типы вопросов:** +- `text` — короткий текст (название товара, место, сумма) +- `date` — дата (дата покупки, дата заказа) — **ИСПОЛЬЗУЙ `input[type="date"]` для дат, НЕ `text`** +- `textarea` — длинный текст (описание проблемы, детали) +- `choice` — выбор одного варианта (да/нет, тип требования) — используй `input[type="radio"]` +- `multi_choice` — выбор нескольких вариантов (наличие документов) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` для множественного выбора** + +**Условные вопросы:** +- Используй `ask_if` для вопросов, которые показываются только при определённых ответах +- **ВАЖНО:** Если в вопросе с вариантами есть опция "Другое", ВСЕГДА добавляй дополнительный вопрос с `ask_if`, который показывается только когда выбрано "Другое" +- Пример: если пользователь выбрал "Другое" в типе требования (`desired_outcome`), показать текстовое поле для уточнения (`desired_outcome_other`) +- Структура `ask_if`: `{"field": "desired_outcome", "op": "==", "value": "other"}` + +**Структура вопроса:** +```json +{ + "order": 1, + "name": "item", + "label": "Как называется товар или услуга?", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "rationale": "Нужно точно определить предмет спора", + "ask_if": null, + "options": [] +} +``` + +**Поля:** +- `order` — порядок отображения (1, 2, 3...) +- `name` — уникальное имя в snake_case (item, place_date, problem, etc.) +- `label` — текст вопроса для пользователя +- `control` — HTML-контрол ("input[type=\"text\"]", "input[type=\"date\"]", "textarea", "input[type=\"radio\"]", "input[type=\"checkbox\"]") +- `input_type` — тип ("text", "date", "textarea", "choice", "multi_choice") — **для дат ВСЕГДА используй "date", для множественного выбора документов ВСЕГДА используй "multi_choice"** +- `required` — обязательный ли вопрос (true/false) +- `priority` — приоритет (1 = критично, 2 = доп) +- `rationale` — почему этот вопрос важен (для логирования) +- `ask_if` — условие показа (null или {field, op, value}) +- `options` — варианты для choice ([{label, value}]) + +### 3. Документы (documents) + +Определи, какие документы нужны для доказательства фактов. + +**Типы документов:** +- **Обязательные** (required: true) — договор, чеки, подтверждение оплаты +- **Дополнительные** (required: false) — переписка, скриншоты, фото + +**Структура документа:** +```json +{ + "id": "contract", + "name": "Договор или подтверждение заказа", + "required": true, + "priority": 1, + "accept": ["pdf", "jpg", "png"], + "hints": "Фото или скан подписанного договора" +} +``` + +**Поля:** +- `id` — уникальный идентификатор (contract, payment, correspondence, etc.) +- `name` — название документа для пользователя +- `required` — обязательный ли документ (true/false) +- `priority` — приоритет (1 = критично, 2 = доп) +- `accept` — допустимые форматы (["pdf", "jpg", "png"]) +- `hints` — подсказка, что именно нужно загрузить + +### 4. Автозаполнение (answers_prefill) + +Если в описании пользователя уже есть ответы на вопросы, заполни их автоматически. + +**Структура:** +```json +{ + "name": "item", + "value": "Онлайн-курс по программированию", + "confidence": 0.9, + "needs_confirm": false, + "source": "user_description", + "evidence": "В описании упомянут 'онлайн-курс по программированию'" +} +``` + +**Правила:** +- Извлекай ТОЛЬКО явно упомянутые факты +- `confidence` — уверенность (0.0-1.0) +- `needs_confirm` — нужна ли подтверждение от пользователя (false если уверен, true если сомневаешься) +- `source` — всегда "user_description" +- `evidence` — короткая цитата из описания (≤120 символов) + +### 5. Отчёт о покрытии (coverage_report) + +Покажи, какие вопросы уже покрыты описанием, а какие нужно задать. + +**Структура:** +```json +{ + "questions": [ + { + "name": "item", + "status": "covered", + "confidence": 0.9, + "source": "user_description", + "value": "Онлайн-курс" + }, + { + "name": "place_date", + "status": "missing", + "confidence": 0, + "source": null, + "value": null + } + ], + "docs_received": [], + "docs_missing": ["contract", "payment"] +} +``` + +**Статусы:** +- `covered` — информация есть в описании +- `partial` — информация частично есть, нужно уточнить +- `missing` — информации нет, нужно спросить + +## Формат вывода + +Верни **строго JSON**, без Markdown, без дополнительного текста. + +```json +{ + "wizard_plan": { + "version": "1.0", + "case_type": "consumer", + "questions": [ + { + "order": 1, + "name": "item", + "label": "Как называется товар или услуга?", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "rationale": "Нужно точно определить предмет спора", + "ask_if": null, + "options": [] + }, + { + "order": 2, + "name": "purchase_date", + "label": "Когда был приобретён товар/заказана услуга?", + "control": "input[type=\"date\"]", + "input_type": "date", + "required": true, + "priority": 1, + "rationale": "Дата важна для определения гарантийного срока и сроков обращения", + "ask_if": null, + "options": [] + }, + { + "order": 3, + "name": "purchase_amount", + "label": "Сколько стоил товар/услуга?", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "rationale": "Сумма нужна для оценки ущерба и размера требований", + "ask_if": null, + "options": [] + }, + { + "order": 4, + "name": "documents_available", + "label": "Какие документы у вас уже есть?", + "control": "input[type=\"checkbox\"]", + "input_type": "multi_choice", + "required": false, + "priority": 2, + "rationale": "Определить какие доказательства уже собраны", + "ask_if": null, + "options": [ + {"label": "Чек", "value": "receipt"}, + {"label": "Договор", "value": "contract"}, + {"label": "Переписка", "value": "correspondence"}, + {"label": "Фото/скриншоты", "value": "photos"}, + {"label": "Акт диагностики/ремонта", "value": "diagnosis"}, + {"label": "Досудебная претензия", "value": "pretrial_claim"}, + {"label": "Другое", "value": "other"} + ] + }, + { + "order": 5, + "name": "desired_outcome", + "label": "Что вы хотите получить в результате?", + "control": "input[type=\"radio\"]", + "input_type": "choice", + "required": true, + "priority": 1, + "rationale": "Уточнение цели для корректного требования", + "ask_if": null, + "options": [ + {"label": "Возврат денег", "value": "refund"}, + {"label": "Замена товара/услуги", "value": "replacement"}, + {"label": "Ремонт", "value": "repair"}, + {"label": "Компенсация", "value": "compensation"}, + {"label": "Другое", "value": "other"} + ] + }, + { + "order": 6, + "name": "desired_outcome_other", + "label": "Опишите, пожалуйста, ваше требование", + "control": "input[type=\"text\"]", + "input_type": "text", + "required": true, + "priority": 1, + "rationale": "Уточнение нетипичного требования", + "ask_if": {"field": "desired_outcome", "op": "==", "value": "other"}, + "options": [] + } + ], + "documents": [ + { + "id": "contract", + "name": "Договор или подтверждение заказа", + "required": true, + "priority": 1, + "accept": ["pdf", "jpg", "png"], + "hints": "Фото или скан подписанного договора" + } + ], + "user_text": "Краткое описание (2-3 предложения) что потребуется собрать и почему" + }, + "answers_prefill": [ + { + "name": "item", + "value": "...", + "confidence": 1, + "needs_confirm": false, + "source": "user_description", + "evidence": "..." + } + ], + "coverage_report": { + "questions": [ + { + "name": "item", + "status": "covered", + "confidence": 1, + "source": "user_description", + "value": "..." + } + ], + "docs_received": [], + "docs_missing": ["contract", "payment"] + } +} +``` + +## Примеры типовых ситуаций + +### Покупка товара с дефектом +**Вопросы (priority: 1) — ВСЕ эти вопросы ОБЯЗАТЕЛЬНЫ для товаров:** +1. Как называется товар? (item, text, required: true) +2. Кто продавец? (seller, text, required: true) +3. Где был приобретён товар? (purchase_place, text, required: true) +4. Когда был приобретён товар? (purchase_date, **date**, required: true) — **НЕ ПРОПУСКАЙ, используй input_type="date"** +5. Сколько стоил товар? (purchase_amount, text, required: true) — **НЕ ПРОПУСКАЙ** +6. Есть ли гарантия и какой срок? (warranty_info, text, required: true) — **НЕ ПРОПУСКАЙ для товаров** +7. Опишите проблему с товаром (problem_description, textarea, required: true) +8. Какие шаги уже предприняли? (actions_taken, textarea, required: false) + +**Вопросы (priority: 2):** +9. Какие документы у вас есть? (documents_available, **multi_choice**) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"`** — варианты: чек, договор, переписка, фото дефекта, акт диагностики, досудебная претензия +10. Что вы хотите получить? (desired_outcome, choice) — используй `input[type="radio"]` для выбора одного варианта — варианты: возврат денег, замена товара, ремонт, компенсация, другое +11. **ОБЯЗАТЕЛЬНО:** Если в desired_outcome есть опция "Другое", добавь условный вопрос (desired_outcome_other, text) с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования + +**Документы:** +- Договор/чек (required: true) +- Фото дефекта (required: true) +- Переписка с продавцом (required: false) +- Акт диагностики/ремонта (required: false) + +### Некачественная услуга +**Вопросы (priority: 1) — ВСЕ эти вопросы ОБЯЗАТЕЛЬНЫ для услуг:** +1. Какая услуга? (item, text, required: true) +2. Кто исполнитель? (seller, text, required: true) +3. Где заказали услугу? (purchase_place, text, required: true) +4. Когда заказали услугу? (purchase_date, **date**, required: true) — **НЕ ПРОПУСКАЙ, используй input_type="date"** +5. Сколько стоила услуга? (purchase_amount, text, required: true) — **НЕ ПРОПУСКАЙ** +6. В чём проблема? (problem_description, textarea, required: true) +7. Какие шаги уже предприняли? (actions_taken, textarea, required: false) + +**Вопросы (priority: 2):** +8. Какие документы у вас есть? (documents_available, **multi_choice**) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"`** +9. Что вы хотите получить? (desired_outcome, choice) — используй `input[type="radio"]` для выбора одного варианта +10. **ОБЯЗАТЕЛЬНО:** Если в desired_outcome есть опция "Другое", добавь условный вопрос (desired_outcome_other, text) с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования + +**Документы:** +- Договор (required: true) +- Подтверждение оплаты (required: true) +- Переписка (required: false) +- Скриншоты/фото (required: false) + +### Нарушение сроков +**Вопросы (priority: 1):** +1. Что заказали? (item, text) +2. Кто исполнитель? (seller, text) +3. Когда заказали? (purchase_date, text) +4. Когда должны были выполнить? (expected_date, text) +5. Когда фактически выполнили (или не выполнили)? (actual_date, text) +6. Сколько стоило? (purchase_amount, text) +7. Какие последствия? (problem_description, textarea) +8. Какие шаги уже предприняли? (actions_taken, textarea) + +**Документы:** +- Договор с датами (required: true) +- Переписка (required: true) +- Подтверждение оплаты (required: true) + +## Важные правила + +1. **Будь конкретным** — вопросы должны быть понятными и конкретными +2. **Не дублируй** — если информация уже есть в описании, используй автозаполнение +3. **Адаптируйся** — учитывай тип ситуации (покупка товара ≠ конфликт в магазине) +4. **Обязательные поля** — для товаров/услуг ВСЕГДА включай в визард ВСЕ эти вопросы: дату покупки (purchase_date с input_type="date"), сумму (purchase_amount), гарантию (warranty_info для товаров). НЕ пропускай их, даже если они не упомянуты в описании — пользователь должен их заполнить. +5. **Тип поля для даты** — для даты покупки (purchase_date) ВСЕГДА используй `control: "input[type=\"date\"]"` и `input_type: "date"`, а НЕ текстовое поле. +6. **Вопрос про документы** — используй `multi_choice` с чекбоксами (`input[type="checkbox"]` и `input_type: "multi_choice"`), потому что пользователь может иметь несколько документов одновременно. НЕ используй `input[type="radio"]` для этого вопроса. +7. **Желаемый результат** — спрашивай "Что вы хотите получить?" с вариантами (возврат денег, замена, ремонт, компенсация, другое), а не "Хотите ли идти в суд?". **ВАЖНО:** Если есть опция "Другое", ВСЕГДА добавляй условный вопрос с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования. +8. **Приоритеты** — сначала критичные (priority: 1), потом дополнительные (priority: 2) +9. **Документы обязательны** — для большинства дел нужны договор и подтверждение оплаты +10. **НЕ создавай вопросы про загрузку файлов** — НЕ создавай вопросы с `input_type: "file"`, `input[type="file"]`, именами `upload_*` или текстами "загрузите", "фото", "сканы". Загрузка файлов происходит автоматически через блоки документов в секции `documents`. +11. **Минимум вопросов** — 5-8 вопросов достаточно для большинства случаев, но не меньше обязательных полей + +## Выполни задачу + +Проанализируй описание проблемы пользователя и создай визард. + +**ВХОД:** +- USER_DESCRIPTION: "{{ описание проблемы }}" + +**ВЫХОД:** +Верни только JSON без Markdown разметки. + diff --git a/frontend/src/App.css b/frontend/src/App.css index ddb53c5..64a36f9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,11 +5,12 @@ } .app-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: #fafafa; + color: #000000; padding: 2rem; text-align: center; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border-bottom: 1px solid #d9d9d9; } .app-header h1 { @@ -40,8 +41,8 @@ .card h2 { margin-bottom: 1rem; - color: #333; - border-bottom: 2px solid #667eea; + color: #000000; + border-bottom: 2px solid #d9d9d9; padding-bottom: 0.5rem; } @@ -88,8 +89,8 @@ } .card a { - color: #667eea; - text-decoration: none; + color: #000000; + text-decoration: underline; font-weight: 500; } @@ -101,7 +102,7 @@ text-align: center; padding: 3rem; font-size: 1.5rem; - color: #667eea; + color: #000000; } .success { diff --git a/frontend/src/assets/ai-working.svg b/frontend/src/assets/ai-working.svg index 5bb92c0..e23a4b1 100644 --- a/frontend/src/assets/ai-working.svg +++ b/frontend/src/assets/ai-working.svg @@ -49,3 +49,4 @@ + diff --git a/frontend/src/components/DebugPanel.tsx b/frontend/src/components/DebugPanel.tsx index 9ab9a25..cb84e73 100644 --- a/frontend/src/components/DebugPanel.tsx +++ b/frontend/src/components/DebugPanel.tsx @@ -50,12 +50,16 @@ export default function DebugPanel({ events, formData }: Props) { color: '#d4d4d4', border: '1px solid #333' }} - headStyle={{ - background: '#252526', - color: '#fff', - borderBottom: '1px solid #333' + styles={{ + header: { + background: '#252526', + color: '#fff', + borderBottom: '1px solid #333' + }, + body: { + padding: 12 + } }} - bodyStyle={{ padding: 12 }} > {/* Текущие данные формы */}
@@ -79,18 +83,17 @@ export default function DebugPanel({ events, formData }: Props) { Events Log:
- - {events.length === 0 && ( - - Нет событий... - - )} - - {events.map((event, index) => ( - + Нет событий... + } + ] : events.map((event, index) => ({ + key: index, + dot: getIcon(event.status), + children: (
{event.timestamp} @@ -251,9 +254,9 @@ export default function DebugPanel({ events, formData }: Props) {
)}
-
- ))} -
+ ) + }))} + /> {events.length > 0 && (
diff --git a/frontend/src/components/form/Step1Phone.tsx b/frontend/src/components/form/Step1Phone.tsx index b4f4e0b..e00b3d5 100644 --- a/frontend/src/components/form/Step1Phone.tsx +++ b/frontend/src/components/form/Step1Phone.tsx @@ -5,7 +5,7 @@ import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons'; interface Props { formData: any; updateFormData: (data: any) => void; - onNext: () => void; + onNext: (unified_id?: string) => void; // ✅ Может принимать unified_id setIsPhoneVerified: (verified: boolean) => void; addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; } @@ -96,7 +96,8 @@ export default function Step1Phone({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, - session_id: formData.session_id // ✅ Передаём session_id + session_id: formData.session_id, // ✅ Передаём session_id + form_id: 'ticket_form' // ✅ Маркируем источник формы }) }); @@ -118,6 +119,7 @@ export default function Step1Phone({ phone, contact_id: result.contact_id, claim_id: result.claim_id, + unified_id: result.unified_id, // ← Добавляем в лог is_new_contact: result.is_new_contact }); @@ -126,13 +128,18 @@ export default function Step1Phone({ // Сохраняем данные из CRM в форму updateFormData({ phone, + smsCode: code, contact_id: result.contact_id, + unified_id: result.unified_id, // ✅ Unified ID из PostgreSQL (получаем от n8n) claim_id: result.claim_id, is_new_contact: result.is_new_contact }); message.success(result.is_new_contact ? 'Контакт создан!' : 'Контакт найден!'); - onNext(); + + // ✅ Передаем unified_id напрямую в onNext для проверки черновиков + // Это нужно, потому что formData может еще не обновиться + onNext(result.unified_id); } else { addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult); message.error('Ошибка создания контакта в CRM'); @@ -173,13 +180,21 @@ export default function Step1Phone({ { pattern: /^\d{10}$/, message: 'Введите 10 цифр без кода страны' } ]} > - } - addonBefore="+7" - placeholder="9001234567" - maxLength={10} - size="large" - /> + + + } + placeholder="9001234567" + maxLength={10} + size="large" + style={{ flex: 1 }} + /> + diff --git a/frontend/src/components/form/Step1Policy.tsx b/frontend/src/components/form/Step1Policy.tsx index 52acdef..7fb0aff 100644 --- a/frontend/src/components/form/Step1Policy.tsx +++ b/frontend/src/components/form/Step1Policy.tsx @@ -470,11 +470,11 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
-

+

⚠️ Полис не найден в базе данных

@@ -525,7 +525,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug

Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB) {fileList.length > 0 && ( - + (автоконвертация в PDF) )} @@ -570,7 +570,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug )} {!policyNotFound && ( -
+

💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически

@@ -647,7 +647,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug

{ocrModalContent.message || 'Документ не распознан'}

Полный ответ:

-
+                
                   {JSON.stringify(ocrModalContent.data, null, 2)}
                 
diff --git a/frontend/src/components/form/Step2Details.tsx b/frontend/src/components/form/Step2Details.tsx index bc30db4..13f4e95 100644 --- a/frontend/src/components/form/Step2Details.tsx +++ b/frontend/src/components/form/Step2Details.tsx @@ -381,7 +381,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, {/* Прогресс обработки документов */} {eventType && currentDocuments.length > 0 && ( - +
Прогресс обработки документов:
@@ -396,7 +396,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
{currentDocuments.map(doc => processedDocuments[doc.field] ? ( -
+
{doc.name} - ✅ Обработан
) : null @@ -411,14 +411,14 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, -
-

+

+

💡 {currentDocConfig.description}

{currentDocConfig.required && ( -

+

⚠️ Этот документ обязательный

)} @@ -475,11 +475,11 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, {/* Если все документы обработаны или текущий индекс вышел за пределы */} {eventType && currentDocumentIndex >= currentDocuments.length && (
- -

✅ Все документы обработаны!

+ +

✅ Все документы обработаны!

Обработано обязательных документов: {processedRequired}/{totalRequired}

@@ -533,12 +533,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,

-

+

✅ Документ успешно распознан

@@ -562,7 +562,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,

{ocrModalContent.message || 'Документ не распознан'}

Детали:

 = ({ formData, updateFormData, onNext, onP
       
         

- + Выберите тип страхового случая

diff --git a/frontend/src/components/form/Step3Payment.tsx b/frontend/src/components/form/Step3Payment.tsx index ee94d14..8048114 100644 --- a/frontend/src/components/form/Step3Payment.tsx +++ b/frontend/src/components/form/Step3Payment.tsx @@ -143,6 +143,14 @@ export default function Step3Payment({ initialValues={formData} style={{ marginTop: 24 }} > + {/* Скрытые технические поля */} + + + {/* Кнопка Назад вверху */}

+ + ) : ( + <> + ( + onSelectDraft(draft.claim_id!)} + icon={} + > + Продолжить + , + handleDelete(draft.claim_id!)} + okText="Да, удалить" + cancelText="Отмена" + > + + , + ]} + > + } + title={ + + Черновик {draft.claim_id} + Черновик + + } + description={ + + + Обновлен: {formatDate(draft.updated_at)} + + {draft.problem_description && ( + + {draft.problem_description} + + )} + + + {draft.wizard_plan ? '✓ План' : 'План'} + + + {draft.wizard_answers ? '✓ Ответы' : 'Ответы'} + + + {draft.has_documents ? '✓ Документы' : 'Документы'} + + + + Прогресс: {getProgressInfo(draft)} + + + } + /> + + )} + /> + +
+ +
+ +
+ +
+ + )} + + +
+ ); +} + diff --git a/frontend/src/components/form/StepWizardPlan.tsx b/frontend/src/components/form/StepWizardPlan.tsx index 3c23e49..7b7492e 100644 --- a/frontend/src/components/form/StepWizardPlan.tsx +++ b/frontend/src/components/form/StepWizardPlan.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button, Card, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd'; +import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd'; import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; import AiWorkingIllustration from '../../assets/ai-working.svg'; import type { UploadFile } from 'antd/es/upload/interface'; @@ -57,11 +57,16 @@ const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record': return left > right; case '<': @@ -109,6 +114,7 @@ export default function StepWizardPlan({ }: Props) { const [form] = Form.useForm(); const eventSourceRef = useRef(null); + const timeoutRef = useRef(null); const debugLoggerRef = useRef(addDebugEvent); const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan); const [connectionError, setConnectionError] = useState(null); @@ -122,6 +128,10 @@ export default function StepWizardPlan({ const [customFileBlocks, setCustomFileBlocks] = useState( formData.wizardUploads?.custom || [] ); + const [skippedDocuments, setSkippedDocuments] = useState>( + new Set(formData.wizardSkippedDocuments || []) + ); + const [submitting, setSubmitting] = useState(false); const [progressState, setProgressState] = useState<{ done: number; total: number }>({ done: 0, total: 0, @@ -131,26 +141,6 @@ export default function StepWizardPlan({ if (!progressState.total) return 0; return Math.round((progressState.done / progressState.total) * 100); }, [progressState]); - const persistUploads = useCallback( - (nextDocuments: Record, nextCustom: FileBlock[]) => { - updateFormData({ - wizardUploads: { - documents: nextDocuments, - custom: nextCustom, - }, - }); - }, - [updateFormData] - ); - - useEffect(() => { - if (formData.wizardUploads?.documents) { - setQuestionFileBlocks(formData.wizardUploads.documents); - } - if (formData.wizardUploads?.custom) { - setCustomFileBlocks(formData.wizardUploads.custom); - } - }, [formData.wizardUploads]); useEffect(() => { debugLoggerRef.current = addDebugEvent; @@ -196,32 +186,35 @@ export default function StepWizardPlan({ const currentBlocks = nextDocs[docId] || []; const updated = updater(currentBlocks); nextDocs[docId] = updated; - persistUploads(nextDocs, customFileBlocks); return nextDocs; }); }, - [customFileBlocks, persistUploads] + [] ); const handleCustomBlocksChange = useCallback( (updater: (blocks: FileBlock[]) => FileBlock[]) => { setCustomFileBlocks((prev) => { const updated = updater(prev); - persistUploads(questionFileBlocks, updated); return updated; }); }, - [persistUploads, questionFileBlocks] + [] ); - const addDocumentBlock = (docId: string, docLabel?: string) => { + const addDocumentBlock = (docId: string, docLabel?: string, docList?: WizardDocument[]) => { + // Для предопределённых документов используем их ID как категорию + const category = docList && docList.length === 1 && docList[0].id && !docList[0].id.includes('_exist') + ? docList[0].id + : docId; + handleDocumentBlocksChange(docId, (blocks) => [ ...blocks, { id: generateBlockId(docId), fieldName: docId, description: '', - category: docId, + category: category, docLabel: docLabel, files: [], }, @@ -304,6 +297,47 @@ export default function StepWizardPlan({ setProgressState({ done, total }); }, [formValues, questions]); + // Автоматически создаём блоки для обязательных документов при ответе "Да" + useEffect(() => { + if (!plan || !formValues) return; + + questions.forEach((question) => { + const visible = evaluateCondition(question.ask_if, formValues); + if (!visible) return; + + const questionValue = formValues?.[question.name]; + if (!isAffirmative(questionValue)) return; + + const questionDocs = documentGroups[question.name] || []; + questionDocs.forEach((doc) => { + if (!doc.required) return; + + const docKey = doc.id || doc.name || `doc_${question.name}`; + + // Не создаём блок, если документ пропущен + if (skippedDocuments.has(docKey)) return; + + const existingBlocks = questionFileBlocks[docKey] || []; + + // Если блока ещё нет, создаём его автоматически + if (existingBlocks.length === 0) { + const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey; + handleDocumentBlocksChange(docKey, (blocks) => [ + ...blocks, + { + id: generateBlockId(docKey), + fieldName: docKey, + description: '', + category: category, + docLabel: doc.name, + files: [], + }, + ]); + } + }); + }); + }, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]); + useEffect(() => { if (!isWaiting || !formData.claim_id || plan) { return; @@ -313,6 +347,16 @@ export default function StepWizardPlan({ const source = new EventSource(`/events/${claimId}`); eventSourceRef.current = source; debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId }); + + // Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку + timeoutRef.current = setTimeout(() => { + setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.'); + debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { claim_id: claimId }); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, 120000); // 2 минуты для RAG обработки source.onopen = () => { setConnectionError(null); @@ -357,6 +401,15 @@ export default function StepWizardPlan({ payload?.data?.event_type || payload?.redis_value?.event_type; + // Логируем все события для отладки + debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', { + claim_id: claimId, + event_type: eventType, + has_wizard_plan: Boolean(extractWizardPayload(payload)), + payload_keys: Object.keys(payload), + payload_preview: JSON.stringify(payload).substring(0, 200), + }); + const wizardPayload = extractWizardPayload(payload); const hasWizardPlan = Boolean(wizardPayload); @@ -384,6 +437,10 @@ export default function StepWizardPlan({ wizardPlanStatus: 'ready', }); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } source.close(); eventSourceRef.current = null; } @@ -393,6 +450,10 @@ export default function StepWizardPlan({ }; return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -415,37 +476,55 @@ export default function StepWizardPlan({ }; const validateUploads = (values: Record) => { - for (const [questionName, docs] of Object.entries(documentGroups)) { - if (!docs.length) continue; + // Проверяем каждый документ по его ID + for (const doc of documents) { + // Находим вопрос, к которому привязан документ + const questionName = Object.keys(documentGroups).find(key => + documentGroups[key].some(d => d.id === doc.id) + ); + + if (!questionName) continue; const answer = values?.[questionName]; if (!isAffirmative(answer)) continue; - const blocks = questionFileBlocks[questionName] || []; - for (const doc of docs) { - const matched = blocks.some((block) => { - if (!block.files.length) return false; - if (!block.category) return true; - const normalizedCategory = block.category.toLowerCase(); - const normalizedId = (doc.id || '').toLowerCase(); - const normalizedName = (doc.name || '').toLowerCase(); - return ( - normalizedCategory === normalizedId || - normalizedCategory === normalizedName || - (normalizedCategory.includes('contract') && normalizedId.includes('contract')) || - (normalizedCategory.includes('payment') && normalizedId.includes('payment')) || - (normalizedCategory.includes('correspondence') && normalizedId.includes('correspondence')) - ); - }); - if (doc.required && !matched) { - return `Добавьте файлы для документа "${doc.name}"`; + + // Блоки теперь хранятся по doc.id, а не по questionName + const docKey = doc.id || doc.name || `doc_${questionName}`; + const blocks = questionFileBlocks[docKey] || []; + + // Проверяем, есть ли файлы для обязательного документа (если он не пропущен) + if (doc.required) { + if (skippedDocuments.has(docKey)) { + continue; // Пропускаем валидацию для пропущенных документов + } + const hasFiles = blocks.some((block) => block.files.length > 0); + if (!hasFiles) { + return `Добавьте файлы для документа "${doc.name}" или отметьте, что документа нет`; } } - const missingDescription = blocks.some( - (block) => block.files.length > 0 && !block.description?.trim() - ); - if (missingDescription) { - return 'Заполните описание для каждого блока документов'; + + // Проверяем описание только для необязательных документов И только если документ не предопределённый + // Предопределённые документы (contract, payment, payment_confirmation, receipt, cheque) не требуют описания + const docIdLower = (doc.id || '').toLowerCase(); + const docNameLower = (doc.name || '').toLowerCase(); + const isPredefinedDoc = doc.id && !doc.id.includes('_exist') && + (doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' || + docIdLower.includes('contract') || docIdLower.includes('payment') || + docIdLower.includes('receipt') || docIdLower.includes('cheque') || + docNameLower.includes('договор') || docNameLower.includes('чек') || + docNameLower.includes('оплат') || docNameLower.includes('платеж')); + + // Для обязательных документов описание не требуется + // Для предопределённых документов описание не требуется + if (!doc.required && !isPredefinedDoc) { + const missingDescription = blocks.some( + (block) => block.files.length > 0 && !block.description?.trim() + ); + if (missingDescription) { + return `Заполните описание для документа "${doc.name}"`; + } } } + const customMissingDescription = customFileBlocks.some( (block) => block.files.length > 0 && !block.description?.trim() ); @@ -455,13 +534,14 @@ export default function StepWizardPlan({ return null; }; - const handleFinish = (values: Record) => { + const handleFinish = async (values: Record) => { const uploadError = validateUploads(values); if (uploadError) { message.error(uploadError); return; } + // Сохраняем в общий стейт updateFormData({ wizardPlan: plan, wizardAnswers: values, @@ -470,14 +550,196 @@ export default function StepWizardPlan({ documents: questionFileBlocks, custom: customFileBlocks, }, + wizardSkippedDocuments: Array.from(skippedDocuments), }); + addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', { answers: values, }); + + // Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data) + try { + setSubmitting(true); + addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', { + claim_id: formData.claim_id, + }); + + const formPayload = new FormData(); + formPayload.append('stage', 'wizard'); + formPayload.append('form_id', 'ticket_form'); + if (formData.session_id) formPayload.append('session_id', formData.session_id); + if (formData.clientIp) formPayload.append('client_ip', formData.clientIp); + if (formData.smsCode) formPayload.append('sms_code', formData.smsCode); + if (formData.claim_id) formPayload.append('claim_id', formData.claim_id); + if (formData.contact_id) formPayload.append('contact_id', String(formData.contact_id)); + if (formData.project_id) formPayload.append('project_id', String(formData.project_id)); + if (typeof formData.is_new_contact !== 'undefined') { + formPayload.append('is_new_contact', String(formData.is_new_contact)); + } + if (typeof formData.is_new_project !== 'undefined') { + formPayload.append('is_new_project', String(formData.is_new_project)); + } + if (formData.phone) formPayload.append('phone', formData.phone); + if (formData.email) formPayload.append('email', formData.email); + if (formData.eventType) formPayload.append('event_type', formData.eventType); + + // JSON-поля + formPayload.append('wizard_plan', JSON.stringify(plan || {})); + formPayload.append('wizard_answers', JSON.stringify(values || {})); + formPayload.append('wizard_skipped_documents', JSON.stringify(Array.from(skippedDocuments))); + + // --- Группируем блоки в uploads[i][j] + uploads_descriptions[i] + uploads_field_names[i] + type UploadGroup = { + index: number; + question?: string; + block: FileBlock; + kind: 'question' | 'custom'; + }; + + const groups: UploadGroup[] = []; + let groupIndex = 0; + + // Собираем все блоки документов (теперь они хранятся по doc.id) + // Сначала ищем блоки, которые привязаны к вопросам через documentGroups + const allDocKeys = new Set(); + Object.values(documentGroups).forEach(docs => { + docs.forEach(doc => { + const docKey = doc.id || doc.name; + if (docKey && questionFileBlocks[docKey]) { + allDocKeys.add(docKey); + } + }); + }); + + // Также добавляем блоки по старым ключам (для обратной совместимости) + Object.keys(questionFileBlocks).forEach(key => { + if (!allDocKeys.has(key) && (key.includes('_exist') || key.startsWith('doc_'))) { + allDocKeys.add(key); + } + }); + + Array.from(allDocKeys).forEach((docKey) => { + const blocks = questionFileBlocks[docKey] || []; + blocks.forEach((block) => { + groups.push({ + index: groupIndex++, + question: docKey, // Используем docKey как идентификатор + block, + kind: 'question', + }); + }); + }); + + // Затем кастомные блоки + customFileBlocks.forEach((block) => { + groups.push({ + index: groupIndex++, + question: 'custom', + block, + kind: 'custom', + }); + }); + + const guessFieldName = (group: UploadGroup): string => { + const cat = (group.block.category || group.question || '').toLowerCase(); + + // Определяем имя поля на основе категории (которая теперь равна doc.id) + if (cat.includes('contract') || cat === 'contract' || cat === 'договор') { + return 'upload_contract'; + } + if (cat.includes('payment') || cat.includes('cheque') || cat.includes('receipt') || + cat.includes('подтверждение') || cat === 'payment_proof') { + return 'upload_payment'; + } + if (cat.includes('correspondence') || cat.includes('chat') || cat.includes('переписка')) { + return 'upload_correspondence'; + } + // Если категория похожа на ID документа, используем её + if (cat && !cat.includes('_exist')) { + return `upload_${cat.replace(/[^a-z0-9_]/g, '_')}`; + } + // Fallback на индекс + return `upload_${group.index}`; + }; + + groups.forEach((group) => { + const i = group.index; + const block = group.block; + + // Описание группы + formPayload.append( + `uploads_descriptions[${i}]`, + block.description || '' + ); + + // Имя "поля" группы + formPayload.append( + `uploads_field_names[${i}]`, + guessFieldName(group) + ); + + // Файлы: uploads[i][j] + block.files.forEach((file, j) => { + const origin: any = (file as any).originFileObj; + if (!origin) return; + formPayload.append(`uploads[${i}][${j}]`, origin, origin.name); + }); + }); + + const response = await fetch('/api/v1/claims/wizard', { + method: 'POST', + body: formPayload, + }); + + const text = await response.text(); + let parsed: any = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + + if (!response.ok) { + message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.'); + addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', { + status: response.status, + body: text, + }); + return; + } + + addDebugEvent?.('wizard', 'success', '✅ Визард отправлен в n8n', { + response: parsed ?? text, + }); + message.success('Мы изучаем ваш вопрос и документы.'); + } catch (error) { + message.error('Ошибка соединения при отправке визарда.'); + addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', { + error: String(error), + }); + } finally { + setSubmitting(false); + } + onNext(); }; const renderQuestionField = (question: WizardQuestion) => { + // Обработка по input_type для более точного определения типа поля + if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') { + return ( + + + {question.options?.map((option) => ( + + {option.label} + + ))} + + + ); + } + switch (question.control) { case 'textarea': case 'input[type="textarea"]': @@ -488,6 +750,14 @@ export default function StepWizardPlan({ autoSize={{ minRows: 3, maxRows: 6 }} /> ); + case 'input[type="date"]': + return ( + + ); case 'input[type="radio"]': return ( @@ -510,49 +780,93 @@ export default function StepWizardPlan({ const docLabel = docList.map((doc) => doc.name).join(', '); const accept = docList.flatMap((doc) => doc.accept || []); const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png'])); + + // Если документ предопределён (конкретный тип, не общий), не показываем лишние поля + // Предопределённые документы: contract, payment, payment_confirmation и их вариации + const doc = docList[0]; + const isPredefinedDoc = docList.length === 1 && doc && doc.id && + !doc.id.includes('_exist') && + (doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' || + doc.id.includes('contract') || doc.id.includes('payment') || doc.id.includes('receipt') || + doc.id.includes('cheque') || doc.id.includes('чек')); + const singleDocName = isPredefinedDoc ? doc.name : null; + const isRequired = docList.some(doc => doc.required); + const isSkipped = skippedDocuments.has(docId); return ( - {currentBlocks.map((block, idx) => ( + {/* Чекбокс "Пропустить" для обязательных документов */} + {isRequired && ( +
+ { + const newSkipped = new Set(skippedDocuments); + if (e.target.checked) { + newSkipped.add(docId); + } else { + newSkipped.delete(docId); + } + setSkippedDocuments(newSkipped); + updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) }); + }} + > + У меня нет этого документа + +
+ )} + + {!isSkipped && currentBlocks.map((block, idx) => ( removeDocumentBlock(docId, block.id)} - > - Удалить - + currentBlocks.length > 1 && ( + + ) } > - - updateDocumentBlock(docId, block.id, { description: e.target.value }) - } - /> - + {/* Поле описания только для необязательных/кастомных документов */} + {/* Для обязательных документов (contract, payment) описание не требуется */} + {!isPredefinedDoc && !isRequired && ( + + updateDocumentBlock(docId, block.id, { description: e.target.value }) + } + /> + )} + + {/* Выпадашка категорий только для общих вопросов (docs_exist, correspondence_exist) */} + {!isPredefinedDoc && ( + + )} + false} @@ -561,10 +875,10 @@ export default function StepWizardPlan({ updateDocumentBlock(docId, block.id, { files: fileList }) } accept={uniqueAccept.map((ext) => `.${ext}`).join(',')} - style={{ background: '#f8f9ff' }} + style={{ background: '#fafafa' }} >

- +

Перетащите файлы или нажмите для загрузки

@@ -574,13 +888,18 @@ export default function StepWizardPlan({ ))} - + {/* Кнопка "Добавить" только если документ не пропущен */} + {!isSkipped && (!isPredefinedDoc || currentBlocks.length === 0) && ( + + )} ); }; @@ -588,8 +907,8 @@ export default function StepWizardPlan({ const renderCustomUploads = () => ( }> Добавить блок @@ -642,7 +961,7 @@ export default function StepWizardPlan({ accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic" >

- +

Перетащите файлы или нажмите для загрузки

Максимум 10 файлов, до 20 МБ каждый.

@@ -663,7 +982,7 @@ export default function StepWizardPlan({ <>
@@ -681,44 +1000,94 @@ export default function StepWizardPlan({ onFinish={handleFinish} initialValues={{ ...prefillMap, ...formData.wizardAnswers }} > - {questions.map((question) => ( - - {() => { - const values = form.getFieldsValue(true); - if (!evaluateCondition(question.ask_if, values)) { - return null; - } - const questionDocs = documentGroups[question.name] || []; - const questionValue = values?.[question.name]; - return ( - <> - - {renderQuestionField(question)} - - {questionDocs.length > 0 && isAffirmative(questionValue) && ( -
- Загрузите документы: - {renderDocumentBlocks(question.name, questionDocs)} -
- )} - - ); - }} -
- ))} + {questions.map((question) => { + // Для условных полей используем dependencies для отслеживания изменений + const dependencies = question.ask_if ? [question.ask_if.field] : undefined; + + return ( + { + // Обновляем только если изменилось значение поля, от которого зависит вопрос + return prev[question.ask_if!.field] !== curr[question.ask_if!.field]; + } : undefined} + > + {() => { + const values = form.getFieldsValue(true); + if (!evaluateCondition(question.ask_if, values)) { + return null; + } + const questionDocs = documentGroups[question.name] || []; + const questionValue = values?.[question.name]; + + // Скрываем вопросы, которые связаны с загрузкой документов + // Если в плане визарда есть документы, не показываем поля про загрузку (text/textarea/file) + const questionLabelLower = (question.label || '').toLowerCase(); + const questionNameLower = (question.name || '').toLowerCase(); + const isDocumentUploadQuestion = + (question.input_type === 'text' || + question.input_type === 'textarea' || + question.input_type === 'file') && + (questionLabelLower.includes('загрузите') || + questionLabelLower.includes('фото') || + questionLabelLower.includes('сканы') || + questionLabelLower.includes('документ') || + questionLabelLower.includes('договор') || + questionLabelLower.includes('чек') || + questionLabelLower.includes('платеж') || + questionLabelLower.includes('копии') || + questionLabelLower.includes('переписк') || + questionNameLower.includes('upload') || + questionNameLower.includes('document')); + + // Если это вопрос про загрузку документов И в плане есть документы, не показываем поле + // (даже если вопрос не связан с documentGroups) + // Загрузка файлов уже реализована через блоки документов (documents) + if (isDocumentUploadQuestion && documents.length > 0) { + return null; + } + + return ( + <> + + {renderQuestionField(question)} + + {questionDocs.length > 0 && isAffirmative(questionValue) && ( +
+ Загрузите документы: + + {questionDocs.map((doc) => { + // Используем doc.id как ключ для отдельного хранения блоков каждого документа + const docKey = doc.id || doc.name || `doc_${question.name}`; + return ( +
+ {renderDocumentBlocks(docKey, [doc])} +
+ ); + })} +
+
+ )} + + ); + }} +
+ ); + })} - @@ -751,9 +1120,9 @@ export default function StepWizardPlan({ {isWaiting && ( @@ -791,7 +1160,7 @@ export default function StepWizardPlan({ {!isWaiting && plan && (
- <ThunderboltOutlined style={{ color: '#6366f1' }} /> План действий + <ThunderboltOutlined style={{ color: '#595959' }} /> План действий {plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'} @@ -801,9 +1170,9 @@ export default function StepWizardPlan({ ; wizardCoverageReport?: any; wizardUploads?: Record; + wizardSkippedDocuments?: string[]; // Шаг 3: Event Type eventType?: string; @@ -81,12 +87,33 @@ export default function ClaimForm() { }); const [isPhoneVerified, setIsPhoneVerified] = useState(false); const [debugEvents, setDebugEvents] = useState([]); + const [isSubmitted, setIsSubmitted] = useState(false); + const [showDraftSelection, setShowDraftSelection] = useState(false); + const [selectedDraftId, setSelectedDraftId] = useState(null); + const [hasDrafts, setHasDrafts] = useState(false); useEffect(() => { // 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился! console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!'); }, []); + // Получаем IP клиента один раз при монтировании + useEffect(() => { + const fetchClientIp = async () => { + try { + const response = await fetch('/api/v1/utils/client-ip'); + if (!response.ok) return; + const data = await response.json(); + if (data?.ip) { + setFormData((prev) => ({ ...prev, clientIp: data.ip })); + } + } catch { + // Тихо игнорируем, IP всегда можно взять на бэке из request + } + }; + fetchClientIp(); + }, []); + // Динамически определяем список шагов на основе выбранного eventType const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : []; const totalDocumentSteps = documentConfigs.length; @@ -127,51 +154,196 @@ export default function ClaimForm() { }); }, []); + // Загрузка черновика + const loadDraft = useCallback(async (claimId: string) => { + try { + const response = await fetch(`/api/v1/claims/drafts/${claimId}`); + if (!response.ok) { + throw new Error('Не удалось загрузить черновик'); + } + + const data = await response.json(); + const claim = data.claim; + const payload = claim.payload || {}; + + // Восстанавливаем данные формы из черновика + updateFormData({ + claim_id: claim.claim_id, + session_id: claim.session_token || sessionId, + phone: payload.phone || formData.phone, + email: payload.email || formData.email, + problemDescription: payload.problem_description || formData.problemDescription, + wizardPlan: payload.wizard_plan || formData.wizardPlan, + wizardAnswers: payload.answers || formData.wizardAnswers, + wizardPrefill: payload.answers_prefill ? + payload.answers_prefill.reduce((acc: any, item: any) => { + acc[item.name] = item.value; + return acc; + }, {}) : formData.wizardPrefill, + wizardPrefillArray: payload.answers_prefill || formData.wizardPrefillArray, + wizardCoverageReport: payload.coverage_report || formData.wizardCoverageReport, + wizardUploads: { + documents: payload.documents_meta ? {} : formData.wizardUploads?.documents, + custom: formData.wizardUploads?.custom || [], + }, + wizardSkippedDocuments: payload.wizard_skipped_documents || formData.wizardSkippedDocuments, + eventType: payload.event_type || formData.eventType, + contact_id: payload.contact_id || formData.contact_id, + project_id: payload.project_id || formData.project_id, + }); + + setSelectedDraftId(claimId); + setShowDraftSelection(false); + + // Переходим к шагу с описанием, если оно есть, иначе к шагу с рекомендациями + if (payload.problem_description) { + // Если есть описание, переходим к шагу с рекомендациями + setCurrentStep(2); // StepWizardPlan + } else { + // Если нет описания, переходим к шагу с описанием + setCurrentStep(1); // StepDescription + } + } catch (error) { + console.error('Ошибка загрузки черновика:', error); + message.error('Не удалось загрузить черновик'); + } + }, [formData, sessionId, updateFormData]); + + // Обработчик выбора черновика + const handleSelectDraft = useCallback((claimId: string) => { + loadDraft(claimId); + }, [loadDraft]); + + // Проверка наличия черновиков + const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => { + try { + const params = new URLSearchParams(); + // Приоритет: unified_id > phone > session_id + if (unified_id) { + params.append('unified_id', unified_id); + } else if (phone) { + params.append('phone', phone); + } else if (sessionId) { + params.append('session_id', sessionId); + } else { + return false; + } + + const url = `/api/v1/claims/drafts/list?${params.toString()}`; + console.log('🔍 Запрос черновиков:', url); + + const response = await fetch(url); + if (!response.ok) { + console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText); + return false; + } + + const data = await response.json(); + console.log('🔍 Ответ API черновиков:', data); + const count = data.count || 0; + console.log('🔍 Количество черновиков:', count); + + setHasDrafts(count > 0); + setShowDraftSelection(count > 0); + return count > 0; + } catch (error) { + console.error('Ошибка проверки черновиков:', error); + return false; + } + }, []); + + // Обработчик создания новой заявки + const handleNewClaim = useCallback(() => { + setShowDraftSelection(false); + setSelectedDraftId(null); + // Очищаем данные формы, кроме телефона и session_id + updateFormData({ + claim_id: undefined, + problemDescription: undefined, + wizardPlan: undefined, + wizardAnswers: undefined, + wizardPrefill: undefined, + wizardPrefillArray: undefined, + wizardCoverageReport: undefined, + wizardUploads: undefined, + wizardSkippedDocuments: undefined, + eventType: undefined, + }); + // Переходим к шагу с описанием + setCurrentStep(1); + }, [updateFormData]); + const handleSubmit = useCallback(async () => { try { - addDebugEvent('form', 'info', '📤 Отправка заявки на сервер'); - - const response = await fetch(`${API_BASE_URL}/api/v1/claims/create`, { + addDebugEvent('form', 'info', '📤 Отправка заявки в n8n через backend'); + + const payload = { + stage: 'final', + form_id: 'ticket_form', + session_id: formData.session_id ?? sessionId, + client_ip: formData.clientIp, + sms_code: formData.smsCode, + + // Базовые идентификаторы + claim_id: formData.claim_id, + contact_id: formData.contact_id, + project_id: formData.project_id, + ticket_id: formData.ticket_id, + is_new_contact: formData.is_new_contact, + is_new_project: formData.is_new_project, + + // Основные поля формы (для удобства в n8n) + voucher: formData.voucher, + phone: formData.phone, + email: formData.email, + event_type: formData.eventType, + payment_method: formData.paymentMethod, + bank_name: formData.bankName, + card_number: formData.cardNumber, + account_number: formData.accountNumber, + + // Старый блок документов + новые загрузки визарда (пока как есть) + documents: formData.documents || {}, + wizard_uploads: formData.wizardUploads || {}, + + // Всё состояние формы целиком — на всякий случай + form: formData, + }; + + const response = await fetch('/api/v1/claims/create', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - claim_id: formData.claim_id, // ✅ Используем claim_id от n8n - voucher: formData.voucher, - email: formData.email, - phone: formData.phone, - event_type: formData.eventType, - payment_method: formData.paymentMethod, - bank_name: formData.bankName, - card_number: formData.cardNumber, - account_number: formData.accountNumber, - documents: formData.documents || {}, - }), + body: JSON.stringify(payload), }); - const result = await response.json(); - - if (result.success) { - message.success(`Заявка ${result.claim_number} успешно создана!`); - addDebugEvent('form', 'success', `✅ Заявка ${result.claim_number} создана`); - - // Сброс формы (создаём новую заявку, claim_id будет сгенерирован при следующем SMS) - setFormData({ - voucher: '', - claim_id: undefined, // ✅ Очищаем для новой заявки - session_id: sessionId, - paymentMethod: 'sbp', - }); - setCurrentStep(0); - setIsPhoneVerified(false); - } else { - message.error('Ошибка при создании заявки'); - addDebugEvent('form', 'error', '❌ Ошибка создания заявки'); + const text = await response.text(); + let parsed: any = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; } + + if (!response.ok) { + message.error('Ошибка при создании заявки (n8n)'); + addDebugEvent('form', 'error', '❌ Ошибка создания заявки в n8n', { + status: response.status, + body: text, + }); + return; + } + + addDebugEvent('form', 'success', '✅ Финальный webhook в n8n отработал', { + response: parsed ?? text, + }); + // Помечаем, что заявка отправлена, и показываем заглушку. + setIsSubmitted(true); + message.success('Данные отправлены, заявка принята в обработку.'); } catch (error) { message.error('Ошибка соединения с сервером'); - addDebugEvent('form', 'error', '❌ Ошибка соединения'); + addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) }); console.error(error); } }, [formData, sessionId, addDebugEvent]); @@ -180,6 +352,22 @@ export default function ClaimForm() { const steps = useMemo(() => { const stepsArray: any[] = []; + // Шаг 0: Выбор черновика (показывается только если есть черновики и телефон верифицирован) + if (showDraftSelection && isPhoneVerified && !selectedDraftId && hasDrafts) { + stepsArray.push({ + title: 'Черновики', + description: 'Выбор заявки', + content: ( + + ), + }); + } + // Шаг 1: Phone (телефон + SMS верификация) stepsArray.push({ title: 'Телефон', @@ -187,11 +375,52 @@ export default function ClaimForm() { content: ( { + updateFormData(data); + // После верификации телефона проверяем черновики + if (data.phone && isPhoneVerified && !selectedDraftId && !showDraftSelection) { + setShowDraftSelection(true); + } + }} + onNext={async (unified_id?: string) => { + console.log('🔥 onNext вызван с unified_id:', unified_id); + console.log('🔥 formData.unified_id:', formData.unified_id); + console.log('🔥 isPhoneVerified:', isPhoneVerified); + console.log('🔥 selectedDraftId:', selectedDraftId); + + // После верификации проверяем черновики + // Используем unified_id из параметра (если передан) или из formData + const finalUnifiedId = unified_id || formData.unified_id; + console.log('🔥 finalUnifiedId:', finalUnifiedId); + + if (formData.phone && isPhoneVerified && !selectedDraftId) { + console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone); + const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionId); + console.log('🔍 Результат checkDrafts:', hasDraftsResult); + if (hasDraftsResult) { + console.log('✅ Есть черновики, переходим к шагу 0'); + setCurrentStep(0); // Переходим к шагу выбора черновика + } else { + console.log('❌ Нет черновиков, идем дальше'); + nextStep(); // Нет черновиков, идем дальше + } + } else { + console.log('⚠️ Условие не выполнено, идем дальше'); + nextStep(); + } + }} onPrev={prevStep} isPhoneVerified={isPhoneVerified} - setIsPhoneVerified={setIsPhoneVerified} + setIsPhoneVerified={async (verified: boolean) => { + setIsPhoneVerified(verified); + // После верификации проверяем черновики + if (verified && formData.phone && !selectedDraftId) { + const hasDraftsResult = await checkDrafts(formData.unified_id, formData.phone, sessionId); + if (hasDraftsResult) { + setCurrentStep(0); // Переходим к шагу выбора черновика + } + } + }} addDebugEvent={addDebugEvent} /> ), @@ -296,9 +525,10 @@ export default function ClaimForm() { }); return stepsArray; - }, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent]); + }, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]); const handleReset = () => { + setIsSubmitted(false); setFormData({ voucher: '', claim_id: undefined, // ✅ Очищаем для новой заявки @@ -312,7 +542,7 @@ export default function ClaimForm() { }; return ( -
+
{/* Левая часть - Форма */} @@ -320,7 +550,7 @@ export default function ClaimForm() { title="Подать заявку на выплату" className="claim-form-card" extra={ - currentStep > 0 && ( + !isSubmitted && currentStep > 0 && (