Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
210
docs/CLAIMSAVE_FINAL_SQL.md
Normal file
210
docs/CLAIMSAVE_FINAL_SQL.md
Normal file
@@ -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.
|
||||
|
||||
103
docs/CODE1_FIX.md
Normal file
103
docs/CODE1_FIX.md
Normal file
@@ -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` это критично, т.к. она вызывается первой и падает.
|
||||
|
||||
212
docs/CODE1_FIXED_CODE.js
Normal file
212
docs/CODE1_FIXED_CODE.js
Normal file
@@ -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 }];
|
||||
|
||||
183
docs/DATABASE_SCHEMA.md
Normal file
183
docs/DATABASE_SCHEMA.md
Normal file
@@ -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)
|
||||
```
|
||||
|
||||
285
docs/FIXED_SQL_QUERY.md
Normal file
285
docs/FIXED_SQL_QUERY.md
Normal file
@@ -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) более надежный и правильный.
|
||||
|
||||
38
docs/N8N_CODE_NODE_RESPONSE.js
Normal file
38
docs/N8N_CODE_NODE_RESPONSE.js
Normal file
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
47
docs/N8N_CODE_NODE_RESPONSE_SAFE.js
Normal file
47
docs/N8N_CODE_NODE_RESPONSE_SAFE.js
Normal file
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
94
docs/N8N_RESPONSE_FORMAT.md
Normal file
94
docs/N8N_RESPONSE_FORMAT.md
Normal file
@@ -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`
|
||||
|
||||
144
docs/N8N_RESPONSE_WITH_UNIFIED_ID.md
Normal file
144
docs/N8N_RESPONSE_WITH_UNIFIED_ID.md
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
133
docs/N8N_USER_CREATION_INSTRUCTIONS.md
Normal file
133
docs/N8N_USER_CREATION_INSTRUCTIONS.md
Normal file
@@ -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_...`.
|
||||
|
||||
431
docs/PERSONAL_CABINET_ARCHITECTURE.md
Normal file
431
docs/PERSONAL_CABINET_ARCHITECTURE.md
Normal file
@@ -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)
|
||||
- ✅ Гибкость (можно отключить кеш, если не нужен)
|
||||
|
||||
### Когда использовать:
|
||||
- ✅ Личный кабинет (список незавершенных заявок)
|
||||
- ✅ Возобновление заполнения формы
|
||||
- ✅ Быстрая загрузка состояния формы
|
||||
|
||||
73
docs/PROMPT_UPDATE_GUIDE.md
Normal file
73
docs/PROMPT_UPDATE_GUIDE.md
Normal file
@@ -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. Проверить, что всё работает как раньше
|
||||
|
||||
191
docs/REDIS_CLAIM_STORAGE_ANALYSIS.md
Normal file
191
docs/REDIS_CLAIM_STORAGE_ANALYSIS.md
Normal file
@@ -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}`).
|
||||
|
||||
198
docs/REDIS_VS_POSTGRESQL_SPEED.md
Normal file
198
docs/REDIS_VS_POSTGRESQL_SPEED.md
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Но это опционально и не обязательно для веб-формы.
|
||||
|
||||
72
docs/SESSION_LOG_2025-11-19.md
Normal file
72
docs/SESSION_LOG_2025-11-19.md
Normal file
@@ -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 не возвращает данные - проверить формат параметров
|
||||
|
||||
114
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql
Normal file
114
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql
Normal file
@@ -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;
|
||||
131
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md
Normal file
131
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md
Normal file
@@ -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`
|
||||
|
||||
72
docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
Normal file
72
docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
Normal file
@@ -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;
|
||||
|
||||
261
docs/WIZARD_API_ALTERNATIVES.md
Normal file
261
docs/WIZARD_API_ALTERNATIVES.md
Normal file
@@ -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: "Дата покупки" }
|
||||
}
|
||||
};
|
||||
|
||||
<Form schema={schema} />
|
||||
```
|
||||
|
||||
**Вывод:** Полезно для рендеринга, но структуру всё равно нужно генерировать самим.
|
||||
|
||||
---
|
||||
|
||||
### 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` или свой компонент
|
||||
|
||||
**Это оптимальный баланс скорости, контроля и стоимости.**
|
||||
|
||||
448
docs/WIZARD_CACHING_STRATEGY.md
Normal file
448
docs/WIZARD_CACHING_STRATEGY.md
Normal file
@@ -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 сек)
|
||||
|
||||
55
docs/WIZARD_OPTIMIZATION.md
Normal file
55
docs/WIZARD_OPTIMIZATION.md
Normal file
@@ -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 сек
|
||||
|
||||
|
||||
264
docs/WIZARD_OPTIMIZATION_ANALYSIS.md
Normal file
264
docs/WIZARD_OPTIMIZATION_ANALYSIS.md
Normal file
@@ -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 секунд
|
||||
|
||||
58
docs/WIZARD_SPEEDUP_GUIDE.md
Normal file
58
docs/WIZARD_SPEEDUP_GUIDE.md
Normal file
@@ -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`
|
||||
|
||||
|
||||
211
docs/WORKFLOW_ANALYSIS.md
Normal file
211
docs/WORKFLOW_ANALYSIS.md
Normal file
@@ -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`
|
||||
|
||||
218
docs/WORKFLOW_OCR_ANALYSIS.md
Normal file
218
docs/WORKFLOW_OCR_ANALYSIS.md
Normal file
@@ -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) для всех систем
|
||||
|
||||
61
docs/optimized_ai_agent_node.json
Normal file
61
docs/optimized_ai_agent_node.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
docs/optimized_wizard_prompt.txt
Normal file
60
docs/optimized_wizard_prompt.txt
Normal file
@@ -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.
|
||||
|
||||
|
||||
113
docs/wizard_prompt_n8n.txt
Normal file
113
docs/wizard_prompt_n8n.txt
Normal file
@@ -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 согласно схеме.
|
||||
|
||||
406
docs/wizard_prompt_simple.txt
Normal file
406
docs/wizard_prompt_simple.txt
Normal file
@@ -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 разметки.
|
||||
|
||||
Reference in New Issue
Block a user