feat(n8n): RAG workflow для извлечения данных из документов

- Обновлён Code1: нормализация данных из give_data1 с поддержкой payload.applicant, ai_analysis, wizard_plan
- Обновлён Code6: генерация промптов для RAG (user, project, offenders)
- Добавлена документация по настройке n8n нод для OCR статуса
- Добавлен эндпоинт check-ocr-status в documents.py
- Добавлен лог сессии с полным описанием workflow

Workflow itX62h38faB51y9J успешно извлекает:
- Данные пользователя (ФИО, контакты, адрес)
- Данные проекта (сумма, предмет, даты договора)
- Несколько контрагентов с разными ролями (seller, service_provider)
This commit is contained in:
Fedor
2025-11-29 19:29:14 +03:00
parent 840acca51a
commit 985ee23810
11 changed files with 715 additions and 1 deletions

View File

@@ -0,0 +1,433 @@
# Лог сессии: Настройка RAG workflow для извлечения данных
**Дата:** 2025-11-29
**Workflow ID:** `itX62h38faB51y9J` ("6 ocr_check:attempt")
---
## 🎯 Цель сессии
Настроить workflow для автоматического извлечения данных из документов с использованием RAG (Retrieval-Augmented Generation) для заполнения формы заявления.
---
## 📊 Структура workflow
```
ocr_check:attempt (Redis Trigger)
clime_id (Set) → извлекает claim_id, session_id
analiz (Set) → добавляет prefix, session_token
give_data1 (PostgreSQL) → большой SQL, собирает все данные
Code1 (Code) → нормализует данные
prepare_rag_items (Code) → создаёт 3 items: user, project, offenders
Loop Over Items → итерация по типам
Code6 (Code) → генерация промптов для AI
AI Agent2 (LLM + RAG) → извлечение данных из документов
Code5 (Code) → парсинг JSON из LLM
Edit Fields4 → извлекает output
Aggregate → собирает все результаты
dataset (Set) → финальная сборка
```
---
## 📝 Обновлённые ноды
### 1. Code1 — нормализация данных из give_data1
```javascript
// Code1 — нормализация данных из give_data1
// ИСПРАВЛЕНО: извлекаем payload.applicant, ai_analysis, wizard_plan, полные documents
function toNullish(v) {
if (v === undefined || v === null) return null;
if (typeof v === 'string' && v.trim() === '') return null;
return v;
}
function pick(...vals) {
return vals.find(v => v !== undefined && v !== null && v !== '') ?? null;
}
function mapDocuments(docs = []) {
if (!docs || !Array.isArray(docs)) return [];
return docs.map(d => ({
id: toNullish(d.id),
claim_document_id: toNullish(d.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),
uploaded_at: toNullish(d.uploaded_at),
filename_for_upload: toNullish(d.filename_for_upload),
// AI данные
document_type: toNullish(d.document_type),
document_label: toNullish(d.document_label),
document_summary: toNullish(d.document_summary),
ocr_status: toNullish(d.ocr_status),
match_score: toNullish(d.match_score),
match_status: toNullish(d.match_status),
match_reason: toNullish(d.match_reason),
}));
}
function mapCombinedDocs(cds = []) {
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),
combined_text: toNullish(c.combined_text),
}));
}
function normalizeOne(src) {
const claim = src.claim ?? {};
const payload = claim.payload ?? {};
const userInfo = src.user_info ?? {};
// Извлекаем applicant из payload
const applicant = payload.applicant ?? {};
const aiAnalysis = payload.ai_analysis ?? {};
const answersPrefill = payload.answers_prefill ?? [];
const wizardPlan = payload.wizard_plan ?? null;
// USER: приоритет payload.applicant
const user = {
firstname: pick(applicant.firstname, applicant.first_name),
secondname: pick(applicant.middle_name, applicant.secondname),
lastname: pick(applicant.lastname, applicant.last_name),
mobile: pick(payload.phone),
email: pick(payload.email),
tgid: pick(claim.telegram_id, payload.tg_id),
birthday: pick(applicant.birthday, applicant.birth_date),
birthplace: pick(applicant.birthplace, applicant.birth_place),
mailingstreet: pick(applicant.address),
inn: pick(applicant.inn),
zip: pick(applicant.zip),
channel: pick(userInfo.channel, claim.channel),
unified_id: pick(claim.unified_id),
session_token: pick(claim.session_token),
};
// CASE
const caseData = {
id: toNullish(claim.id),
prefix: toNullish(claim.prefix),
channel: toNullish(claim.channel),
type_code: toNullish(claim.type_code),
status_code: toNullish(claim.status_code),
created_at: toNullish(claim.created_at),
updated_at: toNullish(claim.updated_at),
telegram_id: toNullish(claim.telegram_id),
session_token: toNullish(claim.session_token),
unified_id: toNullish(claim.unified_id),
case_type: pick(wizardPlan?.case_type, claim.type_code),
};
// ANSWERS
const answers = {};
if (Array.isArray(answersPrefill)) {
answersPrefill.forEach(a => {
if (a?.name && a?.value !== undefined) {
answers[a.name] = a.value;
}
});
}
// AI_ANALYSIS
const ai = {
problem: toNullish(aiAnalysis.problem),
facts_short: toNullish(aiAnalysis.facts_short),
facts_full: toNullish(aiAnalysis.facts_full),
recommendation: toNullish(aiAnalysis.recommendation),
};
const problemDescription = toNullish(payload.problem_description);
return {
case: caseData,
user,
answers: Object.keys(answers).length ? answers : null,
answers_prefill: answersPrefill.length ? answersPrefill : null,
ai_analysis: ai,
problem_description: problemDescription,
documents: mapDocuments(src.documents),
combined_docs: mapCombinedDocs(src.combined_docs),
wizard_plan: wizardPlan,
meta: {
claim_id: caseData.id,
session_token: caseData.session_token,
unified_id: caseData.unified_id,
}
};
}
const raw = items[0]?.json ?? {};
const arr = Array.isArray(raw) ? raw : [raw];
const results = arr.map(normalizeOne).map(obj => ({ json: obj }));
return results.length ? results : [{ json: null }];
```
---
### 2. Code6 — генерация промптов для RAG
```javascript
// n8n Code node: Генерация prompt'а под конкретный тип
// ВХОД: { type: 'user'|'project'|'offenders', data: {...} }
const type = $json.type;
const data = $json.data;
const code1Data = (() => {
try {
return $('Code1').first().json || {};
} catch(_) {
return {};
}
})();
const aiAnalysis = code1Data.ai_analysis || {};
const problemDescription = code1Data.problem_description || '';
const wizardPlan = code1Data.wizard_plan || {};
const caseType = wizardPlan.case_type || code1Data.case?.type_code || 'consumer';
let schema = '';
let searchHints = '';
let contextInfo = '';
contextInfo = `
КОНТЕКСТ ДЕЛА:
- Тип: ${caseType}
- Проблема: ${aiAnalysis.problem || 'не указана'}
- Краткие факты: ${aiAnalysis.facts_short || 'не указаны'}
`;
if (type === 'user') {
schema = `{
"user": {
"firstname": string|null,
"secondname": string|null,
"lastname": string|null,
"mobile": string|null,
"email": string|null,
"tgid": number|null,
"birthday": "YYYY-MM-DD"|null,
"birthplace": string|null,
"mailingstreet": string|null,
"inn": string|null (12 цифр для физлица)
}
}`;
searchHints = `Ищи данные ПОКУПАТЕЛЯ/ЗАКАЗЧИКА:
- ФИО: после "Покупатель:", "Заказчик:", "Потребитель:"
- Адрес: "адрес регистрации", "адрес проживания", "место жительства"
- ИНН физлица = 12 цифр
- Телефон: в реквизитах, после "тел:", "моб:"
- Email: в реквизитах`;
} else if (type === 'project') {
schema = `{
"project": {
"category": string|null (тема обращения),
"direction": string|null,
"agrprice": number|null (сумма в рублях, только цифры!),
"subject": string|null (предмет договора - что купили/заказали),
"agrdate": "YYYY-MM-DD"|null (дата заключения договора),
"startdate": "YYYY-MM-DD"|null (дата начала услуги/поездки),
"finishdate": "YYYY-MM-DD"|null (дата окончания),
"country": string|null (страна для турпутёвок),
"hotel": string|null (название отеля),
"transport": "да"|"нет"|null (включён ли трансфер),
"insurance": "да"|"нет"|null (включена ли страховка),
"description": string|null (краткое описание сделки)
}
}`;
searchHints = `Ищи данные ДОГОВОРА/СДЕЛКИ:
- Сумма: "Цена договора", "Стоимость", "Итого к оплате", "Сумма заказа"
- Дата: "Дата заключения", "Договор № ... от ...", "Заказ от ..."
- Предмет: что именно купили или заказали (товар, услуга, тур)
- Для туров: страна, отель, даты заезда/выезда`;
} else if (type === 'offenders') {
schema = `{
"offenders": [
{
"role": "seller"|"service_provider"|"tour_agent"|"tour_operator"|"delivery"|"installer"|"intermediary"|null,
"accountname": string|null (название организации или ФИО ИП),
"address": string|null (юридический адрес),
"email": string|null,
"website": string|null,
"phone": string|null,
"inn": string|null (10 цифр для юрлица, 12 для ИП),
"ogrn": string|null (13 цифр ОГРН или 15 цифр ОГРНИП)
}
]
}`;
searchHints = `Ищи данные ВСЕХ КОНТРАГЕНТОВ (может быть несколько!):
ГДЕ ИСКАТЬ:
- "Продавец:", "Исполнитель:", "Поставщик:"
- После ООО, ИП, ЗАО, ОАО, ПАО, АО
- В реквизитах договора, в шапке чека
РЕКВИЗИТЫ:
- ИНН юрлица = 10 цифр, ИП = 12 цифр
- ОГРН = 13 цифр, ОГРНИП = 15 цифр
РОЛИ (определи по контексту):
- seller — продавец товара (магазин, салон)
- service_provider — исполнитель услуги
- tour_agent — турагент (кто продал путёвку)
- tour_operator — туроператор (кто организует тур, указан в договоре отдельно)
- delivery — служба доставки
- installer — сборщик/установщик
- intermediary — посредник, маркетплейс
ВАЖНО: Если в документах несколько организаций — добавь всех!`;
}
const filledCount = Object.values(data || {}).filter(v => v !== null && v !== undefined && v !== '').length;
const totalCount = Object.keys(data || {}).length;
return [{
json: {
systemMessage: `Ты — юридический помощник-экстрактор. У тебя есть инструмент vectorStore для поиска по документам.
${contextInfo}
${searchHints}
ПРАВИЛА:
1. Ищи только поля из схемы ниже
2. Возвращай строго JSON в указанном формате
3. Если данные не найдены — ставь null
4. НЕ ПРИДУМЫВАЙ данные!
5. Дозаполняй только пустые/null поля`,
userMessage: `Текущие данные (заполнено ${filledCount} из ${totalCount}, дозаполни остальное):
${JSON.stringify(data, null, 2)}
Схема для ответа:
${schema}`,
_meta: {
type,
filledCount,
totalCount,
caseType
}
}
}];
```
---
### 3. prepare_rag_items — создание 3 items для RAG (НУЖНО ДОБАВИТЬ!)
```javascript
// Code node: prepare_rag_items
// Создаёт 3 items для RAG: user, project, offenders
// Вставить между Code1 и Loop Over Items
const src = $('Code1').first().json;
// USER — из уже собранных данных Code1
const userData = src.user || {};
// PROJECT — пока пустой, RAG заполнит
const projectData = {
category: null,
direction: null,
agrprice: null,
subject: null,
agrdate: null,
startdate: null,
finishdate: null,
country: null,
hotel: null,
transport: null,
insurance: null,
description: src.problem_description || null,
};
// OFFENDERS — пока пустой массив, RAG найдёт
const offendersData = [];
// Выдаём 3 items для Loop Over Items
return [
{ json: { type: 'user', data: userData } },
{ json: { type: 'project', data: projectData } },
{ json: { type: 'offenders', data: offendersData } }
];
```
---
## ✅ Результат работы workflow
Тестовый запуск на claim `509872e2-9666-4c5e-8ab7-2304dd6a5d18`:
### USER — полностью заполнен
- firstname: Федор
- secondname: Владимирович
- lastname: Коробков
- mobile: 79262306381
- email: help@clientright.ru
- tgid: 295410106
- birthday: 1981-09-18
- birthplace: Москва
- mailingstreet: МО, г. Балашиха, мкр. Железнодорожный, ул. Советская, д.20, кв. 52
### PROJECT — основное заполнено
- category: задержка ремонта/недоставка комплектующих и отказ в оказании услуги сборки
- agrprice: **89620** (сумма договора)
- subject: кровать-подиум Hemwood Base 180х200 и тумбы к ней
- agrdate: **2025-08-09** (дата договора)
- startdate: **2025-08-16** (дата доставки)
- description: полное описание проблемы
### OFFENDERS — найдено 2 контрагента!
**1. Продавец (seller):**
- accountname: ИП Хациев Зелимхан Зелимханович
- inn: 201471261963 (12 цифр — ИП ✅)
- ogrn: 315774600000123 (15 цифр — ОГРНИП ✅)
- website: raiton.ru
**2. Исполнитель услуг (service_provider):**
- accountname: АО «ОРМАТЕК»
- inn: 7724890784 (10 цифр — юрлицо ✅)
- email: kassa@ormatek.com
---
## 📋 TODO (следующие шаги)
1. [ ] Добавить ноду `prepare_rag_items` между Code1 и Loop Over Items
2. [ ] Добавить постобработку данных (валидация, исправление ошибок AI)
3. [ ] Сохранение результата в Redis для формы
4. [ ] Подключить к генерации формы заявления
---
## 📁 Связанные файлы
- `ticket_form/docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED_FIXED.sql` — SQL для сохранения документов
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` — шаблон формы заявления

View File

@@ -0,0 +1,99 @@
# Настройка OCR Status Tracking в n8n
## Шаги для добавления нод в workflow `fnSo3FTTbQcMjwt3`
### 1. Открой workflow в n8n
https://n8n.clientright.pro/workflow/fnSo3FTTbQcMjwt3
### 2. Добавь ноды в следующем порядке:
#### Нода 1: `update_ocr_status` (PostgreSQL)
**Расположение:** После `Postgres PGVector Store1`
**Позиция:** [3850, 1664]
**SQL запрос:**
```sql
UPDATE clpr_claim_documents
SET
ocr_status = 'ready',
ocr_processed_at = NOW()
WHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid
RETURNING
id AS doc_id,
claim_id,
ocr_status,
(SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id) AS total_docs,
(SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id AND ocr_status = 'ready') AS ready_docs;
```
**Credentials:** `timeweb_bd` (Postgres account 2)
---
#### Нода 2: `redis_incr_ready` (Redis)
**Расположение:** После `update_ocr_status`
**Позиция:** [4100, 1664]
**Параметры:**
- Operation: `incr`
- Key: `claim:ocr:ready:{{ $json.claim_id }}`
**Credentials:** `Redis account`
---
#### Нода 3: `check_all_ready` (IF)
**Расположение:** После `redis_incr_ready`
**Позиция:** [4350, 1664]
**Условие:**
```
{{ $('update_ocr_status').item.json.total_docs }} == {{ $('update_ocr_status').item.json.ready_docs }}
```
---
#### Нода 4: `publish_docs_ready` (Redis)
**Расположение:** TRUE выход из `check_all_ready`
**Позиция:** [4600, 1550]
**Параметры:**
- Operation: `publish`
- Channel: `clpr:claim:docs_ready`
- Message:
```javascript
{{ JSON.stringify({
claim_id: $('update_ocr_status').item.json.claim_id,
total_docs: $('update_ocr_status').item.json.total_docs,
ready_docs: $('update_ocr_status').item.json.ready_docs,
timestamp: new Date().toISOString()
}) }}
```
**Credentials:** `Redis account`
---
### 3. Соединения (Connections)
```
Postgres PGVector Store1 → update_ocr_status
update_ocr_status → redis_incr_ready
redis_incr_ready → check_all_ready
check_all_ready (TRUE) → publish_docs_ready
check_all_ready (FALSE) → (конец)
```
### 4. Сохрани и активируй workflow
---
## Проверка
После добавления нод, при обработке документа:
1. Статус в `clpr_claim_documents.ocr_status` будет меняться на `'ready'`
2. Счётчик в Redis `claim:ocr:ready:{claim_id}` будет инкрементиться
3. Когда все документы готовы, событие `clpr:claim:docs_ready` будет опубликовано в Redis

View File

@@ -0,0 +1,32 @@
{
"name": "check_all_ready",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [4350, 1664],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "ocr-ready-check-001",
"leftValue": "={{ $('update_ocr_status').item.json.total_docs }}",
"rightValue": "={{ $('update_ocr_status').item.json.ready_docs }}",
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "publish_docs_ready",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [4600, 1550],
"parameters": {
"operation": "publish",
"channel": "clpr:claim:docs_ready",
"messageData": "={{ JSON.stringify({ claim_id: $('update_ocr_status').item.json.claim_id, total_docs: $('update_ocr_status').item.json.total_docs, ready_docs: $('update_ocr_status').item.json.ready_docs, timestamp: new Date().toISOString() }) }}"
},
"credentials": {
"redis": {
"id": "F2IkIEYT9O7UTtz9",
"name": "Redis account"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"name": "redis_incr_ready",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [4100, 1664],
"parameters": {
"operation": "incr",
"key": "=claim:ocr:ready:{{ $json.claim_id }}"
},
"credentials": {
"redis": {
"id": "F2IkIEYT9O7UTtz9",
"name": "Redis account"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "update_ocr_error",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [3850, 1850],
"parameters": {
"operation": "executeQuery",
"query": "-- Обновляем статус OCR при ошибке\nUPDATE clpr_claim_documents\nSET \n ocr_status = 'error',\n ocr_error = '{{ $json.error || \"OCR processing failed\" }}',\n ocr_processed_at = NOW()\nWHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid\nRETURNING id, claim_id, ocr_status, ocr_error;",
"options": {}
},
"credentials": {
"postgres": {
"id": "sGJ0fJhU8rz88w3k",
"name": "timeweb_bd"
}
},
"onError": "continueRegularOutput"
}

View File

@@ -0,0 +1,20 @@
{
"name": "update_ocr_status",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [3850, 1664],
"parameters": {
"operation": "executeQuery",
"query": "-- Обновляем статус OCR для документа и возвращаем счётчики\nUPDATE clpr_claim_documents\nSET \n ocr_status = 'ready',\n ocr_processed_at = NOW()\nWHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid\nRETURNING \n id AS doc_id,\n claim_id,\n ocr_status,\n (SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id) AS total_docs,\n (SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id AND ocr_status = 'ready') AS ready_docs;",
"options": {}
},
"credentials": {
"postgres": {
"id": "sGJ0fJhU8rz88w3k",
"name": "timeweb_bd"
}
}
}