initial commit

This commit is contained in:
vakabunga
2026-01-16 18:37:32 +03:00
commit 643eddb5fc
14 changed files with 3469 additions and 0 deletions

4
.env Normal file
View File

@@ -0,0 +1,4 @@
POSTGRES_DB=budget
POSTGRES_USER=budget_user
POSTGRES_PASSWORD=difficult_P@
PGADMIN_DEFAULT_PASSWORD=easy_P@

BIN
2025.pdf Normal file

Binary file not shown.

BIN
2026.pdf Normal file

Binary file not shown.

369
backlog_buget.md Normal file
View File

@@ -0,0 +1,369 @@
# SMS → n8n → LLM → PostgreSQL → Telegram → Google Sheets
Текущая архитектура workflow (11.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,
chat_id BIGINT,
-- LLM / Human-in-the-loop
counterparty VARCHAR(255),
category VARCHAR(100),
llm_processed_at TIMESTAMP WITH TIME ZONE,
human_verified BOOLEAN DEFAULT FALSE,
human_verified_at TIMESTAMP WITH TIME ZONE
```
**Индексы:**
- По дате, действию, контрагенту, категории и флагу `human_verified`
- `idx_chat_categories` на `(chat_id, category, received_at DESC)` для персонализации
### 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,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
"tempCounterparty" VARCHAR(255),
"tempCategory" VARCHAR(100)
```
### 2.3. Таблица `user_category_stats`
Кэш популярных категорий пользователей для персонализации.
```sql
chat_id BIGINT,
category VARCHAR(100),
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_id, category)
```
**Индекс:** `idx_user_cats` на `(chat_id, count DESC)`
***
## 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 в нормализованный объект.
**Типы операций (action):**
```javascript
const rules = [
{ action: 'salary', re: /зарплат/i },
{ action: 'reversal', re: /(отмена|возврат)/i },
{ action: 'payment', re: /(оплата|покупк)/i },
{ action: 'transfer', re: /перевод/i },
{ action: 'writeoff', re: /списан/i },
{ action: 'income', re: /(поступлен|поступление|зачислен)/i },
{ action: 'withdrawal', re: /снятие/i },
{ action: 'deposit', re: /внесение/i }
];
```
**Логика знаков:**
```javascript
// Проверка валидности суммы
if (amount === null || amount === 0) {
throw new Error('Не удалось распарсить сумму из SMS');
}
let signed_amount = amount;
// Расходные операции (минус)
if (['payment', 'transfer', 'writeoff', 'withdrawal'].includes(action)) {
signed_amount = -Math.abs(amount);
}
// Приходные операции (плюс)
if (['salary', 'income', 'deposit', 'reversal'].includes(action)) {
signed_amount = Math.abs(amount);
}
```
**Результат:** объект с полями `action`, `amount`, `signed_amount`, `currency`, `balance`, `received_at`, `raw_sms`, `sms_text`, `sms_sender`, `raw_json`.
### 3.3. PostgreSQL: `create_from_sms` (INSERT)
- Operation: **Insert**
- Table: `transactions`
- Поля: все базовые из `parse_from_sms` (без LLM-полей)
- Options: **Always Output Data = ON**
### 3.4. Set: `Edit Fields`
Урезает объект перед LLM (data minimization): `sms_text`, `action`, `amount`, `currency`, `balance`.
### 3.5. HTTP Request: `post_to_llm`
- Метод: POST
- URL: `http://<LM_STUDIO_IP>:1234/v1/chat/completions`
- Модель: Qwen2.5-7B-Instruct
**System prompt (фрагмент):**
```text
ДОПУСТИМЫЕ КАТЕГОРИИ:
"Продукты", "Авто", "Здоровье", "Арчи", "ЖКХ", "Дом", "Проезд", "Одежда",
"Химия", "Косметика", "Инвестиции", "Развлечения", "Общепит", "Штрафы",
"Налоги", "Подписки", "Перевод", "Наличные", "Подарки", "Спорт", "Поступления"
КАТЕГОРИЗАЦИЯ ПО ТИПУ ОПЕРАЦИИ:
- Для снятия наличных категория ВСЕГДА "Наличные".
- Для внесения наличных категория ВСЕГДА "Поступления".
- Для зарплаты или поступлений категория ВСЕГДА "Поступления".
- Для возвратов/отмен категория ВСЕГДА "Поступления".
- Для переводов определяй категорию по контрагенту.
```
**Параметры:** `temperature: 0.2`, `max_tokens: 256`
### 3.6. Code: `parse_from_llm`
```javascript
let content = $input.first().json.choices.message.content;
content = content.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
const llmResponse = JSON.parse(content);
const originalData = $('create_from_sms').first().json;
return {
...originalData,
counterparty: llmResponse.counterparty || originalData.counterparty || 'Продукты',
category: llmResponse.category || originalData.category || 'Продукты'
};
```
### 3.7. PostgreSQL: `update_llm_fields` (UPDATE)
- Table: `transactions`
- Where: `id = {{ $json.id }}`
- Columns: `counterparty`, `category`, `llm_processed_at = NOW()`, `human_verified = FALSE`
- Options: **Always Output Data = ON**
### 3.8. Telegram Send Message: `edit_or_confirm`
- Chat ID: `{{ $json.chatId }}`
- Text: карточка транзакции с суммой, контрагентом, категорией, датой
- Inline Keyboard:
- `✅ Подтвердить``confirm_{{ $json.id }}`
- `✏️ Изменить категорию``edit_category_{{ $json.id }}`
- `✏️ Изменить контрагента``edit_counterparty_{{ $json.id }}`
***
## 4. Ветка 2. Telegram Trigger и разветвление
### 4.1. Telegram Trigger: `answer_trigger`
- Updates: `callback_query`, `message`
- Все события (кнопки и текст) приходят в один триггер
### 4.2. IF: `split_event_type`
- Condition: `{{ $json.callback_query }}` Is Empty
- TRUE → текстовое сообщение → ветка редактирования
- FALSE → callback от кнопки → ветка подтверждения/редактирования
***
## 5. Ветка 3. Подтверждение транзакции
### 5.1. Code: `parse_from_callback`
Извлекает из callback: `action`, `field`, `transactionId`, `chatId`, `messageId`, `isEditedCard`, `counterparty`, `category`.
### 5.2. IF: `confirm_or_edit`
- TRUE → подтверждение
- FALSE → редактирование
### 5.3. IF: `check_if_edited`
- TRUE → подтверждение после редактирования
- FALSE → первое подтверждение
#### Первое подтверждение
- PostgreSQL SELECT: `row_select_by_id`
- PostgreSQL UPDATE: `update_after_confirm`
- Columns: `human_verified = true`, `human_verified_at = {{ $now }}`, `chat_id = {{ $('answer_trigger').first().json.callback_query.message.chat.id }}`
#### После редактирования
- Code: `parse_card_text`
- PostgreSQL UPDATE: `update_edited_transaction`
- Columns: `counterparty`, `category`, `human_verified = true`, `chat_id = {{ $('answer_trigger').first().json.callback_query.message.chat.id }}`
- Code: `prepare_confirmation_data`
```javascript
const messageData = $('parse_card_text').first().json;
const parseData = $input.first().json;
return {
transactionId: parseData.transactionId,
chatId: messageData.chatId,
messageId: messageData.messageId,
counterparty: parseData.counterparty,
category: parseData.category,
raw_json: {
action: parseData.raw_json.action,
signed_amount: parseData.raw_json.signed_amount,
currency: parseData.raw_json.currency,
}
};
```
### 5.4. Merge: `merge_confirm_paths`
- Mode: **Append**
- Объединяет обе ветки подтверждения
### 5.5. Telegram Answer Callback Query: `popup_confirm`
### 5.6. Telegram Send Message: `confirmation_message`
***
## 6. Ветка 4. Редактирование транзакции
### 6.1. PostgreSQL Execute Query: `empty_session`
Удаляет старую сессию и пробрасывает данные.
### 6.2. IF: `which_field_to_edit`
- TRUE → категория
- FALSE → контрагент
### 6.3. PostgreSQL INSERT: `add_session_category` / `add_session_counterparty`
Создает сессию в `user_sessions`.
### 6.4. Telegram Send Message: `category_edit` / `counterparty_edit`
Запрашивает ввод нового значения.
***
## 7. Ветка 5. Обработка текстового ответа
### 7.1. PostgreSQL SELECT: `get_session`
### 7.2. Code: `merge_user_and_session`
### 7.3. Code: `parse_text_input`
### 7.4. PostgreSQL SELECT: `get_current_transaction`
### 7.5. Code: `merge_changes`
### 7.6. Telegram Send Message: `show_updated_card`
Отображает обновленную карточку с надписью "не сохранено".
***
## 8. Интеграция с Google Sheets
### 8.1. Структура таблицы
Колонки: `дата`, `действие`, `контрагент`, `сумма`, `категория`, `примечание`
### 8.2. Узел Google Sheets (после `merge_confirm_paths`)
- Operation: **Append Row**
- Credentials: Google Service Account
**Mapping:**
```javascript
дата: {{ $json.received_at }}
действие: {{ $json.raw_json.action === 'salary' ? 'Зарплата' : $json.raw_json.action === 'reversal' ? 'Возврат' : $json.raw_json.action === 'payment' ? 'Оплата' : $json.raw_json.action === 'transfer' ? 'Перевод' : $json.raw_json.action === 'writeoff' ? 'Списание' : $json.raw_json.action === 'income' ? 'Поступление' : $json.raw_json.action === 'withdrawal' ? 'Снятие' : $json.raw_json.action === 'deposit' ? 'Внесение' : $json.raw_json.action }}
контрагент: {{ $json.counterparty }}
сумма: {{ String($json.raw_json.signed_amount).replace('.', ',') }}
категория: {{ $json.category }}
примечание:
```
***
## 9. Планируемые доработки
### 9.1. Архитектурные улучшения (Приоритет 1)
- [ ] **Переход на stateless редактирование:** Хранить состояние в `callback_data` кнопок вместо таблицы `user_sessions`, убрать 7 узлов workflow, снизить нагрузку на БД на 70%
- [ ] **Inline-клавиатура для категорий:** Заменить текстовый ввод на кнопки с персонализированными популярными категориями (топ-3 за 30 дней) + полный список
- [ ] **Кэширование популярных категорий:** Использовать таблицу `user_category_stats` для быстрого получения часто используемых категорий
- [ ] **Fallback для длинных callback_data:** MD5-хэш + временное хранилище для значений >64 байт
### 9.2. LLM и категоризация (Приоритет 2)
- [ ] Собрать статистику по качеству автокатегоризации
- [ ] Fine-tuning модели LLM на собственных размеченных данных
- [ ] Скорректировать описания категорий/словарь на основе статистики
### 9.3. Автоматизация запуска (Приоритет 3)
- [ ] Автозапуск LM Studio при старте системы (service/systemd)
- [ ] Docker `restart: always` для n8n и PostgreSQL
- [ ] Health-check LM Studio API и уведомления в Telegram при падении
### 9.4. Улучшения UX (Приоритет 4)
- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций
- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы)
- [ ] Периодическая очистка старых записей в `user_sessions` (>1 часа)
- [ ] Добавление исходного текста SMS в карточку
- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI)
***
**Версия документа:** 5.0
**Дата:** 2026-01-11
**Статус:** Добавлены типы операций (withdrawal/deposit), логика знаков, категория "Поступления", поле `chat_id`, экспорт в Google Sheets с колонкой "действие". Следующий этап — переход на stateless редактирование и inline-клавиатуры для категорий.

564
backlog_buget_260106.md Normal file
View File

@@ -0,0 +1,564 @@
# 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. проходят через humanintheloop: пользователь подтверждает или редактирует контрагента и категорию;
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://<LM_STUDIO_IP>:1234/v1/chat/completions`).
- Тело: промпт с описанием SMS, типа операции и суммы.
- Модель: Qwen2.57BInstruct (через 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.
- [ ] Healthcheck LM Studio API и уведомления в Telegram при падении.
### 9.3. Улучшения и UX (Приоритет 3)
- [ ] Удалить подкатегорию.
- [ ] Удалить confidence из промпта и на всех этапах Workflow.
- [ ] Скорректировать промпт.
- [ ] Обработка исторических данных (2025 год и далее) для накопления статистики.
- [ ] Потенциальный finetuning модели LLM на собственных размеченных данных.
- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций.
- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы).
- [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа).
- [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием).
- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI).
***
**Версия документа:** 4.0
**Дата:** 20260105
**Статус:** Основной функционал реализован, финальная отладка цикла редактирования/подтверждения в процессе; интеграция с внешними отчётами и автоматизация запуска в планах.

538
backlog_buget_260107.md Normal file
View File

@@ -0,0 +1,538 @@
# 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`
```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`.[file:46]
### 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`, дата и пр.[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 вида:
```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:
```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:
```javascript
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]);
```
- Определяет, редактировалась ли карточка:
```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`.[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`
```javascript
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:
```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:
```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 → редактируем контрагента.[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:
```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`.[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]
Возвращает:
```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`
```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'
};
```
### 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`
```javascript
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:
```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]

618
backlog_buget_260111.md Normal file
View File

@@ -0,0 +1,618 @@
# SMS → n8n → LLM → PostgreSQL → Telegram
Текущая архитектура workflow (09.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 для отчётности.[^1]
Проект развернут на Synology NAS: PostgreSQL. n8n в Docker и LM Studio — на локальной машине.[^1]
***
## 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),
llm_processed_at TIMESTAMP WITH TIME ZONE,
human_verified BOOLEAN DEFAULT FALSE,
human_verified_at TIMESTAMP WITH TIME ZONE
```
Есть индексы по дате, действию, контрагенту, категории и флагу `human_verified`.[^1]
### 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`, дата и пр.[^1]
### 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`.[^1]
### 3.3. PostgreSQL: `create_from_sms` (INSERT)
- Operation: **Insert**
- Table: `transactions`
- Поля: все базовые из `parse_from_sms` (без LLMполей).
- Options: **Always Output Data = ON** — чтобы сразу получить `id` созданной строки.[^1]
### 3.4. Set: `Edit Fields`
- Урезает объект перед LLM (data minimization): оставляет только то, что нужно модели:
- `sms_text`, `action`, `amount`, `currency`, `balance`.[^1]
### 3.5. HTTP Request: `post_to_llm`
- Метод: POST.
- URL: локальный LM Studio API (`http://<LM_STUDIO_IP>:1234/v1/chat/completions`).
- Модель: Qwen2.57BInstruct (через LM Studio).[^1]
**Тело запроса (JSON, Expression):**
```js
{
"model": "qwen2.5-7b-instruct",
"messages": [
{
"role": "system",
"content": "Ты эксперт по категоризации расходов семейного бюджета в России. Анализируй банковские SMS и извлекай данные.\n\nТВОЯ ЗАДАЧА:\n- Определить контрагента (магазин, сервис, банк и т.п.).\n- Определить категорию расхода (еда, транспорт, подписки, одежда, здоровье, дом, техника, развлечения, авто, услуги и т.п.).\n\nВАЖНО:\n- Никогда не используй значения 'Неизвестно', 'Не определён', 'Требует уточнения' или похожие.\n- Даже если не уверен, выбери наиболее вероятного контрагента и категорию по тексту SMS.\n- Для контрагента используй удобное для человека название (например, 'Пятёрочка', 'Магнит', 'Яндекс Такси').\n- Если название контрагента написано латиницей или капсом (например, PYATEROCHKA, PEREKRESTOK, YANDEXTAXI), считай, что это русские бренды и приведи их к удобному русскому названию для человека: «Пятёрочка», «Перекрёсток», «Яндекс Такси» и т.п.\n\nСтарайся находить наиболее близкий известный российский бренд по написанию, а не считать его неизвестным.\n\nДОПУСТИМЫЕ КАТЕГОРИИ:\nИспользуй ТОЛЬКО следующие значения поля \"category\" (строго как написано, без синонимов):\n\"Продукты\" - продуктовые магазины, продовольственные товары\n\"Авто\" - топливо, парковки, платные дороги, ремонт авто и т.д.\n\"Здоровье\" - аптеки, поликлиники, больницы и т.д.\n\"Арчи\" - зоотовары, вет. клиники, вет. аптеки и т.д.\n\"ЖКХ\" - коммунальные платежи и т.д.\n\"Дом\" - мелочи для интерьера, напольные покрытия, мебель, техника для дома, бытовые мелочи и т.д. сюда можно отнести товары от контрагента OZON\n\"Проезд\" - все что связано с общественным транспортом (электрички, метро, такси и т.д.)\n\"Одежда\" - одежда и обувь\n\"Химия\" - бытовая химия и т.д.\n\"Косметика\" - косметика\n\"Инвестиции\" - перевод во вклады, покупка акций и т.д.\n\"Развлечения\" - любые развлечения (кино, музеи, концерты, отпуск и т.д.)\n\"Общепит\" - кафе, рестораны, столовые и другая еда вне дома\n\"Штрафы\" - любые штрафы\n\"Налоги\" - государственные налоги\n\"Подписки\" - обязательные переодические платежи (подписки, связь, интернет и т.д.)\n\"Перевод\" - перевод денежных средств\n\"Наличные\" - снятие или внесение наличных\n\"Подарки\" - сотовые телефоны, драгоценности, цветы и т.д.\n\"Спорт\" - покупка билетов (слотов) на спортивные мероприятия, покупка спортивного инвентаря, в том числе одежда и обувь из спортивных магазинов\n\nНикогда не придумывай новые формулировки категорий и не используй синонимы.\nЕсли не уверен, выбери наиболее подходящую категорию из этого списка.\n\nОтвечай ТОЛЬКО валидным JSON без каких‑либо пояснений."
},
{
"role": "user",
"content": "Проанализируй банковскую SMS и верни только JSON.\n\nSMS:\n{{ $json.sms_text }}\n\nВерни строго валидный JSON одного объекта вида:\n{\"counterparty\": \"\", \"category\": \"\"}\n\nГде:\n- \"counterparty\" — человекопонятное название контрагента на русском (например, \"Пятёрочка\", \"Магнит\", \"Яндекс Такси\").\n- \"category\" — ОДНА из допустимых категорий из системного промпта. Не придумывай других категорий и не используй синонимы.\n\nНе добавляй никаких других полей и комментариев. Только JSON."
}
],
"temperature": 0.2,
"max_tokens": 256
}
```
### 3.6. Code: `parse_from_llm`
Актуальная логика:
- Берёт `choices[^0].message.content` из ответа.
- Убирает возможные обёртки ```json /``` (markdown).
- Парсит JSON вида:
```json
{"counterparty": "...", "category": "..."}
```
- Объединяет с исходными данными из `create_from_sms`.
- Без `subcategory` и `confidence`.
- Подстраховывает пустые поля дефолтами.[^1]
Код:
```javascript
// Получаем content от LLM
let content = $input.first().json.choices[^0].message.content;
// Убираем markdown-обёртку ```json ... ``` если есть
content = content
.replace(/```json\n?/gi, '')
.replace(/```\n?/g, '')
.trim();
// Парсим очищенный JSON
const llmResponse = JSON.parse(content);
// Берём все данные из первоначальной транзакции
const originalData = $('create_from_sms').first().json;
// Объединяем: базовые данные + результаты LLM
return {
...originalData,
counterparty: llmResponse.counterparty || originalData.counterparty || 'Продукты',
category: llmResponse.category || originalData.category || 'Продукты'
};
```
### 3.7. PostgreSQL: `update_llm_fields` (UPDATE)
- Operation: **Update**
- Table: `transactions`
- Where: `id = {{ $json.id }}`
- Поля для обновления:
- `counterparty`, `category`;
- `llm_processed_at = NOW()`;
- `human_verified = FALSE`.
- Options: **Always Output Data = ON** — чтобы вернуть актуальную строку и `chatId`.[^1]
### 3.8. Telegram Send Message: `edit_or_confirm`
- Resource: Message.
- Operation: Send Message.
- Chat ID: `{{ $json.chatId }}`
- Text:
```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 }}`.[^1]
***
## 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`.[^1]
Все события (клики по кнопкам и текстовые сообщения) приходят в этот один триггер.[^1]
### 4.2. IF: `split_event_type`
- Condition: `{{ $json.callback_query }}` **Is Empty**.
- TRUE → текстовое сообщение пользователя → ветка редактирования.
- FALSE → callback от кнопки → ветка подтверждения/редактирования.[^1]
***
## 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`.[^1]
- Разбирает callback:
```javascript
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]);
```
- Определяет, редактировалась ли карточка:
```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`.[^1]
### 5.2. IF: `confirm_or_edit`
- Condition: `{{ $json.action }}` Equal `confirm`.
- TRUE → ветка подтверждения.
- FALSE → ветка редактирования.[^1]
### 5.3. IF: `check_if_edited` (ветка подтверждения)
- Condition: `{{ $json.isEditedCard }}` Equal `true`.
- TRUE → подтверждение после редактирования.
- FALSE → первое подтверждение без правок.[^1]
#### 5.3.1. FALSE (первое подтверждение)
- PostgreSQL SELECT: `row_select_by_id`
- Operation: Select
- Table: `transactions`
- Where: `id = {{ $json.transactionId }}`
- Always Output Data = ON.[^1]
- 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 (на выходе есть строка транзакции).[^1]
#### 5.3.2. TRUE (подтверждение после редактирования)
- Code: `parse_card_text`
- Парсит `counterparty` и `category` из текста карточки по regex.
- Возвращает: `transactionId`, `counterparty`, `category`, `chatId`, `messageId`.[^1]
- 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 (строка транзакции).[^1]
- Code: `prepare_confirmation_data`
```javascript
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` (после редактирования).[^1]
Важно: обе ветки несут в себе данные транзакции (`signed_amount`, `currency`, `counterparty`, `category`, `chatId`, `messageId`) к моменту входа в `confirmation_message`.[^1]
### 5.5. Telegram Answer Callback Query: `popup_confirm`
- Resource: Callback.
- Operation: Answer Callback Query.
- Query ID: `{{ $json.callback_query.id }}`
- Text: `✅ Транзакция подтверждена и сохранена`.[^1]
### 5.6. Telegram Send Message: `confirmation_message`
- Resource: Message.
- Operation: Send Message.
- Chat ID: `{{ $('answer_trigger').first().json.callback_query.message.chat.id }}`
- Text:
```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:
```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 → редактируем контрагента.[^1]
#### 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 }}`.[^1]
#### 6.1.4. PostgreSQL INSERT: `add_session_counterparty`
- Аналогично, но `waitingFor = 'counterparty'`.[^1]
### 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`.[^1]
### 7.2. PostgreSQL SELECT: `get_session`
- Operation: Select
- Table: `user_sessions`
- Where: `chatId = {{ $json.message.chat.id }}`
- Always Output Data = ON.[^1]
Возвращает:
```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`
```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'
};
```
### 7.5. PostgreSQL SELECT: `get_current_transaction`
- Operation: Select
- Table: `transactions`
- Where: `id = {{ $json.transactionId }}`
- Always Output Data = ON.[^1]
### 7.6. Code: `merge_changes`
```javascript
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:
```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 }}`.[^1]
***
## 8. Интеграция с Google Sheets
### 8.1. Структура таблицы
- Отдельный документ семейного бюджета в Google Sheets.
- Лист, например `Transactions`.
- Колонки:
1. дата
2. контрагент
3. сумма
4. категория
5. примечание (заполняется пользователем вручную).
### 8.2. Доступ через Google Service Account
- В Google Cloud проекте `budget` создан сервисный аккаунт.
- Включены API: Google Sheets API, Google Drive API.
- Скачан JSONключ сервисного аккаунта; в n8n созданы credentials типа **Google Service Account**, использован весь JSON или поля `client_email` + `private_key`
- В самой таблице сервисный аккаунт добавлен как редактор по `client_email`.
### 8.3. Узел Google Sheets после `merge_confirm_paths`
- После ноды `merge_confirm_paths` добавлен узел **Google Sheets** (v2).
- Credentials: Google Service Account.
- Resource: `Sheet Within Document`.
- Operation: `Append Row`.
- Document: семейный бюджет.
- Sheet: лист с транзакциями (например, `Transactions`).
**Mapping колонок (Map Each Column Manually):**
- `дата``{{ $json.received_at }}`
- `контрагент``{{ $json.counterparty }}`
- `сумма``{{ $json.raw_json?.signed_amount ?? $json.signed_amount }}`
- `категория``{{ $json.category }}`
- `примечание``""` (пусто, заполняется человеком).
**Фильтрация только подтверждённых транзакций:**
- Ветка к Google Sheets подключена после подтверждения (`update_after_confirm` / `update_edited_transaction` через `merge_confirm_paths`), где `human_verified = true` гарантирован.
***
## 9. Планируемые доработки
### 9.1. LLM / категории (Приоритет 1)
- [ ] Собрать статистику по качеству автокатегоризации и при необходимости скорректировать описания категорий/словарь.
### 9.2. Автоматизация запуска (Приоритет 2)
- [ ] Автозапуск LM Studio при старте системы (service/systemd).
- [ ] Docker `restart: always` для n8n и PostgreSQL.
- [ ] Healthcheck LM Studio API и уведомления в Telegram при падении.
### 9.3. Улучшения и UX (Приоритет 3)
- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций.
- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы).
- [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа).
- [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием).
- [ ] Обработка исторических данных (2025 год и далее) для накопления статистики.
- [ ] Finetuning модели LLM на собственных размеченных данных.
- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI).
***
**Версия документа:** 4.2
**Дата:** 20260109
**Статус:** Архитектура workflow обновлена: LLM работает с фиксированным набором категорий, подтверждённые транзакции пишутся в Google Sheets, дальнейшая работа — автоматизация запуска и улучшения UX.

735
backlog_buget_old.md Normal file
View File

@@ -0,0 +1,735 @@
# 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: <https://api.telegram.org>)
#### 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 = <https://api.telegram.org>
- [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 установлен в <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
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)

23
compose.yaml Normal file
View File

@@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16
container_name: postgres_budget
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /volume1/docker/postgres/data:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin_budget
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@vakanaut.ru
PGADMIN_DEFAULT_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5050:80"
depends_on:
- postgres

265
improvement.md Normal file
View File

@@ -0,0 +1,265 @@
# Улучшения системы обработки банковских транзакций
## 1. Упрощение логики бесконечного редактирования
### 1.1 Текущая проблема
- Используется таблица `user_sessions` для хранения состояния редактирования
- Множественные SELECT/UPDATE запросы при каждом действии пользователя
- Сложная логика ветвления в n8n для управления сессиями
### 1.2 Решение: Stateful callback_data с editMessage
**Принцип работы:** Храним все состояние редактирования прямо в callback_data кнопок Telegram
#### Формат callback_data
```text
edit_card_{transactionId}_{counterparty}_{category}
set_category_{transactionId}_{counterparty}_{newCategory}
edit_field_{transactionId}_{counterparty}_{category}_{field}
confirm_{transactionId}_{counterparty}_{category}
```
#### Примеры
```text
edit_card_123_Пятёрочкародукты
set_category_123_Пятёрочка_Авто
edit_field_123_Пятёрочкародукты_category
confirm_123_Пятёрочкародукты
```
#### Узлы n8n для удаления
1.`user_sessions` таблица
2.`get_session` (SELECT)
3.`add_session_category` (INSERT)
4.`add_session_counterparty` (INSERT)
5.`merge_user_and_session`
6.`parse_text_input` (частично)
7.`merge_changes`
#### Новые узлы
1.`create_edit_card` — генерирует карточку редактирования с inline-клавиатурой
2.`update_card_on_action` — обрабатывает callback и обновляет сообщение через editMessage
### 1.3 Преимущества
- **Уменьшение нагрузки на БД на 80%** — убираем 5-7 запросов на одно действие
- **Упрощение workflow** — вместо ветвления по сессиям — линейная обработка
- **Мгновенная обратная связь** — editMessage вместо новых сообщений
- **Stateless архитектура** — нет зависимостей между запросами
## 2. Улучшение UX для выбора категорий
### 2.1 Текущая проблема
- Текстовый ввод категорий требует запоминания всех 20 вариантов
- Ошибки при ручном вводе (опечатки, синонимы)
- Медленный процесс категоризации
### 2.2 Решение: Многоуровневая inline-клавиатура
#### Первый уровень — "умные" популярные категории
```javascript
// Алгоритм выбора популярных категорий:
// 1. Берем топ-3 категории пользователя из истории (если есть)
// 2. Добавляем глобально популярные категории
// 3. Всего 4 кнопки
const getUserTopCategories = async (chatId) => {
// Запрос к БД за последние 30 дней
const topCats = await db.query(`
SELECT category, COUNT(*) as count
FROM transactions
WHERE chat_id = $1
AND category IS NOT NULL
AND received_at > NOW() - INTERVAL '30 days'
GROUP BY category
ORDER BY count DESC
LIMIT 3
`, [chatId]);
return topCats.map(row => row.category);
};
const getDefaultCategories = () => ["Продукты", "Авто", "Дом"];
```
#### Второй уровень — все категории компактно
```javascript
// Без эмодзи для экономии места
const ALL_CATEGORIES = [
["Продукты", "Авто", "Здоровье", "Арчи"],
["ЖКХ", "Дом", "Проезд", "Одежда"],
["Химия", "Косметика", "Инвестиции", "Развлечения"],
["Общепит", "Штрафы", "Налоги", "Подписки"],
["Перевод", "Наличные", "Подарки", "Спорт"],
["Другое"]
];
// Каждая кнопка: callback_data = "set_category_{id}_{counterparty}_{category}"
```
#### Узлы n8n для изменения
1. 🔄 `category_edit``send_category_keyboard` (отправляет 4 кнопки)
2.`show_all_categories` → новый узел для отправки полного списка
3. ❌ Текстовый ввод категорий удаляется полностью
#### Кэширование популярных категорий
```javascript
// В Redis или памяти n8n (Global Variables)
// Ключ: user_top_cats_{chatId}
// TTL: 1 час
// Данные: ["Продукты", "Общепит", "Авто"]
// При запросе:
// 1. Проверяем кэш
// 2. Если нет → запрос к БД → сохранение в кэш
// 3. Возвращаем данные
```
### 2.3 Преимущества
- **В 3-5 раз быстрее** выбор категории
- **Нет ошибок ввода** — только валидные категории
- **Персонализация** — показываем часто используемые категории
- **Экономия места** — без эмодзи больше категорий на экране
## 3. Техническая реализация
### Изменения в БД
```sql
-- Удаляем таблицу user_sessions
DROP TABLE IF EXISTS user_sessions;
-- Добавляем кэш популярных категорий (опционально)
CREATE TABLE IF NOT EXISTS user_category_stats (
chat_id BIGINT,
category VARCHAR(100),
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_id, category)
);
-- Индекс для быстрого получения топ категорий
CREATE INDEX idx_user_cats ON user_category_stats(chat_id, count DESC);
```
### Изменения в workflow n8n
#### Ветка редактирования (новая)
```text
edit_field (callback) →
IF field = 'category' →
get_top_categories (Code + возможно DB) →
send_category_keyboard (Telegram Message)
IF field = 'counterparty' →
request_counterparty_input (Telegram Message) →
(текстовый ответ) →
update_card_with_new_counterparty (editMessage)
```
#### Ветка выбора категории
```text
set_category (callback) →
update_category_stats (DB) → -- инкрементируем счетчик
update_card (editMessage) → -- обновляем карточку
(возврат в режим редактирования)
```
#### Ветка "все категории"
```text
show_all_cats (callback) →
send_all_categories (Telegram Message) → -- компактная клавиатура 5x4
(пользователь выбирает) →
set_category (существующая ветка)
```
### Лимиты и ограничения
1. **Callback_data length** ≤ 64 байт
```javascript
// Решение: кодирование/декодирование длинных строк
const encodeData = (str) => encodeURIComponent(str.substring(0, 20));
const decodeData = (str) => decodeURIComponent(str);
```
2. **Inline keyboard rows** ≤ 8-10 для удобства
3. **Кэш TTL** = 1 час для актуальности
## 4. Ожидаемые результаты
### Метрики улучшений
| Показатель | Было | Стало | Улучшение |
| ------------ | ------ | ------- | ----------- |
| Запросов к БД на действие | 5-7 | 1-2 | ~70% ↓ |
| Время категоризации | 10-30 сек | 2-5 сек | 5x ↑ |
| Ошибок категоризации | 15-20% | <2% | 10x ↓ |
| Пользовательских кликов | 3-5 | 1-2 | 2x ↓ |
### Безопасность
1. Все данные в callback_data URL-encoded
2. Валидация transactionId при каждом запросе
3. Проверка прав доступа к чату
### Резервное решение
Если callback_data превышает лимит Telegram:
```javascript
// Fallback: короткий хэш + временное хранилище
const hash = md5(transactionId + counterparty + category).substring(0, 8);
cache.set(`card_${hash}`, {transactionId, counterparty, category}, 300); // 5 мин
callback_data = `short_${hash}`;
```
## 5. План внедрения
### Фаза 1: Подготовка (1-2 дня)
1. Создать новую таблицу `user_category_stats`
2. Написать миграцию данных из истории транзакций
3. Обновить узлы обработки callback_data
### Фаза 2: Внедрение (1 день)
1. Добавить новые узлы n8n параллельно существующим
2. Протестировать на отдельном Telegram-боте
3. Проверить лимиты callback_data
### Фаза 3: Переключение (1 день)
1. Направить 10% трафика на новую систему
2. Сравнить метрики
3. Полное переключение при успехе
### Фаза 4: Оптимизация (непрерывно)
1. Мониторинг нагрузки БД
2. Настройка кэширования
3. A/B тестирование разных наборов популярных категорий
## Заключение
Предложенные изменения устраняют основные боли пользователей:
- **Упрощают редактирование** через мгновенное обновление карточек
- **Ускоряют категоризацию** через умные inline-клавиатуры
- **Уменьшают нагрузку** на инфраструктуру через stateless дизайн
Общее количество узлов n8n сократится с 36 до ~25 при улучшении UX и производительности.

126
index.html Normal file
View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css"
integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="style.css">
<title>Document</title>
</head>
<body class="page">
<p>
<strong>Цель:</strong> формирование, отбор и поддержка проектов,
ориентированных на прикладное использование результатов научных
исследований, разработок и учебно-методических заделов МИЭМ.
</p>
<p><strong>Ключевые положения:</strong></p>
<ol>
<li>На конкурс могут предлагаться проекты трех типов:</li>
<ul class="common-list">
<li>Ориентированные фундаментальные</li>
<li>Прикладные исследования и разработки</li>
<li>Учебно-методические разработки</li>
</ul>
<li>
Объем финансирования проекта может составлять от 500 тыс. руб.
до 3 млн. руб. на срок до 1 года.
</li>
<li>
К экспертизе каждого из проектов привлекаются не менее 2
профильных экспертов, их оценка наряду с оценкой членов Научной
комиссии учитывается в итоговом рейтинге проектов.
</li>
<li>
Все проекты проходят процедуру публичной защиты проектов, как на
этапе конкурса, так и на этапе отчета о результатах проделанной
работы.
</li>
<li>
Результатом должен являться отчет о НИР, отчет о патентных
исследованиях (для проектов 1 и 2 типа), а также те показатели,
которые заявители установили на этапе подачи.
</li>
</ol>
<p><strong>Требования к участникам:</strong></p>
<ul class="list-none">
<li>
- коллектив должен состоять из 5 и более участников (без
ограничения по максимальному количеству участников, работающих в
ней на постоянной основе);
</li>
<li>
- руководитель - работник МИЭМ НИУ ВШЭ, занимающий должность
работника НИУ ВШЭ по основному месту работы;
</li>
<li>
- участник может быть руководителем только одной проектной группы,
при этом не ограничивается количество научных групп, где этот
участник может быть исполнителем;
</li>
<li>
- проектные группы должны иметь смешанный состав, включая
работников и обучающихся;
</li>
<li>
- привлечение внешних исполнителей возможно с обязательством, при
необходимости, последующего найма на условиях полной занятости
или совместительства в структурное подразделение МИЭМ НИУ ВШЭ.
</li>
</ul>
<p>
Предлагаемый проект должен быть оригинальным и не дублировать
проекты, поддерживаемые в рамках действующих централизованных
программ НИУ ВШЭ или за счет внешнего финансирования.
</p>
<p>
Конкурсный отбор, реализация и приемка результатов проектов состоят
из шести этапов:
</p>
<ul class="list-none">
<li>1 этап: прием заявок;</li>
<li>2 этап: экспертиза по формальным признакам;</li>
<li>3 этап: экспертиза заявок по существу;</li>
<li>4 этап: защита проектов, подведение итогов Конкурса;</li>
<li>5 этап: реализация проектов;</li>
<li>6 этап: защита результатов проектов.</li>
</ul>
<p><strong>Финансирование:</strong></p>
<ul>
<li>
Объем финансирования проектной группы составляет от 500 000 до 3
000 000 рублей.
</li>
<li>
Доля оплаты труда руководителя не может превышать 25 % фонда
оплаты труда участников проектной группы.
</li>
<li>
Доля оплаты труда обучающихся не может быть ниже 25 % фонда
оплаты труда участников проектной группы.
</li>
<li>
Сбор конкурсных заявок проводится в МИЭМ НИУ ВШЭ ежегодно с
возможностью выделения нескольких туров.
</li>
<li>
Сроки проведения и количество туров Конкурса устанавливаются
приказом директора МИЭМ НИУ ВШЭ.
</li>
</ul>
<p><strong>Сроки реализации проектов 2026 г.: 01.03 - 31.11.2026 г.</strong></p>
<p>
Заявки принимаются до 22.01.2026 г. по
<a href="https://forms.yandex.ru/cloud/693fd1eb90290261b6f0e1d3/"
>ссылке</a
>
</p>
</body>
</html>

143
index.js Normal file
View File

@@ -0,0 +1,143 @@
function toNumberRu(s) {
if (!s) return null;
const cleaned = String(s)
.replace(/\s/g, "")
.replace(",", ".")
.replace(/[^\d.]/g, "");
const n = Number(cleaned);
console.log("toNumberRu: " + Number.isFinite(n));
return Number.isFinite(n) ? n : null;
}
function paringSms(sms) {
console.log(sms);
const raw = sms.body.message ?? "";
console.log("raw: " + raw);
const text = String(raw).replace(/\s+/g, " ").trim();
const lower = text.toLowerCase();
console.log("text: " + text);
const sender = sms.body?.sender ?? null;
console.log("sender: " + sender);
// 1) Классификация
let action = "unknown";
const rules = [
{ action: "salary", re: /зарплат/i },
{ action: "reversal", re: /(отмена|возврат)/i },
{ action: "payment", re: /(оплата|покупк)/i },
{ action: "transfer", re: /перевод/i },
{ action: "writeoff", re: /списан/i },
{ action: "income", re: /(поступлен|поступление|зачислен)/i },
];
for (const r of rules) {
if (r.re.test(lower)) {
action = r.action;
break;
}
}
console.log("action: " + action);
// 2) Баланс
const balanceRegex =
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)(?=\s|$)/i;
const balanceMatch = text.match(balanceRegex);
console.log("balanceMatch: " + balanceMatch);
const balance = balanceMatch ? toNumberRu(balanceMatch[2]) : null;
console.log("balance: " + balance);
// 3) Сумма операции
let amount = null;
const amountNearActionRegex =
/(отмена[:\s-]*оплата|отмена|списание|оплата|перевод|поступление|зарплат[аы]?)[\s:]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
const near = text.match(amountNearActionRegex);
console.log("near: " + near);
if (near) {
amount = toNumberRu(near[2]);
console.log(amount);
} else {
const textWithoutBalance = text.replace(balanceRegex, " ");
console.log("textWithoutBalance: " + textWithoutBalance);
const amountRegex = /([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
const m = textWithoutBalance.match(amountRegex);
amount = m ? toNumberRu(m[1]) : null;
console.log("m: " + m);
}
const currency = "RUB";
// 4) signedAmount
let signedAmount = amount;
if (amount != null) {
if (["payment", "transfer", "writeoff"].includes(action))
signedAmount = -Math.abs(amount);
if (["income", "salary", "reversal"].includes(action))
signedAmount = Math.abs(amount);
}
console.log({
raw_sms: raw,
sms_text: text,
sms_sender: sender,
action, // income/payment/transfer/salary/reversal/writeoff
amount,
signed_amount: signedAmount,
currency,
balance,
received_at: new Date().toISOString(),
});
return [
{
raw_sms: raw,
sms_text: text,
sms_sender: sender,
action, // income/payment/transfer/salary/reversal/writeoff
amount,
signed_amount: signedAmount,
currency,
balance,
received_at: new Date().toISOString(),
},
];
}
const input_sms = {
body: {
message:
'Оплата 980р Карта*4215 "SKURATOV COFFE Баланс 207033.86р 19:52',
sender: "VTB",
formatted_message:
'📩 *New SMS Received*\n👤 *From:* VTB\n\nОплата 980р Карта*4215 "SKURATOV COFFE Баланс 207033.86р 19:52',
contact: "",
},
};
const input_sms2 = {
body: {
formatted_message:
"📩 *New SMS Received*\n👤 *From:* VTB\n\nОплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56",
contact: "",
sender: "VTB",
message:
"Оплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56",
},
};
paringSms(input_sms);
// paringSms(input_sms2);

34
style.css Normal file
View File

@@ -0,0 +1,34 @@
.list-none {
list-style: none;
margin-left: -15px;
}
.common-list {
list-style-type: disc;
}
p,
ul,
ol,
dl,
li {
padding-top: 0;
padding-bottom: 0;
margin: 10px 0 0;
}
.page {
max-width: 896px;
font-family: "HSE Sans", "Proxima Nova", "Helvetica Neue", Arial, sans-serif;
font: 18px;
margin: auto;
line-height: 1.33;
text-align: justify;
}
a:not([class]),
:link,
:visited,
.link {
color: #007ac5;
border-bottom-color: rgba(0, 122, 197, 0.3);
}

50
test.js Normal file
View File

@@ -0,0 +1,50 @@
const text = "Оплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56";
const balanceRegex =
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)\b/i;
console.log("Текст:", text);
console.log("Регулярка:", balanceRegex);
// Проверим по шагам
console.log("\n=== ДИАГНОСТИКА ===");
// 1. Проверим, есть ли слово "баланс" в тексте
console.log("1. Содержит 'Баланс':", text.includes("Баланс"));
console.log(" Индекс 'Баланс':", text.indexOf("Баланс"));
// 2. Проверим символ после "Баланс"
const balanceIndex = text.indexOf("Баланс");
if (balanceIndex > -1) {
const afterBalance = text.substring(balanceIndex, balanceIndex + 20);
console.log("2. Текст после 'Баланс':", afterBalance);
console.log(" Коды символов после 'Баланс':");
for (let i = 0; i < 10; i++) {
const char = text[balanceIndex + 7 + i];
console.log(` ${char} (${char?.charCodeAt(0)})`);
}
}
// 3. Проверим часть с суммой вручную
console.log("\n3. Проверка частичными регулярками:");
// Проверяем только слово "баланс"
const test1 = /баланс/i.test(text);
console.log(" Находит 'баланс':", test1);
// Проверяем "баланс" с пробелом и числом
const test2 = /баланс\s*\d/i.test(text);
console.log(" Находит 'баланс' с числом:", test2);
// Проверяем всю сумму без валюты
const test3 = text.match(/баланс[:\s]*([\d\s.,]+)/i);
console.log(" Находит сумму без валюты:", test3);
// Проверяем валюту отдельно
const test4 = text.match(/р\b/);
console.log(" Находит 'р' с границей слова:", test4);
// Альтернативная регулярка без \b
const balanceRegexSimple =
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
console.log("\n4. С упрощенной регуляркой (без \\b):");
console.log(" Результат:", text.match(balanceRegexSimple));