# SMS → n8n → LLM → PostgreSQL → Telegram Текущая архитектура workflow и статус проекта (05.01.2026) ## 1. Общий контур решения Источник данных — банковские SMS, которые попадают в n8n через Webhook. Дальше SMS: 1. парсятся в нормализованный JSON; 2. сохраняются в PostgreSQL (таблица `transactions`); 3. обогащаются локальной LLM (Qwen2.5 через LM Studio); 4. отправляются в Telegram‑бота в виде карточки с кнопками; 5. проходят через human‑in‑the‑loop: пользователь подтверждает или редактирует контрагента и категорию; 6. после финального подтверждения данные фиксируются в БД и могут быть отправлены во внешние системы (Google Sheets и т.п. — планируется). Проект развернут на Synology NAS: PostgreSQL. n8n в Docker и LM Studio — на локальной машине. *** ## 2. База данных ### 2.1. Таблица `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 ``` Есть индексы по дате, действию, контрагенту, категории и флагу `human_verified`. ### 2.2. Таблица `user_sessions` Используется для хранения состояния редактирования в Telegram (какую транзакцию и какое поле сейчас меняет пользователь). ```sql "chatId" BIGINT PRIMARY KEY, "transactionId" BIGINT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, "waitingFor" VARCHAR(20) NOT NULL, -- 'category' | 'counterparty' "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, "tempCounterparty" VARCHAR(255), "tempCategory" VARCHAR(100) ``` *** ## 3. Ветка 1. Обработка входящих SMS (Webhook → LLM → БД → Telegram) ### 3.1. Webhook (Node: `Webhook`) - Method: `POST`. - Принимает JSON с полями SMS от прокси/смс‑шлюза. - Входные данные: `raw_sms`, `sms_text`, `sms_sender`, дата и пр. ### 3.2. Code: `parse_from_sms` - Преобразует входящий JSON из Webhook в нормализованный объект: - `action` (тип операции: payment/credit/cash и т.п.); - `amount`, `signed_amount`, `currency`; - `balance`; - `received_at`; - `raw_sms`, `sms_text`, `sms_sender`; - `raw_json` — исходный JSON целиком. Результат — один объект `NormalizedTransaction`. ### 3.3. PostgreSQL: `create_from_sms` (INSERT) - Operation: **Insert** - Table: `transactions` - Поля: все базовые из `parse_from_sms` (без LLM‑полей). - Options: **Always Output Data = ON** — чтобы сразу получить `id` созданной строки. ### 3.4. Set: `Edit Fields` - Урезает объект перед LLM (data minimization): оставляет только то, что нужно модели: - `sms_text`, `action`, `amount`, `currency`, `balance`. ### 3.5. HTTP Request: `post_to_llm` - Метод: POST. - URL: локальный LM Studio API (`http://:1234/v1/chat/completions`). - Тело: промпт с описанием SMS, типа операции и суммы. - Модель: Qwen2.5‑7B‑Instruct (через LM Studio). ### 3.6. Code: `parse_from_llm` - Берёт `choices[^0].message.content` из ответа. - Убирает возможные обёртки ```json /```. - Парсит JSON вида: ```json {"counterparty": "...", "category": "...", "subcategory": "...", "confidence": 0.0} ``` - Мёрджит с исходными данными из `create_from_sms`. - Гарантирует значения по умолчанию, если LLM что‑то не вернула: - `counterparty`: `"Не определён"` - `category`: `"Неизвестно"` - `subcategory`: `"Требует уточнения"`. ### 3.7. PostgreSQL: `update_llm_fields` (UPDATE) - Operation: **Update** - Table: `transactions` - Where: `id = {{ $json.id }}` - Поля для обновления: - `counterparty`, `category`, `subcategory`; - `llm_processed_at = NOW()`; - `human_verified = FALSE`. - Options: **Always Output Data = ON** — чтобы вернуть актуальную строку с `chatId`. ### 3.8. Telegram Send Message: `edit_or_confirm` - Chat ID: `{{ $json.chatId }}` (expression). - Parse Mode: HTML (или Markdown, без использования опасных спецсимволов). - Text, пример: ```text Требуется подтверждение транзакции #{{ $json.id }} Сумма: {{ $json.signed_amount }} {{ $json.currency }} Контрагент: {{ $json.counterparty }} Категория: {{ $json.category }} Дата: {{ $json.received_at }} Подтвердить или изменить? ``` - Reply Markup (Inline Keyboard, через визуальный редактор ноды): - Row 1: - Text: `✅ Подтвердить`, Callback Data: `confirm_{{ $json.id }}` - Row 2: - Text: `✏️ Изменить категорию`, Callback Data: `edit_category_{{ $json.id }}` - Text: `✏️ Изменить контрагента`, Callback Data: `edit_counterparty_{{ $json.id }}` *** ## 4. Ветка 2. Единый Telegram Trigger и разветвление по типу события ### 4.1. Telegram Trigger: `answer_trigger` - Credential: Telegram Bot. - Trigger On: `callback_query`. - Updates: `callback_query`, `message` (оба включены). - Base URL: `https://api.telegram.org`. Все события (клики по кнопкам и текстовые сообщения) приходят в этот один триггер. ### 4.2. IF: `split_event_type` - Condition: `{{ $json.callback_query }}` **Is Empty**. - TRUE → это **текстовое сообщение** пользователя → в ветку редактирования (parse_text_input). - FALSE → это **callback от кнопки** → в `parse_from_callback`. *** ## 5. Ветка 3. Подтверждение транзакции (без/после редактирования) ### 5.1. Code: `parse_from_callback` Вход: объект update с `callback_query`. Код (концептуально): - Получает: - `callbackData = $json.callback_query.data`; - `chatId = $json.callback_query.message.chat.id`; - `messageId = $json.callback_query.message.message_id`; - `messageText = $json.callback_query.message.text`. - Разбирает callback: ```javascript const parts = callbackData.split('_'); const action = parts; // 'confirm' | 'edit' const field = parts.length === 3 ? parts[^2] : null; // 'category' | 'counterparty' const transactionId = parseInt(parts[parts.length - 1]); ``` - Определяет, редактировалась ли карточка: ```javascript const isEditedCard = messageText && messageText.includes('не сохранено'); ``` - Парсит текущие значения из текста карточки: ```javascript const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/); const categoryMatch = messageText.match(/Категория:\s*(.+?)(?:\n|$)/); ``` - Возвращает объект с: - `action`, `field`, `transactionId`, `chatId`, `messageId`, - `messageText`, `isEditedCard`, `originalCallbackData`, - `counterparty`, `category`. ### 5.2. IF: `confirm_or_edit` - Condition: `{{ $json.action }}` Equal `confirm`. - TRUE → ветка подтверждения. - FALSE → ветка редактирования. ### 5.3. IF: `check_if_edited` Работает только в ветке подтверждения. - Condition: `{{ $json.isEditedCard }}` Equal `true`. - TRUE → карточка уже обновлялась (на ней есть маркер «не сохранено»). - FALSE → первое подтверждение без редактирования. #### 5.3.1. FALSE (первое подтверждение) - PostgreSQL SELECT: `row_select_by_id` - Operation: Select - Table: `transactions` - Where: `id = {{ $json.transactionId }}` - Always Output Data = ON. - PostgreSQL UPDATE: `update_after_confirm` - Operation: Update - Table: `transactions` - Columns: - `human_verified = true`; - `human_verified_at = {{ $now }}`. - Where: `id = {{ $json.transactionId }}`. #### 5.3.2. TRUE (после редактирования) - Code: `parse_card_text` - Парсит из текста карточки: - `counterparty` и `category`; - использует те же regex, что и в `parse_from_callback`. - Возвращает: `transactionId`, `counterparty`, `category`, `chatId`, `messageId`. - PostgreSQL UPDATE: `update_edited_transaction` - Operation: Update - Table: `transactions` - Columns: - `counterparty = {{ $json.counterparty }}`; - `category = {{ $json.category }}`; - `human_verified = true`. - Where: `id = {{ $json.transactionId }}`. - Code: `prepare_confirmation_data` - Приводит данные к единому формату для вывода: - `transactionId`, `chatId`, `messageId`, `counterparty`, `category`. ### 5.4. Merge: `merge_confirm_paths` - Type: **Merge** - Mode: **Append** - Input 1: `update_after_confirm` (первое подтверждение). - Input 2: `prepare_confirmation_data` (после редактирования). - Выход: в ноду `popup_confirm`. ### 5.5. Telegram Answer Callback Query: `popup_confirm` - Query ID: `{{ $json.callback_query.id }}` - Text: `✅ Транзакция подтверждена и сохранена`. ### 5.6. Telegram Edit Message Text: `confirmation_message` - Chat ID: `{{ $json.chatId }}` - Message ID: `{{ $json.messageId }}` - Text: ```text ✅ Транзакция #{{ $json.transactionId }} подтверждена Контрагент: {{ $json.counterparty }} Категория: {{ $json.category }} ``` *** ## 6. Ветка 4. Редактирование транзакции с циклом ### 6.1. Построение сессии редактирования После `confirm_or_edit`, если `action = 'edit'`, управление идёт в: #### 6.1.1. PostgreSQL Execute Query: `empty_session` - Удаляет старую сессию и пробрасывает входные данные дальше. - Query: ```sql DELETE FROM user_sessions WHERE "chatId" = {{ $json.chatId }}; SELECT '{{ $json.action }}'::text AS "action", '{{ $json.field }}'::text AS "field", {{ $json.transactionId }}::bigint AS "transactionId", {{ $json.chatId }}::bigint AS "chatId", {{ $json.messageId }}::bigint AS "messageId", {{ $json.isEditedCard }}::boolean AS "isEditedCard", '{{ $json.originalCallbackData }}'::text AS "originalCallbackData", '{{ $json.counterparty }}'::text AS "counterparty", '{{ $json.category }}'::text AS "category"; ``` #### 6.1.2. IF: `which_field_to_edit` - Condition: `{{ $json.field }}` Equal `category`. - TRUE → редактируем категорию. - FALSE → редактируем контрагента. #### 6.1.3. PostgreSQL INSERT: `add_session_category` - Table: `user_sessions` - Columns: - `chatId` = `{{ $json.chatId }}` - `transactionId` = `{{ $json.transactionId }}` - `waitingFor` = `category` (Fixed) - `tempCounterparty` = `{{ $json.counterparty }}` - `tempCategory` = `{{ $json.category }}` #### 6.1.4. PostgreSQL INSERT: `add_session_counterparty` Аналогично, но: - `waitingFor` = `counterparty`. ### 6.2. Запрос на ввод значения #### 6.2.1. Telegram Send Message: `category_edit` - Chat ID: `{{ $json.chatId }}` - Text: ```text Введите новую категорию для транзакции #{{ $json.transactionId }} ВАЖНО: Лимит 12 символов ``` #### 6.2.2. Telegram Send Message: `counterparty_edit` - Chat ID: `{{ $json.chatId }}` - Text: ```text Введите нового контрагента для транзакции #{{ $json.transactionId }} ВАЖНО: Лимит 12 символов ``` Далее пользователь вводит текст в ответ. *** ## 7. Ветка 5. Обработка текстового ответа пользователя ### 7.1. answer_trigger → split_event_type (TRUE) Приходит `message` от пользователя (без `callback_query`). TRUE‑ветка `split_event_type` ведёт в `get_session`. ### 7.2. PostgreSQL SELECT: `get_session` - Operation: Select - Table: `user_sessions` - Where: `chatId = {{ $json.message.chat.id }}` - Always Output Data = ON. Возвращает: ```json { "chatId": "...", "transactionId": "...", "waitingFor": "category" | "counterparty", "tempCounterparty": "...", "tempCategory": "...", "createdAt": "..." } ``` ### 7.3. Code: `merge_user_and_session` Объединяет данные сообщения и сессии: ```javascript const sessionData = $input.first().json; const userMessage = $('split_event_type').first().json; return { message: userMessage.message, session: sessionData }; ``` ### 7.4. Code: `parse_text_input` Работает уже поверх структуры `{ message, session }`: ```javascript const userText = $input.first().json.message.text; const chatId = $input.first().json.message.chat.id; // Игнорируем команды if (userText.startsWith('/')) { throw new Error('Команда проигнорирована'); } const sessionData = $input.first().json.session; if (!sessionData || !sessionData.transactionId) { throw new Error('Сессия не найдена. Начните редактирование заново из карточки транзакции.'); } return { newValue: userText.trim(), chatId: chatId, transactionId: parseInt(sessionData.transactionId), changedField: sessionData.waitingFor // 'category' | 'counterparty' }; ``` Выход: `newValue`, `chatId`, `transactionId`, `changedField`. ### 7.5. PostgreSQL SELECT: `get_current_transaction` - Operation: Select - Table: `transactions` - Where: `id = {{ $json.transactionId }}` - Always Output Data = ON. Возвращает текущую строку транзакции (с LLM‑значениями). ### 7.6. Code: `merge_changes` Применяет изменение без записи в БД, с учётом данных из `user_sessions`: ```javascript const textInput = $('parse_text_input').first().json; const sessionData = $('get_session').first().json; const currentData = $input.first().json; const tempCategory = sessionData?.tempCategory ?? currentData.category; const tempCounterparty = sessionData?.tempCounterparty ?? currentData.counterparty; const updatedCategory = textInput.changedField === 'category' ? textInput.newValue : tempCategory; const updatedCounterparty = textInput.changedField === 'counterparty' ? textInput.newValue : tempCounterparty; return { ...currentData, category: updatedCategory, counterparty: updatedCounterparty, chatId: textInput.chatId }; ``` Результат — «виртуально» обновлённая транзакция, которая ещё не записана в БД. ### 7.7. Telegram Send Message: `show_updated_card` - Chat ID: `{{ $json.chatId }}` - Text: ```text Данные обновлены (не сохранено) Сумма: {{ $json.signed_amount }} {{ $json.currency }} Контрагент: {{ $json.counterparty }} Категория: {{ $json.category }} Дата: {{ $json.received_at }} ВАЖНО: Лимит 12 символов на поле ``` - Inline Keyboard (аналогично `edit_or_confirm`): - `✅ Подтвердить` → `confirm_{{ $json.id }}` - `✏️ Изменить категорию` → `edit_category_{{ $json.id }}` - `✏️ Изменить контрагента` → `edit_counterparty_{{ $json.id }}` Пользователь может снова начать цикл редактирования или подтвердить. *** ## 8. Статус проекта на 05.01.2026 ### 8.1. Реализовано - Полная цепочка обработки SMS: - Webhook → parse_from_sms → create_from_sms → post_to_llm → parse_from_llm → update_llm_fields → edit_or_confirm. - Единый Telegram Trigger: - разделение message / callback через `split_event_type`. - Ветка подтверждения: - первое подтверждение → `human_verified = TRUE`; - подтверждение после редактирования → парсинг карточки и финальное обновление `counterparty`/`category`. - Ветка редактирования с сессиями: - хранение состояния в `user_sessions` (какую транзакцию и какое поле редактируем); - временные поля `tempCounterparty`, `tempCategory` для сохранения уже внесённых изменений; - цикл «Изменить → ввести текст → обновлённая карточка → снова изменить / подтвердить» — логика нод выстроена, корректность проходит по шагам в ручных тестах. ### 8.2. На чём остановились - Полный автоматический цикл редактирования **почти** отлажен: - контрагент и категория при последовательных правках больше не должны затирать друг друга значениями LLM из БД; - требуется ещё один‑два полных сквозных теста «несколько правок подряд → финальное подтверждение → проверка строки в `transactions`», чтобы зафиксировать, что все граничные случаи (несколько итераций, смена поля, отмена и т.п.) работают стабильно. *** ## 9. Планируемые доработки (частично перенесено из предыдущей версии документа и актуализировано) ### 9.1. Интеграция с Google Sheets (Приоритет 1) - [ ] Создать таблицу семейного бюджета в Google Sheets. - [ ] Настроить Google Service Account и подключить Google Sheets node в n8n. - [ ] Добавить ноду Google Sheets **после** `merge_confirm_paths`: - записывать только подтверждённые транзакции; - колонки: Дата | Контрагент | Сумма | Категория | Подкатегория. - [ ] Настроить форматирование (цвета для доходов/расходов). ### 9.2. Автоматизация запуска (Приоритет 2) - [ ] Автозапуск LM Studio при старте системы (service/systemd). - [ ] Docker `restart: always` для n8n и PostgreSQL. - [ ] Health‑check LM Studio API и уведомления в Telegram при падении. ### 9.3. Улучшения и UX (Приоритет 3) - [ ] Удалить подкатегорию. - [ ] Удалить confidence из промпта и на всех этапах Workflow. - [ ] Скорректировать промпт. - [ ] Обработка исторических данных (2025 год и далее) для накопления статистики. - [ ] Потенциальный fine‑tuning модели LLM на собственных размеченных данных. - [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций. - [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы). - [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа). - [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием). - [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI). *** **Версия документа:** 4.0 **Дата:** 2026‑01‑05 **Статус:** Основной функционал реализован, финальная отладка цикла редактирования/подтверждения в процессе; интеграция с внешними отчётами и автоматизация запуска в планах.