# SMS → n8n → LLM → БД/Telegram: принятые решения и контракты данных Контекст: автоматизация "Семейный бюджет". Источник данных — банковские SMS, которые пересылаются в n8n вебхуком, далее парсятся и обогащаются (LLM), затем пишутся в БД и/или отправляются уведомления в Telegram. --- ## СТАТУС ПРОЕКТА ### ✅ ВЫПОЛНЕНО (100% основного функционала) #### Инфраструктура - [x] PostgreSQL 16 развернут в Docker на Synology NAS (порт 5433) - [x] pgAdmin настроен (порт 5050) - [x] Таблица `transactions` создана и расширена полями для LLM и human-in-the-loop - [x] n8n запущен в Docker-контейнере - [x] Telegram-бот создан через @BotFather - [x] Telegram credentials настроены в n8n (Base URL: ) #### n8n Workflow - Основная ветка обработки SMS - [x] Webhook (POST) настроен для приёма SMS - [x] Auth check (проверка x-api-key заголовка) - [x] Code node (parse_sms) для парсинга SMS → NormalizedTransaction JSON - [x] PostgreSQL INSERT (create_from_sms) для записи базовых данных транзакции - Always Output Data = ON для получения id - [x] Edit Fields (Set) для data minimization перед LLM - [x] HTTP Request (post_to_llm) для обращения к LM Studio API - [x] Code node (parse_from_llm) для парсинга JSON-ответа от LLM - Очистка markdown обёртки (```json) - Merge с originalData из create_from_sms - Исправлена ошибка: choices.message → choices[0].message - [x] PostgreSQL UPDATE (update_llm_fields) для записи обогащённых данных - counterparty, category, subcategory - llm_processed_at - human_verified = FALSE (пока не подтверждено) - [x] Telegram Send Message (edit_or_confirm) для отправки карточки транзакции с кнопками - Inline Keyboard: Подтвердить / Изменить категорию / Изменить контрагента #### LLM - Локальная модель - [x] LM Studio установлен и настроен - [x] Модель Qwen2.5-7B-Instruct (Q6_K, 6 ГБ) загружена - [x] API-сервер запущен (порт 1234, Serve on Local Network включен) - [x] Параметры модели оптимизированы (Context: 8192, Temperature: 0.2, Max Tokens: 512) - [x] Промпт улучшен для корректной работы с неизвестными контрагентами - [x] Обработка markdown-обёртки в ответах (```json) #### Human-in-the-loop через Telegram - ПОЛНОСТЬЮ ЗАВЕРШЕНО ✅ **Единый Telegram Trigger (решена проблема конфликта webhook):** - [x] Один answer_trigger для ВСЕХ событий (callback_query + message) - [x] Решена проблема 403 Forbidden при регистрации webhook - [x] Telegram Credential настроен с Base URL = - [x] IF node (split_event_type) для разделения типов событий **TRUE ветка (подтверждение) - ЗАВЕРШЕНА:** - [x] Code node (parse_from_callback) для парсинга callback_data - Извлечение: action, field, transactionId, chatId, messageId, messageText - Определение isEditedCard (проверка текста "не сохранено") - [x] IF node (confirm_or_edit) для разделения подтверждения/редактирования - [x] IF node (check_if_edited) для определения источника подтверждения - TRUE: подтверждение после редактирования → parse_card_text - FALSE: первое подтверждение без изменений → row_select_by_id - [x] Code node (parse_card_text) для извлечения данных из текста карточки - [x] Postgres UPDATE (update_edited_transaction) для сохранения отредактированных данных - counterparty, category, human_verified = TRUE - [x] Postgres SELECT (row_select_by_id) для получения данных транзакции - [x] Postgres UPDATE (update_after_confirm) для первого подтверждения - human_verified = TRUE - human_verified_at = timestamp - [x] Merge node (merge_confirm_paths) для объединения двух путей подтверждения - [x] Telegram Answer Callback Query (popup_confirm) для всплывающего уведомления - [x] Telegram Edit Message Text (confirmation_message) для обновления сообщения **FALSE ветка (редактирование) - ЗАВЕРШЕНА:** - [x] IF node (which_field_to_edit) для определения редактируемого поля - [x] Telegram Send Message (category_edit) для запроса новой категории - С Reply To Message ID для связи с транзакцией - Напоминание о лимите 12 символов - [x] Telegram Send Message (counterparty_edit) для запроса нового контрагента - С Reply To Message ID для связи с транзакцией - Напоминание о лимите 12 символов - [x] IF node (split_event_type) после единого триггера - isEmpty(callback_query) = TRUE → текстовое сообщение → parse_text_input - isEmpty(callback_query) = FALSE → callback от кнопки → parse_from_callback - [x] Code node (parse_text_input) для парсинга ответа пользователя - Извлечение transactionId из reply_to_message (#123) - Определение changedField по ключевым словам - Игнорирование команд (/) - Output: newValue, chatId, transactionId, changedField - [x] Postgres SELECT (get_current_transaction) для получения текущих данных - [x] Code node (merge_changes) для применения изменений - Merge новых и существующих данных - Не пишет в БД (временное состояние) - [x] 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 (ЗАВЕРШЕНА) ```text 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 (КРИТИЧНО) ```text 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: Подтверждение транзакции (ЗАВЕРШЕНА) ```text 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: Редактирование транзакции с циклом (ЗАВЕРШЕНА) ```text 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:** ```sql -- Базовые поля 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:** ```text Ты эксперт по категоризации расходов семейного бюджета в России. Анализируй банковские SMS и извлекай данные. ВАЖНО: - Если контрагент НЕИЗВЕСТЕН или непонятен - укажи confidence < 0.5 - Уверенность 1.0 ставь ТОЛЬКО для известных российских брендов - Для неочевидных названий снижай confidence до 0.3-0.6 Известные сети: PYATEROCHKA, MAGNIT, ЗОЛОТОЕ ЯБЛОКО, WILDBERRIES, YANDEX TAXI Для неизвестных: category=Неизвестно, confidence < 0.5 Отвечай ТОЛЬКО валидным JSON без markdown обёртки. ``` **User Message:** ```text Проанализируй 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) ```javascript // Исправлено: 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) ```javascript 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 (для обработки текстовых ответов) ```javascript 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 (применение изменений без записи в БД) ```javascript 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 (извлечение данных из текста карточки) ```javascript 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 (подготовка данных для финальных нод) ```javascript 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: ```text Требуется подтверждение транзакции #{{ $json.id }} Сумма: {{ $json.signed_amount }} {{ $json.currency }} Контрагент: {{ $json.counterparty }} Категория: {{ $json.category }} Дата: {{ $json.received_at }} ``` - Reply Markup: ```json { "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: ```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 }}` Equal `confirm` - TRUE → подтверждение - FALSE → редактирование **check_if_edited (определение источника подтверждения)**: - Condition: `{{ $json.isEditedCard }}` Equal `true` - TRUE → после редактирования → parse_card_text - FALSE → первое подтверждение → row_select_by_id **which_field_to_edit (определение редактируемого поля)**: - Condition: `{{ $json.field }}` Equal `category` - 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 установлен в **Реализован полный цикл редактирования:** - 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 1. Создать Google Sheets таблицу для семейного бюджета 2. Настроить Google Sheets API в n8n (Service Account) 3. Добавить Google Sheets node после merge_confirm_paths 4. Записывать только подтверждённые транзакции 5. Настроить колонки: Дата | Контрагент | Сумма | Категория | Подкатегория 6. Форматирование: цветовое кодирование расходов/доходов ### Приоритет 2: Автоматизация запуска 1. Автозапуск LM Studio при загрузке системы (systemd service) 2. Docker restart policy = always для всех контейнеров 3. Health-check для мониторинга LM Studio API 4. Уведомления в Telegram при падении сервисов ### Приоритет 3: Улучшения (опционально) 1. Кнопка "❌ Отклонить" для удаления ошибочных транзакций 2. Редактирование подкатегории 3. Валидация пользовательского ввода (длина, спецсимволы) 4. Обработка исторических данных 2025 года 5. Fine-tuning модели на собственных данных 6. Аналитика и отчёты по категориям расходов 7. Экспорт данных в различные форматы --- **Версия документа**: 3.0 **Дата последнего обновления**: 2026-01-05 **Статус**: Основной функционал ЗАВЕРШЁН (100%), готов к продакшн-использованию **Следующий шаг**: Интеграция с Google Sheets **Автор обновления**: AI Assistant (Perplexity)