Files
test/backlog_buget_260107.md
2026-01-16 18:37:32 +03:00

19 KiB
Raw Permalink Blame History

SMS → n8n → LLM → PostgreSQL → Telegram

Текущая архитектура workflow (07.01.2026)

1. Общий контур решения

Источник данных — банковские SMS, которые попадают в n8n через Webhook.
Дальше SMS:

  1. парсятся в нормализованный JSON;
  2. сохраняются в PostgreSQL (таблица transactions);
  3. обогащаются локальной LLM (Qwen2.5 через LM Studio);
  4. отправляются в Telegramбота в виде карточки с кнопками;
  5. проходят через humanintheloop: пользователь подтверждает или редактирует контрагента и категорию;
  6. после финального подтверждения данные фиксируются в БД и могут быть отправлены во внешние системы (Google Sheets и т.п. — планируется).[file:46]

Проект развернут на Synology NAS: PostgreSQL. n8n в Docker и LM Studio — на локальной машине.[file:46]


2. База данных

2.1. Таблица 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

Есть индексы по дате, действию, контрагенту, категории и флагу human_verified.[file:46]

2.2. Таблица user_sessions

Используется для хранения состояния редактирования в Telegram (какую транзакцию и какое поле сейчас меняет пользователь).

"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, дата и пр.[file:46]

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.[file:46]

3.3. PostgreSQL: create_from_sms (INSERT)

  • Operation: Insert
  • Table: transactions
  • Поля: все базовые из parse_from_sms (без LLMполей).
  • Options: Always Output Data = ON — чтобы сразу получить id созданной строки.[file:46]

3.4. Set: Edit Fields

  • Урезает объект перед LLM (data minimization): оставляет только то, что нужно модели:
    • sms_text, action, amount, currency, balance.[file:46]

3.5. HTTP Request: post_to_llm

  • Метод: POST.
  • URL: локальный LM Studio API (http://<LM_STUDIO_IP>:1234/v1/chat/completions).
  • Тело: промпт с описанием SMS, типа операции и суммы.
  • Модель: Qwen2.57BInstruct (через LM Studio).[file:46]

3.6. Code: parse_from_llm

  • Берёт choices[^0].message.content из ответа.
  • Убирает возможные обёртки json /.[file:46]
  • Парсит JSON вида:
{"counterparty": "...", "category": "...", "subcategory": "...", "confidence": 0.0}
  • Мёрджит с исходными данными из create_from_sms.
  • Гарантирует значения по умолчанию, если LLM что‑то не вернула:
    • counterparty: "Не определён"
    • category: "Неизвестно"
    • subcategory: "Требует уточнения".[file:46]

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.[file:46]

3.8. Telegram Send Message: edit_or_confirm

  • Resource: Message.
  • Operation: 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):
    • ✅ Подтвердитьconfirm_{{ $json.id }}
    • ✏️ Изменить категориюedit_category_{{ $json.id }}
    • ✏️ Изменить контрагентаedit_counterparty_{{ $json.id }}.[file:46]

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.[file:46]

Все события (клики по кнопкам и текстовые сообщения) приходят в этот один триггер.

4.2. IF: split_event_type

  • Condition: {{ $json.callback_query }} Is Empty.
  • TRUE → текстовое сообщение пользователя → ветка редактирования.
  • FALSE → callback от кнопки → ветка подтверждения/редактирования.[file:46]

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.[file:46]
  • Разбирает callback:
const parts = callbackData.split('_');
const action = parts;                    // 'confirm' | 'edit'
const field  = parts.length === 3 ? parts[^1] : null; // 'category' | 'counterparty'
const transactionId = parseInt(parts[parts.length - 1]);
  • Определяет, редактировалась ли карточка:
const isEditedCard = messageText && messageText.includes('не сохранено');
  • Парсит текущие значения из текста карточки:
const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/);
const categoryMatch     = messageText.match(/Категория:\s*(.+?)(?:\n|$)/);
  • Возвращает:
    • action, field, transactionId, chatId, messageId,
    • messageText, isEditedCard, originalCallbackData,
    • counterparty, category.[file:46]

5.2. IF: confirm_or_edit

  • Condition: {{ $json.action }} Equal confirm.
  • TRUE → ветка подтверждения.
  • FALSE → ветка редактирования.[file:46]

5.3. IF: check_if_edited (ветка подтверждения)

  • Condition: {{ $json.isEditedCard }} Equal true.
  • TRUE → подтверждение после редактирования.
  • FALSE → первое подтверждение без правок.[file:46]

5.3.1. FALSE (первое подтверждение)

  • PostgreSQL SELECT: row_select_by_id
    • Operation: Select
    • Table: transactions
    • Where: id = {{ $json.transactionId }}
    • Always Output Data = ON.[file:46]
  • PostgreSQL UPDATE: update_after_confirm
    • Operation: Update
    • Table: transactions
    • Columns:
      • human_verified = true;
      • human_verified_at = {{ $now }}
    • Where: id = {{ $json.transactionId }}
    • Always Output Data = ON (на выходе есть строка транзакции).[file:46]

5.3.2. TRUE (подтверждение после редактирования)

  • Code: parse_card_text
    • Парсит counterparty и category из текста карточки по regex.
    • Возвращает: transactionId, counterparty, category, chatId, messageId.[file:46]
  • PostgreSQL UPDATE: update_edited_transaction
    • Operation: Update
    • Table: transactions
    • Columns:
      • counterparty = {{ $json.counterparty }};
      • category = {{ $json.category }};
      • human_verified = true.
    • Where: id = {{ $json.transactionId }}
    • Always Output Data = ON (строка транзакции).[file:46]
  • Code: prepare_confirmation_data
const parseData = $('parse_card_text').first().json;
const tx        = $input.first().json; // результат update_edited_transaction

return {
  transactionId: parseData.transactionId,
  chatId:        parseData.chatId,
  messageId:     parseData.messageId,
  counterparty:  parseData.counterparty,
  category:      parseData.category,
  raw_json: {
    signed_amount: tx.raw_json.signed_amount,
    currency:      tx.raw_json.currency,
  },
};

5.4. Merge: merge_confirm_paths

  • Type: Merge
  • Mode: Append
  • Input 1: update_after_confirm (первое подтверждение).
  • Input 2: prepare_confirmation_data (после редактирования).[file:46]

Важно: обе ветки несут в себе данные транзакции (signed_amount, currency, counterparty, category, chatId, messageId) к моменту входа в confirmation_message.

5.5. Telegram Answer Callback Query: popup_confirm

  • Resource: Callback.
  • Operation: Answer Callback Query.
  • Query ID: {{ $json.callback_query.id }}
  • Text: ✅ Транзакция подтверждена и сохранена.[file:46]

5.6. Telegram Send Message: confirmation_message

  • Resource: Message.
  • Operation: Send Message.
  • Chat ID: {{ $('answer_trigger').first().json.callback_query.message.chat.id }}
  • Text:
✅ Транзакция подтверждена

Сумма: {{ $json.raw_json.signed_amount }} {{ $json.raw_json.currency }}
Контрагент: {{ $json.counterparty }}
Категория: {{ $json.category }}
Подтверждено: {{ $now.plus({hours: 3}).toFormat('dd.MM HH:mm') }}

6. Ветка 4. Редактирование транзакции с циклом

6.1. Построение сессии редактирования

После confirm_or_edit, если action = 'edit', управление идёт в:

6.1.1. PostgreSQL Execute Query: empty_session

  • Удаляет старую сессию и пробрасывает входные данные дальше.
  • Query:
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 → редактируем контрагента.[file:46]

6.1.3. PostgreSQL INSERT: add_session_category

  • Table: user_sessions
  • Columns:
    • chatId = {{ $json.chatId }}
    • transactionId = {{ $json.transactionId }}
    • waitingFor = category
    • tempCounterparty = {{ $json.counterparty }}
    • tempCategory = {{ $json.category }}.[file:46]

6.1.4. PostgreSQL INSERT: add_session_counterparty

  • Аналогично, но waitingFor = 'counterparty'.[file:46]

6.2. Запрос на ввод значения

6.2.1. Telegram Send Message: category_edit

  • Chat ID: {{ $json.chatId }}
  • Text:
Введите новую категорию для транзакции #{{ $json.transactionId }}

ВАЖНО: Лимит 12 символов

6.2.2. Telegram Send Message: counterparty_edit

  • Chat ID: {{ $json.chatId }}
  • Text:
Введите нового контрагента для транзакции #{{ $json.transactionId }}

ВАЖНО: Лимит 12 символов

7. Ветка 5. Обработка текстового ответа пользователя

7.1. answer_trigger → split_event_type (TRUE)

Приходит message от пользователя (без callback_query). TRUEветка split_event_type ведёт в get_session.[file:46]

7.2. PostgreSQL SELECT: get_session

  • Operation: Select
  • Table: user_sessions
  • Where: chatId = {{ $json.message.chat.id }}
  • Always Output Data = ON.[file:46]

Возвращает:

{
  "chatId": "...",
  "transactionId": "...",
  "waitingFor": "category" | "counterparty",
  "tempCounterparty": "...",
  "tempCategory": "...",
  "createdAt": "..."
}

7.3. Code: merge_user_and_session

const sessionData = $input.first().json;
const userMessage = $('split_event_type').first().json;

return {
  message: userMessage.message,
  session: sessionData
};

7.4. Code: parse_text_input

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'
};

7.5. PostgreSQL SELECT: get_current_transaction

  • Operation: Select
  • Table: transactions
  • Where: id = {{ $json.transactionId }}
  • Always Output Data = ON.[file:46]

7.6. Code: merge_changes

const textInput   = $('parse_text_input').first().json;
const sessionData = $('get_session').first().json;
const currentData = $input.first().json;

const tempCategory =
  sessionData?.tempCategory != null ? sessionData.tempCategory : currentData.category;

const tempCounterparty =
  sessionData?.tempCounterparty != null ? 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:
Данные обновлены (не сохранено)

Сумма: {{ $json.signed_amount }} {{ $json.currency }}
Контрагент: {{ $json.counterparty }}
Категория: {{ $json.category }}
Дата: {{ $json.received_at }}

ВАЖНО: Лимит 12 символов на поле
  • Inline Keyboard:
    • ✅ Подтвердитьconfirm_{{ $json.id }}
    • ✏️ Изменить категориюedit_category_{{ $json.id }}
    • ✏️ Изменить контрагентаedit_counterparty_{{ $json.id }}.[file:46]

8. Планируемые доработки

8.1. Интеграция с Google Sheets (Приоритет 1)

  • Создать таблицу семейного бюджета в Google Sheets.
  • Настроить Google Service Account и подключить Google Sheets node в n8n.
  • Добавить Google Sheets после merge_confirm_paths:
    • записывать только подтверждённые транзакции;
    • колонки: Дата | Контрагент | Сумма | Категория | Подкатегория.
  • Настроить форматирование (цвета для доходов/расходов).[file:46]

8.2. Автоматизация запуска (Приоритет 2)

  • Автозапуск LM Studio при старте системы (service/systemd).
  • Docker restart: always для n8n и PostgreSQL.
  • Healthcheck LM Studio API и уведомления в Telegram при падении.[file:46]

8.3. Улучшения и UX (Приоритет 3)

  • Удалить подкатегорию.
  • Удалить confidence из промпта и на всех этапах Workflow.
  • Скорректировать промпт.
  • Обработка исторических данных (2025 год и далее) для накопления статистики.
  • Finetuning модели LLM на собственных размеченных данных.
  • Кнопка « Отклонить» для удаления/игнорирования ошибочных транзакций.
  • Ограничения и валидация пользовательского ввода (длина, спецсимволы).
  • Периодическая очистка старых записей в user_sessions (например, старше 1 часа).
  • Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием).
  • Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI).[file:46]

Версия документа: 4.1 Дата: 20260107 Статус: Архитектура workflow зафиксирована, дальнейшая работа — интеграция с отчётностью и улучшения UX.[file:46]