439 lines
15 KiB
Markdown
439 lines
15 KiB
Markdown
|
|
# Лог сессии: Настройка 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` — шаблон формы заявления
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|