30 KiB
SMS → n8n → LLM → БД/Telegram: принятые решения и контракты данных
Контекст: автоматизация "Семейный бюджет". Источник данных — банковские SMS, которые пересылаются в n8n вебхуком, далее парсятся и обогащаются (LLM), затем пишутся в БД и/или отправляются уведомления в Telegram.
СТАТУС ПРОЕКТА
✅ ВЫПОЛНЕНО (100% основного функционала)
Инфраструктура
- PostgreSQL 16 развернут в Docker на Synology NAS (порт 5433)
- pgAdmin настроен (порт 5050)
- Таблица
transactionsсоздана и расширена полями для LLM и human-in-the-loop - n8n запущен в Docker-контейнере
- Telegram-бот создан через @BotFather
- Telegram credentials настроены в n8n (Base URL: https://api.telegram.org)
n8n Workflow - Основная ветка обработки SMS
- Webhook (POST) настроен для приёма SMS
- Auth check (проверка x-api-key заголовка)
- Code node (parse_sms) для парсинга SMS → NormalizedTransaction JSON
- PostgreSQL INSERT (create_from_sms) для записи базовых данных транзакции
- Always Output Data = ON для получения id
- Edit Fields (Set) для data minimization перед LLM
- HTTP Request (post_to_llm) для обращения к LM Studio API
- Code node (parse_from_llm) для парсинга JSON-ответа от LLM
- Очистка markdown обёртки (```json)
- Merge с originalData из create_from_sms
- Исправлена ошибка: choices.message → choices[0].message
- PostgreSQL UPDATE (update_llm_fields) для записи обогащённых данных
- counterparty, category, subcategory
- llm_processed_at
- human_verified = FALSE (пока не подтверждено)
- Telegram Send Message (edit_or_confirm) для отправки карточки транзакции с кнопками
- Inline Keyboard: Подтвердить / Изменить категорию / Изменить контрагента
LLM - Локальная модель
- LM Studio установлен и настроен
- Модель Qwen2.5-7B-Instruct (Q6_K, 6 ГБ) загружена
- API-сервер запущен (порт 1234, Serve on Local Network включен)
- Параметры модели оптимизированы (Context: 8192, Temperature: 0.2, Max Tokens: 512)
- Промпт улучшен для корректной работы с неизвестными контрагентами
- Обработка markdown-обёртки в ответах (```json)
Human-in-the-loop через Telegram - ПОЛНОСТЬЮ ЗАВЕРШЕНО ✅
Единый Telegram Trigger (решена проблема конфликта webhook):
- Один answer_trigger для ВСЕХ событий (callback_query + message)
- Решена проблема 403 Forbidden при регистрации webhook
- Telegram Credential настроен с Base URL = https://api.telegram.org
- IF node (split_event_type) для разделения типов событий
TRUE ветка (подтверждение) - ЗАВЕРШЕНА:
- Code node (parse_from_callback) для парсинга callback_data
- Извлечение: action, field, transactionId, chatId, messageId, messageText
- Определение isEditedCard (проверка текста "не сохранено")
- IF node (confirm_or_edit) для разделения подтверждения/редактирования
- IF node (check_if_edited) для определения источника подтверждения
- TRUE: подтверждение после редактирования → parse_card_text
- FALSE: первое подтверждение без изменений → row_select_by_id
- Code node (parse_card_text) для извлечения данных из текста карточки
- Postgres UPDATE (update_edited_transaction) для сохранения отредактированных данных
- counterparty, category, human_verified = TRUE
- Postgres SELECT (row_select_by_id) для получения данных транзакции
- Postgres UPDATE (update_after_confirm) для первого подтверждения
- human_verified = TRUE
- human_verified_at = timestamp
- Merge node (merge_confirm_paths) для объединения двух путей подтверждения
- Telegram Answer Callback Query (popup_confirm) для всплывающего уведомления
- Telegram Edit Message Text (confirmation_message) для обновления сообщения
FALSE ветка (редактирование) - ЗАВЕРШЕНА:
- IF node (which_field_to_edit) для определения редактируемого поля
- Telegram Send Message (category_edit) для запроса новой категории
- С Reply To Message ID для связи с транзакцией
- Напоминание о лимите 12 символов
- Telegram Send Message (counterparty_edit) для запроса нового контрагента
- С Reply To Message ID для связи с транзакцией
- Напоминание о лимите 12 символов
- IF node (split_event_type) после единого триггера
- isEmpty(callback_query) = TRUE → текстовое сообщение → parse_text_input
- isEmpty(callback_query) = FALSE → callback от кнопки → parse_from_callback
- Code node (parse_text_input) для парсинга ответа пользователя
- Извлечение transactionId из reply_to_message (#123)
- Определение changedField по ключевым словам
- Игнорирование команд (/)
- Output: newValue, chatId, transactionId, changedField
- Postgres SELECT (get_current_transaction) для получения текущих данных
- Code node (merge_changes) для применения изменений
- Merge новых и существующих данных
- Не пишет в БД (временное состояние)
- Telegram Send Message (show_updated_card) для отображения обновлённой карточки
- Маркер "не сохранено" для определения источника
- Те же три кнопки для продолжения редактирования
- ЦИКЛ ЗАМЫКАЕТСЯ: можно редактировать оба поля многократно
🔄 ОСТАЛОСЬ СДЕЛАТЬ
1. Интеграция с Google Sheets (следующий приоритет)
- Создать Google Sheets таблицу для семейного бюджета
- Настроить Google Sheets API в n8n (Service Account)
- Добавить Google Sheets node ПОСЛЕ merge_confirm_paths
- Записывать только подтверждённые транзакции
- Настроить запись строки: Дата | Контрагент | Сумма | Категория
- Форматирование: цветовое кодирование расходов/доходов
2. Автоматизация запуска
- Автозапуск LM Studio при загрузке системы
- Автозапуск Docker-контейнеров (restart policy = always)
- Health-check для мониторинга LM Studio API
3. Улучшения (опционально)
- Кнопка "❌ Отклонить" для удаления ошибочных транзакций
- Редактирование подкатегории
- Валидация пользовательского ввода (длина, спецсимволы)
- Обработка исторических данных 2025 года для создания базы знаний
- Fine-tuning модели на собственных размеченных данных
- Аналитика и отчёты по категориям
ФИНАЛЬНАЯ АРХИТЕКТУРА WORKFLOW (05.01.2026)
Ветка 1: Обработка входящих SMS (ЗАВЕРШЕНА)
Webhook (POST)
→ Auth check (x-api-key)
→ Code (parse_sms)
→ Postgres INSERT (create_from_sms) [Always Output Data = ON]
→ Edit Fields (data minimization)
→ HTTP Request (post_to_llm)
→ Code (parse_from_llm) [choices[0].message.content, очистка markdown, merge]
→ Postgres UPDATE (update_llm_fields) [human_verified=FALSE]
→ Telegram Send Message (edit_or_confirm) [карточка с Inline Keyboard]
Ветка 2: Единый Telegram Trigger (КРИТИЧНО)
answer_trigger (Telegram Trigger)
Updates: callback_query + message
Base URL: https://api.telegram.org
↓
split_event_type (IF: isEmpty(callback_query))
↓ TRUE (текстовое сообщение)
parse_text_input → get_current_transaction → merge_changes → show_updated_card
↓ FALSE (callback от кнопки)
parse_from_callback → confirm_or_edit
ВАЖНО: Только ОДИН Telegram Trigger на весь workflow! Два триггера конфликтуют за один webhook URL.
Ветка 3: Подтверждение транзакции (ЗАВЕРШЕНА)
parse_from_callback [isEditedCard = messageText.includes("не сохранено")]
↓
confirm_or_edit (IF: action == "confirm")
↓ TRUE (подтверждение)
check_if_edited (IF: isEditedCard == true)
↓ TRUE (после редактирования)
parse_card_text → update_edited_transaction
↓ FALSE (первое подтверждение)
row_select_by_id → update_after_confirm
↓
merge_confirm_paths (объединение двух путей)
↓
popup_confirm (Answer Callback Query)
↓
confirmation_message (Edit Message Text)
Ветка 4: Редактирование транзакции с циклом (ЗАВЕРШЕНА)
parse_from_callback
↓
confirm_or_edit (IF: action == "confirm")
↓ FALSE (редактирование)
which_field_to_edit (IF: field == "category")
↓ TRUE
category_edit (Send Message: "Введите категорию #ID, лимит 12 символов")
↓ FALSE
counterparty_edit (Send Message: "Введите контрагента #ID, лимит 12 символов")
↓
[Пользователь вводит текст]
↓
answer_trigger → split_event_type → parse_text_input
↓
get_current_transaction (SELECT текущих данных)
↓
merge_changes (применение изменений БЕЗ записи в БД)
↓
show_updated_card (карточка с маркером "не сохранено")
[Те же три кнопки: Подтвердить / Изменить категорию / Изменить контрагента]
↓
[ЦИКЛ ЗАМЫКАЕТСЯ: нажатие любой кнопки → answer_trigger]
Ключевая особенность: Изменения НЕ сохраняются в БД до финального подтверждения. Данные передаются через текст карточки (лимит 12 символов на поле для влезания в callback_data при необходимости).
ТЕХНИЧЕСКИЕ ДЕТАЛИ
База данных PostgreSQL
Connection:
- Host: Synology NAS IP
- Port: 5433
- Database: budget
- User: postgres
- Credential в n8n: "postgres account"
Таблица transactions:
-- Базовые поля
id BIGSERIAL PRIMARY KEY
raw_sms TEXT NOT NULL
sms_text TEXT NOT NULL
sms_sender VARCHAR(50)
action VARCHAR(20) NOT NULL
amount NUMERIC(15, 2)
signed_amount NUMERIC(15, 2)
currency VARCHAR(3) DEFAULT 'RUB'
balance NUMERIC(15, 2)
received_at TIMESTAMP WITH TIME ZONE NOT NULL
processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
raw_json JSONB NOT NULL
-- LLM и Human-in-the-loop поля
counterparty VARCHAR(255)
category VARCHAR(100)
subcategory VARCHAR(100)
llm_processed_at TIMESTAMP WITH TIME ZONE
human_verified BOOLEAN DEFAULT FALSE
human_verified_at TIMESTAMP WITH TIME ZONE
-- Индексы
CREATE INDEX idx_transactions_received_at ON transactions(received_at DESC);
CREATE INDEX idx_transactions_action ON transactions(action);
CREATE INDEX idx_transactions_counterparty ON transactions(counterparty);
CREATE INDEX idx_transactions_category ON transactions(category);
CREATE INDEX idx_transactions_human_verified ON transactions(human_verified) WHERE human_verified = TRUE;
Telegram Configuration
Telegram Credential (в n8n):
- Access Token: токен от @BotFather
- Base URL:
https://api.telegram.org(ВАЖНО!)
Почему именно этот Base URL:
- НЕ пустой (не polling mode)
- НЕ адрес n8n (не webhook mode через собственный сервер)
- Стандартный API Telegram - n8n использует гибридный режим получения событий
Проблема с двумя триггерами:
- Telegram позволяет только ОДИН webhook на бота
- Два Telegram Trigger в одном workflow конфликтуют
- Решение: единый триггер + IF для разделения типов событий
LLM Промпт (финальная версия)
System Message:
Ты эксперт по категоризации расходов семейного бюджета в России. Анализируй банковские SMS и извлекай данные.
ВАЖНО:
- Если контрагент НЕИЗВЕСТЕН или непонятен - укажи confidence < 0.5
- Уверенность 1.0 ставь ТОЛЬКО для известных российских брендов
- Для неочевидных названий снижай confidence до 0.3-0.6
Известные сети: PYATEROCHKA, MAGNIT, ЗОЛОТОЕ ЯБЛОКО, WILDBERRIES, YANDEX TAXI
Для неизвестных: category=Неизвестно, confidence < 0.5
Отвечай ТОЛЬКО валидным JSON без markdown обёртки.
User Message:
Проанализируй SMS: {{ $json.sms_text }}
Тип: {{ $json.action }}
Сумма: {{ $json.amount }} {{ $json.currency }}
Если контрагент неизвестен - confidence < 0.5!
Верни JSON: {"counterparty": "", "category": "", "subcategory": "", "confidence": 0.0}
ФИНАЛЬНЫЕ CODE NODES (готовые к использованию)
1. parse_from_llm (ИСПРАВЛЕНА ошибка choices.message)
// Исправлено: choices.message → choices[0].message
let content = $input.first().json.choices[0].message.content;
// Очистка markdown обёртки
content = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
const llmResponse = JSON.parse(content);
const originalData = $('create_from_sms').first().json;
return {
...originalData,
counterparty: llmResponse.counterparty || 'Не определён',
category: llmResponse.category || 'Неизвестно',
subcategory: llmResponse.subcategory || 'Требует уточнения',
confidence: llmResponse.confidence !== undefined ? llmResponse.confidence : 0.0
};
2. parse_from_callback (ОБНОВЛЕНА: добавлено isEditedCard)
const callbackData = $input.first().json.callback_query.data;
const chatId = $input.first().json.callback_query.message.chat.id;
const messageId = $input.first().json.callback_query.message.message_id;
const messageText = $input.first().json.callback_query.message.text;
const parts = callbackData.split('_');
const action = parts[0]; // confirm, edit
const field = parts.length === 3 ? parts[1] : null; // category, counterparty
const transactionId = parseInt(parts[parts.length - 1]);
// Проверка: это подтверждение после редактирования?
const isEditedCard = messageText && messageText.includes('не сохранено');
return {
action: action,
field: field,
transactionId: transactionId,
chatId: chatId,
messageId: messageId,
messageText: messageText,
isEditedCard: isEditedCard,
originalCallbackData: callbackData
};
3. parse_text_input (для обработки текстовых ответов)
const userText = $input.first().json.message.text;
const chatId = $input.first().json.message.chat.id;
const messageData = $input.first().json.message;
// Игнорируем команды
if (userText.startsWith('/')) {
throw new Error('Команда проигнорирована');
}
// Проверяем что это ответ на наше сообщение
if (!messageData.reply_to_message) {
throw new Error('Сообщение не является ответом на запрос');
}
// Извлекаем ID транзакции из текста запроса
const originalText = messageData.reply_to_message.text;
const match = originalText.match(/#(\d+)/);
if (!match) {
throw new Error('Не удалось найти ID транзакции');
}
const transactionId = parseInt(match[1]);
// Определяем какое поле пользователь меняет
let changedField = null;
if (originalText.includes('категорию')) {
changedField = 'category';
} else if (originalText.includes('контрагента')) {
changedField = 'counterparty';
}
return {
newValue: userText.trim(),
chatId: chatId,
transactionId: transactionId,
changedField: changedField
};
4. merge_changes (применение изменений без записи в БД)
const textInput = $('parse_text_input').first().json;
const currentData = $('get_current_transaction').first().json;
// Применяем изменение
const updatedCategory = textInput.changedField === 'category'
? textInput.newValue
: currentData.category;
const updatedCounterparty = textInput.changedField === 'counterparty'
? textInput.newValue
: currentData.counterparty;
return {
...currentData,
category: updatedCategory,
counterparty: updatedCounterparty,
chatId: textInput.chatId
};
5. parse_card_text (извлечение данных из текста карточки)
const messageText = $input.first().json.messageText;
const transactionId = $input.first().json.transactionId;
// Извлекаем контрагента (между "Контрагент: " и "\n")
const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/);
const counterparty = counterpartyMatch ? counterpartyMatch[1].trim() : null;
// Извлекаем категорию (между "Категория: " и "\n")
const categoryMatch = messageText.match(/Категория:\s*(.+?)(?:\n|$)/);
const category = categoryMatch ? categoryMatch[1].trim() : null;
if (!counterparty || !category) {
throw new Error('Не удалось извлечь данные из карточки');
}
return {
transactionId: transactionId,
counterparty: counterparty,
category: category,
chatId: $input.first().json.chatId,
messageId: $input.first().json.messageId
};
6. prepare_confirmation_data (подготовка данных для финальных нод)
const parseData = $('parse_card_text').first().json;
return {
transactionId: parseData.transactionId,
chatId: parseData.chatId,
messageId: parseData.messageId,
counterparty: parseData.counterparty,
category: parseData.category
};
НАСТРОЙКИ ВСЕХ НОД
Telegram Nodes
answer_trigger (Telegram Trigger):
- Credential: Telegram account
- Trigger On: callback_query
- Updates:
callback_query,message(оба!)
edit_or_confirm (Telegram Send Message):
- Chat ID:
{{ $json.chatId }} - Text:
Требуется подтверждение транзакции #{{ $json.id }}
Сумма: {{ $json.signed_amount }} {{ $json.currency }}
Контрагент: {{ $json.counterparty }}
Категория: {{ $json.category }}
Дата: {{ $json.received_at }}
- Reply Markup:
{
"inline_keyboard": [
[{"text": "✅ Подтвердить", "callback_data": "confirm_{{ $json.id }}"}],
[{"text": "✏️ Изменить категорию", "callback_data": "edit_category_{{ $json.id }}"}],
[{"text": "✏️ Изменить контрагента", "callback_data": "edit_counterparty_{{ $json.id }}"}]
]
}
category_edit (Telegram Send Message):
- Chat ID:
{{ $json.chatId }} - Text:
Введите новую категорию для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов - Reply To Message ID:
{{ $json.messageId }}
counterparty_edit (Telegram Send Message):
- Chat ID:
{{ $json.chatId }} - Text:
Введите нового контрагента для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов - Reply To Message ID:
{{ $json.messageId }}
show_updated_card (Telegram Send Message):
- Chat ID:
{{ $json.chatId }} - Text:
Данные обновлены (не сохранено)
Сумма: {{ $json.signed_amount }} {{ $json.currency }}
Контрагент: {{ $json.counterparty }}
Категория: {{ $json.category }}
Дата: {{ $json.received_at }}
ВАЖНО: Лимит 12 символов на поле
- Reply Markup: (те же три кнопки что и в edit_or_confirm)
popup_confirm (Telegram Answer Callback Query):
- Query ID:
{{ $json.callback_query.id }} - Text:
✅ Транзакция подтверждена и сохранена
confirmation_message (Telegram Edit Message Text):
- Chat ID:
{{ $json.chatId }} - Message ID:
{{ $json.messageId }} - Text:
✅ Транзакция #{{ $json.transactionId }} подтверждена\n\nКонтрагент: {{ $json.counterparty }}\nКатегория: {{ $json.category }}
PostgreSQL Nodes
create_from_sms (Postgres INSERT):
- Operation: Insert
- Table: transactions
- Columns: все базовые поля из parse_sms
- Options: Always Output Data = ON
update_llm_fields (Postgres UPDATE):
- Operation: Update
- Table: transactions
- Columns: counterparty, category, subcategory, llm_processed_at, human_verified=FALSE
- Where: id = {{ $json.id }}
- Options: Always Output Data = ON
get_current_transaction (Postgres SELECT):
- Operation: Select
- Table: transactions
- Return All: OFF
- Where: id = {{ $json.transactionId }}
- Options: Always Output Data = ON
update_edited_transaction (Postgres UPDATE):
- Operation: Update
- Table: transactions
- Columns:
- counterparty = {{ $json.counterparty }}
- category = {{ $json.category }}
- human_verified = true (toggle ON)
- Where: id = {{ $json.transactionId }}
row_select_by_id (Postgres SELECT):
- Operation: Select
- Table: transactions
- Return All: OFF
- Where: id = {{ $json.transactionId }}
update_after_confirm (Postgres UPDATE):
- Operation: Update
- Table: transactions
- Columns:
- human_verified = true
- human_verified_at = {{ $now }}
- Where: id = {{ $json.transactionId }}
IF Nodes
split_event_type (разделение типов событий):
- Condition:
{{ $json.callback_query }}Is Empty - TRUE → текстовое сообщение → parse_text_input
- FALSE → callback от кнопки → parse_from_callback
confirm_or_edit (разделение подтверждения/редактирования):
- Condition:
{{ $json.action }}Equalconfirm - TRUE → подтверждение
- FALSE → редактирование
check_if_edited (определение источника подтверждения):
- Condition:
{{ $json.isEditedCard }}Equaltrue - TRUE → после редактирования → parse_card_text
- FALSE → первое подтверждение → row_select_by_id
which_field_to_edit (определение редактируемого поля):
- Condition:
{{ $json.field }}Equalcategory - TRUE → category_edit
- FALSE → counterparty_edit
Merge Node
merge_confirm_paths (объединение путей подтверждения):
- Type: Merge
- Mode: Append
- Input 1: update_after_confirm (первое подтверждение)
- Input 2: prepare_confirmation_data (после редактирования)
- Output: popup_confirm
CHANGELOG
2026-01-05 - Human-in-the-loop ПОЛНОСТЬЮ ЗАВЕРШЁН ✅
Критические исправления:
- Исправлена ошибка в parse_from_llm:
choices.message→choices[0].message.content - Решена проблема конфликта двух Telegram триггеров за один webhook
- Удалён edit_trigger, всё через единый answer_trigger
- Добавлен split_event_type IF для разделения callback_query и message
- Исправлена ошибка 403 Forbidden при регистрации webhook
- Telegram Credential Base URL установлен в https://api.telegram.org
Реализован полный цикл редактирования:
- parse_text_input: парсинг текстовых ответов пользователя
- get_current_transaction: SELECT текущих данных
- merge_changes: применение изменений БЕЗ записи в БД
- show_updated_card: обновлённая карточка с маркером "не сохранено"
- ЦИКЛ: можно редактировать category и counterparty многократно
Реализовано двойное подтверждение:
- check_if_edited IF: определение источника подтверждения
- parse_card_text: извлечение данных из текста карточки
- update_edited_transaction: UPDATE после редактирования
- prepare_confirmation_data: подготовка данных
- merge_confirm_paths: объединение двух путей подтверждения
Технические решения:
- Лимит 12 символов на поле (для влезания в callback_data)
- Маркер "не сохранено" в тексте карточки для определения типа
- Данные передаются через текст сообщения, не через БД
- Единый триггер + IF вместо двух конфликтующих триггеров
2026-01-02 - Human-in-the-loop через Telegram (90% завершено)
- Изменена архитектура: все транзакции через человека
- LLM-данные сохраняются сразу (human_verified=false)
- Создан Telegram-бот, настроены credentials
- TRUE ветка (подтверждение) завершена
- FALSE ветка (редактирование) на 90%
- Парсинг markdown обёртки LLM
- Reply To Message для связи с транзакцией
2026-01-01 - LM Studio интегрирован
- Установлен LM Studio, загружена Qwen2.5-7B-Instruct
- Настроены параметры модели
- Решена проблема подключения n8n Docker к LM Studio
- Создан HTTP Request узел для LLM API
- Разработан промпт для категоризации
2025-12-XX - Базовая инфраструктура
- Развернут PostgreSQL 16 в Docker
- Создана таблица transactions
- Настроен n8n workflow: Webhook → Auth → Code → PostgreSQL
- Реализован парсинг SMS
СЛЕДУЮЩИЕ ШАГИ
Приоритет 1: Интеграция с Google Sheets
- Создать Google Sheets таблицу для семейного бюджета
- Настроить Google Sheets API в n8n (Service Account)
- Добавить Google Sheets node после merge_confirm_paths
- Записывать только подтверждённые транзакции
- Настроить колонки: Дата | Контрагент | Сумма | Категория | Подкатегория
- Форматирование: цветовое кодирование расходов/доходов
Приоритет 2: Автоматизация запуска
- Автозапуск LM Studio при загрузке системы (systemd service)
- Docker restart policy = always для всех контейнеров
- Health-check для мониторинга LM Studio API
- Уведомления в Telegram при падении сервисов
Приоритет 3: Улучшения (опционально)
- Кнопка "❌ Отклонить" для удаления ошибочных транзакций
- Редактирование подкатегории
- Валидация пользовательского ввода (длина, спецсимволы)
- Обработка исторических данных 2025 года
- Fine-tuning модели на собственных данных
- Аналитика и отчёты по категориям расходов
- Экспорт данных в различные форматы
Версия документа: 3.0
Дата последнего обновления: 2026-01-05
Статус: Основной функционал ЗАВЕРШЁН (100%), готов к продакшн-использованию
Следующий шаг: Интеграция с Google Sheets
Автор обновления: AI Assistant (Perplexity)