commit 643eddb5fcb674d8953ed90532706d199796100b Author: vakabunga Date: Fri Jan 16 18:37:32 2026 +0300 initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..fab319f --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +POSTGRES_DB=budget +POSTGRES_USER=budget_user +POSTGRES_PASSWORD=difficult_P@ +PGADMIN_DEFAULT_PASSWORD=easy_P@ diff --git a/2025.pdf b/2025.pdf new file mode 100644 index 0000000..e8a7580 Binary files /dev/null and b/2025.pdf differ diff --git a/2026.pdf b/2026.pdf new file mode 100644 index 0000000..ace0b9d Binary files /dev/null and b/2026.pdf differ diff --git a/backlog_buget.md b/backlog_buget.md new file mode 100644 index 0000000..4771d88 --- /dev/null +++ b/backlog_buget.md @@ -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://: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-клавиатуры для категорий. diff --git a/backlog_buget_260106.md b/backlog_buget_260106.md new file mode 100644 index 0000000..032459d --- /dev/null +++ b/backlog_buget_260106.md @@ -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. проходят через human‑in‑the‑loop: пользователь подтверждает или редактирует контрагента и категорию; +6. после финального подтверждения данные фиксируются в БД и могут быть отправлены во внешние системы (Google Sheets и т.п. — планируется). + +Проект развернут на Synology NAS: PostgreSQL. n8n в Docker и LM Studio — на локальной машине. + +*** + +## 2. База данных + +### 2.1. Таблица `transactions` + +```sql +id BIGSERIAL PRIMARY KEY, +raw_sms TEXT NOT NULL, +sms_text TEXT NOT NULL, +sms_sender VARCHAR(50), +action VARCHAR(20) NOT NULL, +amount NUMERIC(15, 2), +signed_amount NUMERIC(15, 2), +currency VARCHAR(3) DEFAULT 'RUB', +balance NUMERIC(15, 2), +received_at TIMESTAMP WITH TIME ZONE NOT NULL, +processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +raw_json JSONB NOT NULL, + +-- LLM / Human-in-the-loop +counterparty VARCHAR(255), +category VARCHAR(100), +subcategory VARCHAR(100), +llm_processed_at TIMESTAMP WITH TIME ZONE, +human_verified BOOLEAN DEFAULT FALSE, +human_verified_at TIMESTAMP WITH TIME ZONE +``` + +Есть индексы по дате, действию, контрагенту, категории и флагу `human_verified`. + +### 2.2. Таблица `user_sessions` + +Используется для хранения состояния редактирования в Telegram (какую транзакцию и какое поле сейчас меняет пользователь). + +```sql +"chatId" BIGINT PRIMARY KEY, +"transactionId" BIGINT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, +"waitingFor" VARCHAR(20) NOT NULL, -- 'category' | 'counterparty' +"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, +"tempCounterparty" VARCHAR(255), +"tempCategory" VARCHAR(100) +``` + +*** + +## 3. Ветка 1. Обработка входящих SMS (Webhook → LLM → БД → Telegram) + +### 3.1. Webhook (Node: `Webhook`) + +- Method: `POST`. +- Принимает JSON с полями SMS от прокси/смс‑шлюза. +- Входные данные: `raw_sms`, `sms_text`, `sms_sender`, дата и пр. + +### 3.2. Code: `parse_from_sms` + +- Преобразует входящий JSON из Webhook в нормализованный объект: + - `action` (тип операции: payment/credit/cash и т.п.); + - `amount`, `signed_amount`, `currency`; + - `balance`; + - `received_at`; + - `raw_sms`, `sms_text`, `sms_sender`; + - `raw_json` — исходный JSON целиком. + +Результат — один объект `NormalizedTransaction`. + +### 3.3. PostgreSQL: `create_from_sms` (INSERT) + +- Operation: **Insert** +- Table: `transactions` +- Поля: все базовые из `parse_from_sms` (без LLM‑полей). +- Options: **Always Output Data = ON** — чтобы сразу получить `id` созданной строки. + +### 3.4. Set: `Edit Fields` + +- Урезает объект перед LLM (data minimization): оставляет только то, что нужно модели: + - `sms_text`, `action`, `amount`, `currency`, `balance`. + +### 3.5. HTTP Request: `post_to_llm` + +- Метод: POST. +- URL: локальный LM Studio API (`http://:1234/v1/chat/completions`). +- Тело: промпт с описанием SMS, типа операции и суммы. +- Модель: Qwen2.5‑7B‑Instruct (через LM Studio). + +### 3.6. Code: `parse_from_llm` + +- Берёт `choices[^0].message.content` из ответа. +- Убирает возможные обёртки ```json /```. +- Парсит JSON вида: + +```json +{"counterparty": "...", "category": "...", "subcategory": "...", "confidence": 0.0} +``` + +- Мёрджит с исходными данными из `create_from_sms`. +- Гарантирует значения по умолчанию, если LLM что‑то не вернула: + - `counterparty`: `"Не определён"` + - `category`: `"Неизвестно"` + - `subcategory`: `"Требует уточнения"`. + +### 3.7. PostgreSQL: `update_llm_fields` (UPDATE) + +- Operation: **Update** +- Table: `transactions` +- Where: `id = {{ $json.id }}` +- Поля для обновления: + - `counterparty`, `category`, `subcategory`; + - `llm_processed_at = NOW()`; + - `human_verified = FALSE`. +- Options: **Always Output Data = ON** — чтобы вернуть актуальную строку с `chatId`. + +### 3.8. Telegram Send Message: `edit_or_confirm` + +- Chat ID: `{{ $json.chatId }}` (expression). +- Parse Mode: HTML (или Markdown, без использования опасных спецсимволов). +- Text, пример: + +```text +Требуется подтверждение транзакции #{{ $json.id }} + +Сумма: {{ $json.signed_amount }} {{ $json.currency }} +Контрагент: {{ $json.counterparty }} +Категория: {{ $json.category }} +Дата: {{ $json.received_at }} + +Подтвердить или изменить? +``` + +- Reply Markup (Inline Keyboard, через визуальный редактор ноды): + - Row 1: + - Text: `✅ Подтвердить`, Callback Data: `confirm_{{ $json.id }}` + - Row 2: + - Text: `✏️ Изменить категорию`, Callback Data: `edit_category_{{ $json.id }}` + - Text: `✏️ Изменить контрагента`, Callback Data: `edit_counterparty_{{ $json.id }}` + +*** + +## 4. Ветка 2. Единый Telegram Trigger и разветвление по типу события + +### 4.1. Telegram Trigger: `answer_trigger` + +- Credential: Telegram Bot. +- Trigger On: `callback_query`. +- Updates: `callback_query`, `message` (оба включены). +- Base URL: `https://api.telegram.org`. + +Все события (клики по кнопкам и текстовые сообщения) приходят в этот один триггер. + +### 4.2. IF: `split_event_type` + +- Condition: `{{ $json.callback_query }}` **Is Empty**. +- TRUE → это **текстовое сообщение** пользователя → в ветку редактирования (parse_text_input). +- FALSE → это **callback от кнопки** → в `parse_from_callback`. + +*** + +## 5. Ветка 3. Подтверждение транзакции (без/после редактирования) + +### 5.1. Code: `parse_from_callback` + +Вход: объект update с `callback_query`. +Код (концептуально): + +- Получает: + - `callbackData = $json.callback_query.data`; + - `chatId = $json.callback_query.message.chat.id`; + - `messageId = $json.callback_query.message.message_id`; + - `messageText = $json.callback_query.message.text`. +- Разбирает callback: + +```javascript +const parts = callbackData.split('_'); +const action = parts; // 'confirm' | 'edit' +const field = parts.length === 3 ? parts[^2] : null; // 'category' | 'counterparty' +const transactionId = parseInt(parts[parts.length - 1]); +``` + +- Определяет, редактировалась ли карточка: + +```javascript +const isEditedCard = messageText && messageText.includes('не сохранено'); +``` + +- Парсит текущие значения из текста карточки: + +```javascript +const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/); +const categoryMatch = messageText.match(/Категория:\s*(.+?)(?:\n|$)/); +``` + +- Возвращает объект с: + - `action`, `field`, `transactionId`, `chatId`, `messageId`, + - `messageText`, `isEditedCard`, `originalCallbackData`, + - `counterparty`, `category`. + +### 5.2. IF: `confirm_or_edit` + +- Condition: `{{ $json.action }}` Equal `confirm`. +- TRUE → ветка подтверждения. +- FALSE → ветка редактирования. + +### 5.3. IF: `check_if_edited` + +Работает только в ветке подтверждения. + +- Condition: `{{ $json.isEditedCard }}` Equal `true`. +- TRUE → карточка уже обновлялась (на ней есть маркер «не сохранено»). +- FALSE → первое подтверждение без редактирования. + +#### 5.3.1. FALSE (первое подтверждение) + +- PostgreSQL SELECT: `row_select_by_id` + - Operation: Select + - Table: `transactions` + - Where: `id = {{ $json.transactionId }}` + - Always Output Data = ON. +- PostgreSQL UPDATE: `update_after_confirm` + - Operation: Update + - Table: `transactions` + - Columns: + - `human_verified = true`; + - `human_verified_at = {{ $now }}`. + - Where: `id = {{ $json.transactionId }}`. + +#### 5.3.2. TRUE (после редактирования) + +- Code: `parse_card_text` + - Парсит из текста карточки: + - `counterparty` и `category`; + - использует те же regex, что и в `parse_from_callback`. + - Возвращает: `transactionId`, `counterparty`, `category`, `chatId`, `messageId`. +- PostgreSQL UPDATE: `update_edited_transaction` + - Operation: Update + - Table: `transactions` + - Columns: + - `counterparty = {{ $json.counterparty }}`; + - `category = {{ $json.category }}`; + - `human_verified = true`. + - Where: `id = {{ $json.transactionId }}`. +- Code: `prepare_confirmation_data` + - Приводит данные к единому формату для вывода: + - `transactionId`, `chatId`, `messageId`, `counterparty`, `category`. + +### 5.4. Merge: `merge_confirm_paths` + +- Type: **Merge** +- Mode: **Append** +- Input 1: `update_after_confirm` (первое подтверждение). +- Input 2: `prepare_confirmation_data` (после редактирования). +- Выход: в ноду `popup_confirm`. + +### 5.5. Telegram Answer Callback Query: `popup_confirm` + +- Query ID: `{{ $json.callback_query.id }}` +- Text: `✅ Транзакция подтверждена и сохранена`. + +### 5.6. Telegram Edit Message Text: `confirmation_message` + +- Chat ID: `{{ $json.chatId }}` +- Message ID: `{{ $json.messageId }}` +- Text: + +```text +✅ Транзакция #{{ $json.transactionId }} подтверждена + +Контрагент: {{ $json.counterparty }} +Категория: {{ $json.category }} +``` + +*** + +## 6. Ветка 4. Редактирование транзакции с циклом + +### 6.1. Построение сессии редактирования + +После `confirm_or_edit`, если `action = 'edit'`, управление идёт в: + +#### 6.1.1. PostgreSQL Execute Query: `empty_session` + +- Удаляет старую сессию и пробрасывает входные данные дальше. +- Query: + +```sql +DELETE FROM user_sessions WHERE "chatId" = {{ $json.chatId }}; + +SELECT + '{{ $json.action }}'::text AS "action", + '{{ $json.field }}'::text AS "field", + {{ $json.transactionId }}::bigint AS "transactionId", + {{ $json.chatId }}::bigint AS "chatId", + {{ $json.messageId }}::bigint AS "messageId", + {{ $json.isEditedCard }}::boolean AS "isEditedCard", + '{{ $json.originalCallbackData }}'::text AS "originalCallbackData", + '{{ $json.counterparty }}'::text AS "counterparty", + '{{ $json.category }}'::text AS "category"; +``` + +#### 6.1.2. IF: `which_field_to_edit` + +- Condition: `{{ $json.field }}` Equal `category`. +- TRUE → редактируем категорию. +- FALSE → редактируем контрагента. + +#### 6.1.3. PostgreSQL INSERT: `add_session_category` + +- Table: `user_sessions` +- Columns: + - `chatId` = `{{ $json.chatId }}` + - `transactionId` = `{{ $json.transactionId }}` + - `waitingFor` = `category` (Fixed) + - `tempCounterparty` = `{{ $json.counterparty }}` + - `tempCategory` = `{{ $json.category }}` + +#### 6.1.4. PostgreSQL INSERT: `add_session_counterparty` + +Аналогично, но: + +- `waitingFor` = `counterparty`. + +### 6.2. Запрос на ввод значения + +#### 6.2.1. Telegram Send Message: `category_edit` + +- Chat ID: `{{ $json.chatId }}` +- Text: + +```text +Введите новую категорию для транзакции #{{ $json.transactionId }} + +ВАЖНО: Лимит 12 символов +``` + +#### 6.2.2. Telegram Send Message: `counterparty_edit` + +- Chat ID: `{{ $json.chatId }}` +- Text: + +```text +Введите нового контрагента для транзакции #{{ $json.transactionId }} + +ВАЖНО: Лимит 12 символов +``` + +Далее пользователь вводит текст в ответ. + +*** + +## 7. Ветка 5. Обработка текстового ответа пользователя + +### 7.1. answer_trigger → split_event_type (TRUE) + +Приходит `message` от пользователя (без `callback_query`). + +TRUE‑ветка `split_event_type` ведёт в `get_session`. + +### 7.2. PostgreSQL SELECT: `get_session` + +- Operation: Select +- Table: `user_sessions` +- Where: `chatId = {{ $json.message.chat.id }}` +- Always Output Data = ON. + +Возвращает: + +```json +{ + "chatId": "...", + "transactionId": "...", + "waitingFor": "category" | "counterparty", + "tempCounterparty": "...", + "tempCategory": "...", + "createdAt": "..." +} +``` + +### 7.3. Code: `merge_user_and_session` + +Объединяет данные сообщения и сессии: + +```javascript +const sessionData = $input.first().json; +const userMessage = $('split_event_type').first().json; + +return { + message: userMessage.message, + session: sessionData +}; +``` + +### 7.4. Code: `parse_text_input` + +Работает уже поверх структуры `{ message, session }`: + +```javascript +const userText = $input.first().json.message.text; +const chatId = $input.first().json.message.chat.id; + +// Игнорируем команды +if (userText.startsWith('/')) { + throw new Error('Команда проигнорирована'); +} + +const sessionData = $input.first().json.session; + +if (!sessionData || !sessionData.transactionId) { + throw new Error('Сессия не найдена. Начните редактирование заново из карточки транзакции.'); +} + +return { + newValue: userText.trim(), + chatId: chatId, + transactionId: parseInt(sessionData.transactionId), + changedField: sessionData.waitingFor // 'category' | 'counterparty' +}; +``` + +Выход: `newValue`, `chatId`, `transactionId`, `changedField`. + +### 7.5. PostgreSQL SELECT: `get_current_transaction` + +- Operation: Select +- Table: `transactions` +- Where: `id = {{ $json.transactionId }}` +- Always Output Data = ON. + +Возвращает текущую строку транзакции (с LLM‑значениями). + +### 7.6. Code: `merge_changes` + +Применяет изменение без записи в БД, с учётом данных из `user_sessions`: + +```javascript +const textInput = $('parse_text_input').first().json; +const sessionData = $('get_session').first().json; +const currentData = $input.first().json; + +const tempCategory = sessionData?.tempCategory ?? currentData.category; +const tempCounterparty = sessionData?.tempCounterparty ?? currentData.counterparty; + +const updatedCategory = + textInput.changedField === 'category' + ? textInput.newValue + : tempCategory; + +const updatedCounterparty = + textInput.changedField === 'counterparty' + ? textInput.newValue + : tempCounterparty; + +return { + ...currentData, + category: updatedCategory, + counterparty: updatedCounterparty, + chatId: textInput.chatId +}; +``` + +Результат — «виртуально» обновлённая транзакция, которая ещё не записана в БД. + +### 7.7. Telegram Send Message: `show_updated_card` + +- Chat ID: `{{ $json.chatId }}` +- Text: + +```text +Данные обновлены (не сохранено) + +Сумма: {{ $json.signed_amount }} {{ $json.currency }} +Контрагент: {{ $json.counterparty }} +Категория: {{ $json.category }} +Дата: {{ $json.received_at }} + +ВАЖНО: Лимит 12 символов на поле +``` + +- Inline Keyboard (аналогично `edit_or_confirm`): + - `✅ Подтвердить` → `confirm_{{ $json.id }}` + - `✏️ Изменить категорию` → `edit_category_{{ $json.id }}` + - `✏️ Изменить контрагента` → `edit_counterparty_{{ $json.id }}` + +Пользователь может снова начать цикл редактирования или подтвердить. + +*** + +## 8. Статус проекта на 05.01.2026 + +### 8.1. Реализовано + +- Полная цепочка обработки SMS: + - Webhook → parse_from_sms → create_from_sms → post_to_llm → parse_from_llm → update_llm_fields → edit_or_confirm. +- Единый Telegram Trigger: + - разделение message / callback через `split_event_type`. +- Ветка подтверждения: + - первое подтверждение → `human_verified = TRUE`; + - подтверждение после редактирования → парсинг карточки и финальное обновление `counterparty`/`category`. +- Ветка редактирования с сессиями: + - хранение состояния в `user_sessions` (какую транзакцию и какое поле редактируем); + - временные поля `tempCounterparty`, `tempCategory` для сохранения уже внесённых изменений; + - цикл «Изменить → ввести текст → обновлённая карточка → снова изменить / подтвердить» — логика нод выстроена, корректность проходит по шагам в ручных тестах. + +### 8.2. На чём остановились + +- Полный автоматический цикл редактирования **почти** отлажен: + - контрагент и категория при последовательных правках больше не должны затирать друг друга значениями LLM из БД; + - требуется ещё один‑два полных сквозных теста «несколько правок подряд → финальное подтверждение → проверка строки в `transactions`», чтобы зафиксировать, что все граничные случаи (несколько итераций, смена поля, отмена и т.п.) работают стабильно. + +*** + +## 9. Планируемые доработки + +(частично перенесено из предыдущей версии документа и актуализировано) + +### 9.1. Интеграция с Google Sheets (Приоритет 1) + +- [ ] Создать таблицу семейного бюджета в Google Sheets. +- [ ] Настроить Google Service Account и подключить Google Sheets node в n8n. +- [ ] Добавить ноду Google Sheets **после** `merge_confirm_paths`: + - записывать только подтверждённые транзакции; + - колонки: Дата | Контрагент | Сумма | Категория | Подкатегория. +- [ ] Настроить форматирование (цвета для доходов/расходов). + +### 9.2. Автоматизация запуска (Приоритет 2) + +- [ ] Автозапуск LM Studio при старте системы (service/systemd). +- [ ] Docker `restart: always` для n8n и PostgreSQL. +- [ ] Health‑check LM Studio API и уведомления в Telegram при падении. + +### 9.3. Улучшения и UX (Приоритет 3) + +- [ ] Удалить подкатегорию. +- [ ] Удалить confidence из промпта и на всех этапах Workflow. +- [ ] Скорректировать промпт. +- [ ] Обработка исторических данных (2025 год и далее) для накопления статистики. +- [ ] Потенциальный fine‑tuning модели LLM на собственных размеченных данных. +- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций. +- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы). +- [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа). +- [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием). +- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI). + +*** + +**Версия документа:** 4.0 +**Дата:** 2026‑01‑05 +**Статус:** Основной функционал реализован, финальная отладка цикла редактирования/подтверждения в процессе; интеграция с внешними отчётами и автоматизация запуска в планах. diff --git a/backlog_buget_260107.md b/backlog_buget_260107.md new file mode 100644 index 0000000..00483f1 --- /dev/null +++ b/backlog_buget_260107.md @@ -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. проходят через human‑in‑the‑loop: пользователь подтверждает или редактирует контрагента и категорию; +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://:1234/v1/chat/completions`). +- Тело: промпт с описанием SMS, типа операции и суммы. +- Модель: Qwen2.5‑7B‑Instruct (через 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. +- [ ] Health‑check LM Studio API и уведомления в Telegram при падении.[file:46] + +### 8.3. Улучшения и UX (Приоритет 3) + +- [ ] Удалить подкатегорию. +- [ ] Удалить confidence из промпта и на всех этапах Workflow. +- [ ] Скорректировать промпт. +- [ ] Обработка исторических данных (2025 год и далее) для накопления статистики. +- [ ] Fine‑tuning модели LLM на собственных размеченных данных. +- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций. +- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы). +- [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа). +- [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием). +- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI).[file:46] + +*** + +**Версия документа:** 4.1 +**Дата:** 2026‑01‑07 +**Статус:** Архитектура workflow зафиксирована, дальнейшая работа — интеграция с отчётностью и улучшения UX.[file:46] diff --git a/backlog_buget_260111.md b/backlog_buget_260111.md new file mode 100644 index 0000000..ac17596 --- /dev/null +++ b/backlog_buget_260111.md @@ -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. проходят через human‑in‑the‑loop: пользователь подтверждает или редактирует контрагента и категорию; +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://:1234/v1/chat/completions`). +- Модель: Qwen2.5‑7B‑Instruct (через 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. +- [ ] Health‑check LM Studio API и уведомления в Telegram при падении. + +### 9.3. Улучшения и UX (Приоритет 3) + +- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций. +- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы). +- [ ] Периодическая очистка старых записей в `user_sessions` (например, старше 1 часа). +- [ ] Добавление исходного текста SMS в карточку (с безопасным форматированием или экранированием). +- [ ] Обработка исторических данных (2025 год и далее) для накопления статистики. +- [ ] Fine‑tuning модели LLM на собственных размеченных данных. +- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI). + +*** + +**Версия документа:** 4.2 +**Дата:** 2026‑01‑09 +**Статус:** Архитектура workflow обновлена: LLM работает с фиксированным набором категорий, подтверждённые транзакции пишутся в Google Sheets, дальнейшая работа — автоматизация запуска и улучшения UX. diff --git a/backlog_buget_old.md b/backlog_buget_old.md new file mode 100644 index 0000000..8ec0457 --- /dev/null +++ b/backlog_buget_old.md @@ -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: ) + +#### n8n Workflow - Основная ветка обработки SMS + +- [x] Webhook (POST) настроен для приёма SMS +- [x] Auth check (проверка x-api-key заголовка) +- [x] Code node (parse_sms) для парсинга SMS → NormalizedTransaction JSON +- [x] PostgreSQL INSERT (create_from_sms) для записи базовых данных транзакции + - Always Output Data = ON для получения id +- [x] Edit Fields (Set) для data minimization перед LLM +- [x] HTTP Request (post_to_llm) для обращения к LM Studio API +- [x] Code node (parse_from_llm) для парсинга JSON-ответа от LLM + - Очистка markdown обёртки (```json) + - Merge с originalData из create_from_sms + - Исправлена ошибка: choices.message → choices[0].message +- [x] PostgreSQL UPDATE (update_llm_fields) для записи обогащённых данных + - counterparty, category, subcategory + - llm_processed_at + - human_verified = FALSE (пока не подтверждено) +- [x] Telegram Send Message (edit_or_confirm) для отправки карточки транзакции с кнопками + - Inline Keyboard: Подтвердить / Изменить категорию / Изменить контрагента + +#### LLM - Локальная модель + +- [x] LM Studio установлен и настроен +- [x] Модель Qwen2.5-7B-Instruct (Q6_K, 6 ГБ) загружена +- [x] API-сервер запущен (порт 1234, Serve on Local Network включен) +- [x] Параметры модели оптимизированы (Context: 8192, Temperature: 0.2, Max Tokens: 512) +- [x] Промпт улучшен для корректной работы с неизвестными контрагентами +- [x] Обработка markdown-обёртки в ответах (```json) + +#### Human-in-the-loop через Telegram - ПОЛНОСТЬЮ ЗАВЕРШЕНО ✅ + +**Единый Telegram Trigger (решена проблема конфликта webhook):** + +- [x] Один answer_trigger для ВСЕХ событий (callback_query + message) +- [x] Решена проблема 403 Forbidden при регистрации webhook +- [x] Telegram Credential настроен с Base URL = +- [x] IF node (split_event_type) для разделения типов событий + +**TRUE ветка (подтверждение) - ЗАВЕРШЕНА:** + +- [x] Code node (parse_from_callback) для парсинга callback_data + - Извлечение: action, field, transactionId, chatId, messageId, messageText + - Определение isEditedCard (проверка текста "не сохранено") +- [x] IF node (confirm_or_edit) для разделения подтверждения/редактирования +- [x] IF node (check_if_edited) для определения источника подтверждения + - TRUE: подтверждение после редактирования → parse_card_text + - FALSE: первое подтверждение без изменений → row_select_by_id +- [x] Code node (parse_card_text) для извлечения данных из текста карточки +- [x] Postgres UPDATE (update_edited_transaction) для сохранения отредактированных данных + - counterparty, category, human_verified = TRUE +- [x] Postgres SELECT (row_select_by_id) для получения данных транзакции +- [x] Postgres UPDATE (update_after_confirm) для первого подтверждения + - human_verified = TRUE + - human_verified_at = timestamp +- [x] Merge node (merge_confirm_paths) для объединения двух путей подтверждения +- [x] Telegram Answer Callback Query (popup_confirm) для всплывающего уведомления +- [x] Telegram Edit Message Text (confirmation_message) для обновления сообщения + +**FALSE ветка (редактирование) - ЗАВЕРШЕНА:** + +- [x] IF node (which_field_to_edit) для определения редактируемого поля +- [x] Telegram Send Message (category_edit) для запроса новой категории + - С Reply To Message ID для связи с транзакцией + - Напоминание о лимите 12 символов +- [x] Telegram Send Message (counterparty_edit) для запроса нового контрагента + - С Reply To Message ID для связи с транзакцией + - Напоминание о лимите 12 символов +- [x] IF node (split_event_type) после единого триггера + - isEmpty(callback_query) = TRUE → текстовое сообщение → parse_text_input + - isEmpty(callback_query) = FALSE → callback от кнопки → parse_from_callback +- [x] Code node (parse_text_input) для парсинга ответа пользователя + - Извлечение transactionId из reply_to_message (#123) + - Определение changedField по ключевым словам + - Игнорирование команд (/) + - Output: newValue, chatId, transactionId, changedField +- [x] Postgres SELECT (get_current_transaction) для получения текущих данных +- [x] Code node (merge_changes) для применения изменений + - Merge новых и существующих данных + - Не пишет в БД (временное состояние) +- [x] Telegram Send Message (show_updated_card) для отображения обновлённой карточки + - Маркер "не сохранено" для определения источника + - Те же три кнопки для продолжения редактирования + - ЦИКЛ ЗАМЫКАЕТСЯ: можно редактировать оба поля многократно + +### 🔄 ОСТАЛОСЬ СДЕЛАТЬ + +#### 1. Интеграция с Google Sheets (следующий приоритет) + +- [ ] Создать Google Sheets таблицу для семейного бюджета +- [ ] Настроить Google Sheets API в n8n (Service Account) +- [ ] Добавить Google Sheets node ПОСЛЕ merge_confirm_paths + - Записывать только подтверждённые транзакции +- [ ] Настроить запись строки: Дата | Контрагент | Сумма | Категория +- [ ] Форматирование: цветовое кодирование расходов/доходов + +#### 2. Автоматизация запуска + +- [ ] Автозапуск LM Studio при загрузке системы +- [ ] Автозапуск Docker-контейнеров (restart policy = always) +- [ ] Health-check для мониторинга LM Studio API + +#### 3. Улучшения (опционально) + +- [ ] Кнопка "❌ Отклонить" для удаления ошибочных транзакций +- [ ] Редактирование подкатегории +- [ ] Валидация пользовательского ввода (длина, спецсимволы) +- [ ] Обработка исторических данных 2025 года для создания базы знаний +- [ ] Fine-tuning модели на собственных размеченных данных +- [ ] Аналитика и отчёты по категориям + +--- + +## ФИНАЛЬНАЯ АРХИТЕКТУРА WORKFLOW (05.01.2026) + +### Ветка 1: Обработка входящих SMS (ЗАВЕРШЕНА) + +```text +Webhook (POST) + → Auth check (x-api-key) + → Code (parse_sms) + → Postgres INSERT (create_from_sms) [Always Output Data = ON] + → Edit Fields (data minimization) + → HTTP Request (post_to_llm) + → Code (parse_from_llm) [choices[0].message.content, очистка markdown, merge] + → Postgres UPDATE (update_llm_fields) [human_verified=FALSE] + → Telegram Send Message (edit_or_confirm) [карточка с Inline Keyboard] +``` + +### Ветка 2: Единый Telegram Trigger (КРИТИЧНО) + +```text +answer_trigger (Telegram Trigger) + Updates: callback_query + message + Base URL: https://api.telegram.org + ↓ +split_event_type (IF: isEmpty(callback_query)) + ↓ TRUE (текстовое сообщение) + parse_text_input → get_current_transaction → merge_changes → show_updated_card + ↓ FALSE (callback от кнопки) + parse_from_callback → confirm_or_edit +``` + +**ВАЖНО**: Только ОДИН Telegram Trigger на весь workflow! Два триггера конфликтуют за один webhook URL. + +### Ветка 3: Подтверждение транзакции (ЗАВЕРШЕНА) + +```text +parse_from_callback [isEditedCard = messageText.includes("не сохранено")] + ↓ +confirm_or_edit (IF: action == "confirm") + ↓ TRUE (подтверждение) + check_if_edited (IF: isEditedCard == true) + ↓ TRUE (после редактирования) + parse_card_text → update_edited_transaction + ↓ FALSE (первое подтверждение) + row_select_by_id → update_after_confirm + ↓ + merge_confirm_paths (объединение двух путей) + ↓ + popup_confirm (Answer Callback Query) + ↓ + confirmation_message (Edit Message Text) +``` + +### Ветка 4: Редактирование транзакции с циклом (ЗАВЕРШЕНА) + +```text +parse_from_callback + ↓ +confirm_or_edit (IF: action == "confirm") + ↓ FALSE (редактирование) + which_field_to_edit (IF: field == "category") + ↓ TRUE + category_edit (Send Message: "Введите категорию #ID, лимит 12 символов") + ↓ FALSE + counterparty_edit (Send Message: "Введите контрагента #ID, лимит 12 символов") + ↓ +[Пользователь вводит текст] + ↓ +answer_trigger → split_event_type → parse_text_input + ↓ +get_current_transaction (SELECT текущих данных) + ↓ +merge_changes (применение изменений БЕЗ записи в БД) + ↓ +show_updated_card (карточка с маркером "не сохранено") + [Те же три кнопки: Подтвердить / Изменить категорию / Изменить контрагента] + ↓ +[ЦИКЛ ЗАМЫКАЕТСЯ: нажатие любой кнопки → answer_trigger] +``` + +**Ключевая особенность**: Изменения НЕ сохраняются в БД до финального подтверждения. Данные передаются через текст карточки (лимит 12 символов на поле для влезания в callback_data при необходимости). + +--- + +## ТЕХНИЧЕСКИЕ ДЕТАЛИ + +### База данных PostgreSQL + +**Connection**: + +- Host: Synology NAS IP +- Port: 5433 +- Database: budget +- User: postgres +- Credential в n8n: "postgres account" + +**Таблица transactions:** + +```sql +-- Базовые поля +id BIGSERIAL PRIMARY KEY +raw_sms TEXT NOT NULL +sms_text TEXT NOT NULL +sms_sender VARCHAR(50) +action VARCHAR(20) NOT NULL +amount NUMERIC(15, 2) +signed_amount NUMERIC(15, 2) +currency VARCHAR(3) DEFAULT 'RUB' +balance NUMERIC(15, 2) +received_at TIMESTAMP WITH TIME ZONE NOT NULL +processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +raw_json JSONB NOT NULL + +-- LLM и Human-in-the-loop поля +counterparty VARCHAR(255) +category VARCHAR(100) +subcategory VARCHAR(100) +llm_processed_at TIMESTAMP WITH TIME ZONE +human_verified BOOLEAN DEFAULT FALSE +human_verified_at TIMESTAMP WITH TIME ZONE + +-- Индексы +CREATE INDEX idx_transactions_received_at ON transactions(received_at DESC); +CREATE INDEX idx_transactions_action ON transactions(action); +CREATE INDEX idx_transactions_counterparty ON transactions(counterparty); +CREATE INDEX idx_transactions_category ON transactions(category); +CREATE INDEX idx_transactions_human_verified ON transactions(human_verified) WHERE human_verified = TRUE; +``` + +### Telegram Configuration + +**Telegram Credential (в n8n)**: + +- Access Token: токен от @BotFather +- Base URL: `https://api.telegram.org` (ВАЖНО!) + +**Почему именно этот Base URL**: + +- НЕ пустой (не polling mode) +- НЕ адрес n8n (не webhook mode через собственный сервер) +- Стандартный API Telegram - n8n использует гибридный режим получения событий + +**Проблема с двумя триггерами**: + +- Telegram позволяет только ОДИН webhook на бота +- Два Telegram Trigger в одном workflow конфликтуют +- Решение: единый триггер + IF для разделения типов событий + +### LLM Промпт (финальная версия) + +**System Message:** + +```text +Ты эксперт по категоризации расходов семейного бюджета в России. Анализируй банковские SMS и извлекай данные. + +ВАЖНО: +- Если контрагент НЕИЗВЕСТЕН или непонятен - укажи confidence < 0.5 +- Уверенность 1.0 ставь ТОЛЬКО для известных российских брендов +- Для неочевидных названий снижай confidence до 0.3-0.6 + +Известные сети: PYATEROCHKA, MAGNIT, ЗОЛОТОЕ ЯБЛОКО, WILDBERRIES, YANDEX TAXI + +Для неизвестных: category=Неизвестно, confidence < 0.5 + +Отвечай ТОЛЬКО валидным JSON без markdown обёртки. +``` + +**User Message:** + +```text +Проанализируй SMS: {{ $json.sms_text }} +Тип: {{ $json.action }} +Сумма: {{ $json.amount }} {{ $json.currency }} + +Если контрагент неизвестен - confidence < 0.5! + +Верни JSON: {"counterparty": "", "category": "", "subcategory": "", "confidence": 0.0} +``` + +--- + +## ФИНАЛЬНЫЕ CODE NODES (готовые к использованию) + +### 1. parse_from_llm (ИСПРАВЛЕНА ошибка choices.message) + +```javascript +// Исправлено: choices.message → choices[0].message +let content = $input.first().json.choices[0].message.content; + +// Очистка markdown обёртки +content = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + +const llmResponse = JSON.parse(content); +const originalData = $('create_from_sms').first().json; + +return { + ...originalData, + counterparty: llmResponse.counterparty || 'Не определён', + category: llmResponse.category || 'Неизвестно', + subcategory: llmResponse.subcategory || 'Требует уточнения', + confidence: llmResponse.confidence !== undefined ? llmResponse.confidence : 0.0 +}; +``` + +### 2. parse_from_callback (ОБНОВЛЕНА: добавлено isEditedCard) + +```javascript +const callbackData = $input.first().json.callback_query.data; +const chatId = $input.first().json.callback_query.message.chat.id; +const messageId = $input.first().json.callback_query.message.message_id; +const messageText = $input.first().json.callback_query.message.text; + +const parts = callbackData.split('_'); +const action = parts[0]; // confirm, edit +const field = parts.length === 3 ? parts[1] : null; // category, counterparty +const transactionId = parseInt(parts[parts.length - 1]); + +// Проверка: это подтверждение после редактирования? +const isEditedCard = messageText && messageText.includes('не сохранено'); + +return { + action: action, + field: field, + transactionId: transactionId, + chatId: chatId, + messageId: messageId, + messageText: messageText, + isEditedCard: isEditedCard, + originalCallbackData: callbackData +}; +``` + +### 3. parse_text_input (для обработки текстовых ответов) + +```javascript +const userText = $input.first().json.message.text; +const chatId = $input.first().json.message.chat.id; +const messageData = $input.first().json.message; + +// Игнорируем команды +if (userText.startsWith('/')) { + throw new Error('Команда проигнорирована'); +} + +// Проверяем что это ответ на наше сообщение +if (!messageData.reply_to_message) { + throw new Error('Сообщение не является ответом на запрос'); +} + +// Извлекаем ID транзакции из текста запроса +const originalText = messageData.reply_to_message.text; +const match = originalText.match(/#(\d+)/); + +if (!match) { + throw new Error('Не удалось найти ID транзакции'); +} + +const transactionId = parseInt(match[1]); + +// Определяем какое поле пользователь меняет +let changedField = null; +if (originalText.includes('категорию')) { + changedField = 'category'; +} else if (originalText.includes('контрагента')) { + changedField = 'counterparty'; +} + +return { + newValue: userText.trim(), + chatId: chatId, + transactionId: transactionId, + changedField: changedField +}; +``` + +### 4. merge_changes (применение изменений без записи в БД) + +```javascript +const textInput = $('parse_text_input').first().json; +const currentData = $('get_current_transaction').first().json; + +// Применяем изменение +const updatedCategory = textInput.changedField === 'category' + ? textInput.newValue + : currentData.category; + +const updatedCounterparty = textInput.changedField === 'counterparty' + ? textInput.newValue + : currentData.counterparty; + +return { + ...currentData, + category: updatedCategory, + counterparty: updatedCounterparty, + chatId: textInput.chatId +}; +``` + +### 5. parse_card_text (извлечение данных из текста карточки) + +```javascript +const messageText = $input.first().json.messageText; +const transactionId = $input.first().json.transactionId; + +// Извлекаем контрагента (между "Контрагент: " и "\n") +const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/); +const counterparty = counterpartyMatch ? counterpartyMatch[1].trim() : null; + +// Извлекаем категорию (между "Категория: " и "\n") +const categoryMatch = messageText.match(/Категория:\s*(.+?)(?:\n|$)/); +const category = categoryMatch ? categoryMatch[1].trim() : null; + +if (!counterparty || !category) { + throw new Error('Не удалось извлечь данные из карточки'); +} + +return { + transactionId: transactionId, + counterparty: counterparty, + category: category, + chatId: $input.first().json.chatId, + messageId: $input.first().json.messageId +}; +``` + +### 6. prepare_confirmation_data (подготовка данных для финальных нод) + +```javascript +const parseData = $('parse_card_text').first().json; + +return { + transactionId: parseData.transactionId, + chatId: parseData.chatId, + messageId: parseData.messageId, + counterparty: parseData.counterparty, + category: parseData.category +}; +``` + +--- + +## НАСТРОЙКИ ВСЕХ НОД + +### Telegram Nodes + +**answer_trigger (Telegram Trigger)**: + +- Credential: Telegram account +- Trigger On: callback_query +- Updates: `callback_query`, `message` (оба!) + +**edit_or_confirm (Telegram Send Message)**: + +- Chat ID: `{{ $json.chatId }}` +- Text: + +```text +Требуется подтверждение транзакции #{{ $json.id }} + +Сумма: {{ $json.signed_amount }} {{ $json.currency }} +Контрагент: {{ $json.counterparty }} +Категория: {{ $json.category }} +Дата: {{ $json.received_at }} +``` + +- Reply Markup: + +```json +{ + "inline_keyboard": [ + [{"text": "✅ Подтвердить", "callback_data": "confirm_{{ $json.id }}"}], + [{"text": "✏️ Изменить категорию", "callback_data": "edit_category_{{ $json.id }}"}], + [{"text": "✏️ Изменить контрагента", "callback_data": "edit_counterparty_{{ $json.id }}"}] + ] +} +``` + +**category_edit (Telegram Send Message)**: + +- Chat ID: `{{ $json.chatId }}` +- Text: `Введите новую категорию для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов` +- Reply To Message ID: `{{ $json.messageId }}` + +**counterparty_edit (Telegram Send Message)**: + +- Chat ID: `{{ $json.chatId }}` +- Text: `Введите нового контрагента для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов` +- Reply To Message ID: `{{ $json.messageId }}` + +**show_updated_card (Telegram Send Message)**: + +- Chat ID: `{{ $json.chatId }}` +- Text: + +```text +Данные обновлены (не сохранено) + +Сумма: {{ $json.signed_amount }} {{ $json.currency }} +Контрагент: {{ $json.counterparty }} +Категория: {{ $json.category }} +Дата: {{ $json.received_at }} + +ВАЖНО: Лимит 12 символов на поле +``` + +- Reply Markup: (те же три кнопки что и в edit_or_confirm) + +**popup_confirm (Telegram Answer Callback Query)**: + +- Query ID: `{{ $json.callback_query.id }}` +- Text: `✅ Транзакция подтверждена и сохранена` + +**confirmation_message (Telegram Edit Message Text)**: + +- Chat ID: `{{ $json.chatId }}` +- Message ID: `{{ $json.messageId }}` +- Text: `✅ Транзакция #{{ $json.transactionId }} подтверждена\n\nКонтрагент: {{ $json.counterparty }}\nКатегория: {{ $json.category }}` + +### PostgreSQL Nodes + +**create_from_sms (Postgres INSERT)**: + +- Operation: Insert +- Table: transactions +- Columns: все базовые поля из parse_sms +- Options: Always Output Data = ON + +**update_llm_fields (Postgres UPDATE)**: + +- Operation: Update +- Table: transactions +- Columns: counterparty, category, subcategory, llm_processed_at, human_verified=FALSE +- Where: id = {{ $json.id }} +- Options: Always Output Data = ON + +**get_current_transaction (Postgres SELECT)**: + +- Operation: Select +- Table: transactions +- Return All: OFF +- Where: id = {{ $json.transactionId }} +- Options: Always Output Data = ON + +**update_edited_transaction (Postgres UPDATE)**: + +- Operation: Update +- Table: transactions +- Columns: + - counterparty = {{ $json.counterparty }} + - category = {{ $json.category }} + - human_verified = true (toggle ON) +- Where: id = {{ $json.transactionId }} + +**row_select_by_id (Postgres SELECT)**: + +- Operation: Select +- Table: transactions +- Return All: OFF +- Where: id = {{ $json.transactionId }} + +**update_after_confirm (Postgres UPDATE)**: + +- Operation: Update +- Table: transactions +- Columns: + - human_verified = true + - human_verified_at = {{ $now }} +- Where: id = {{ $json.transactionId }} + +### IF Nodes + +**split_event_type (разделение типов событий)**: + +- Condition: `{{ $json.callback_query }}` Is Empty +- TRUE → текстовое сообщение → parse_text_input +- FALSE → callback от кнопки → parse_from_callback + +**confirm_or_edit (разделение подтверждения/редактирования)**: + +- Condition: `{{ $json.action }}` Equal `confirm` +- TRUE → подтверждение +- FALSE → редактирование + +**check_if_edited (определение источника подтверждения)**: + +- Condition: `{{ $json.isEditedCard }}` Equal `true` +- TRUE → после редактирования → parse_card_text +- FALSE → первое подтверждение → row_select_by_id + +**which_field_to_edit (определение редактируемого поля)**: + +- Condition: `{{ $json.field }}` Equal `category` +- TRUE → category_edit +- FALSE → counterparty_edit + +### Merge Node + +**merge_confirm_paths (объединение путей подтверждения)**: + +- Type: Merge +- Mode: Append +- Input 1: update_after_confirm (первое подтверждение) +- Input 2: prepare_confirmation_data (после редактирования) +- Output: popup_confirm + +--- + +## CHANGELOG + +### 2026-01-05 - Human-in-the-loop ПОЛНОСТЬЮ ЗАВЕРШЁН ✅ + +**Критические исправления:** + +- Исправлена ошибка в parse_from_llm: `choices.message` → `choices[0].message.content` +- Решена проблема конфликта двух Telegram триггеров за один webhook +- Удалён edit_trigger, всё через единый answer_trigger +- Добавлен split_event_type IF для разделения callback_query и message +- Исправлена ошибка 403 Forbidden при регистрации webhook +- Telegram Credential Base URL установлен в + +**Реализован полный цикл редактирования:** + +- parse_text_input: парсинг текстовых ответов пользователя +- get_current_transaction: SELECT текущих данных +- merge_changes: применение изменений БЕЗ записи в БД +- show_updated_card: обновлённая карточка с маркером "не сохранено" +- ЦИКЛ: можно редактировать category и counterparty многократно + +**Реализовано двойное подтверждение:** + +- check_if_edited IF: определение источника подтверждения +- parse_card_text: извлечение данных из текста карточки +- update_edited_transaction: UPDATE после редактирования +- prepare_confirmation_data: подготовка данных +- merge_confirm_paths: объединение двух путей подтверждения + +**Технические решения:** + +- Лимит 12 символов на поле (для влезания в callback_data) +- Маркер "не сохранено" в тексте карточки для определения типа +- Данные передаются через текст сообщения, не через БД +- Единый триггер + IF вместо двух конфликтующих триггеров + +### 2026-01-02 - Human-in-the-loop через Telegram (90% завершено) + +- Изменена архитектура: все транзакции через человека +- LLM-данные сохраняются сразу (human_verified=false) +- Создан Telegram-бот, настроены credentials +- TRUE ветка (подтверждение) завершена +- FALSE ветка (редактирование) на 90% +- Парсинг markdown обёртки LLM +- Reply To Message для связи с транзакцией + +### 2026-01-01 - LM Studio интегрирован + +- Установлен LM Studio, загружена Qwen2.5-7B-Instruct +- Настроены параметры модели +- Решена проблема подключения n8n Docker к LM Studio +- Создан HTTP Request узел для LLM API +- Разработан промпт для категоризации + +### 2025-12-XX - Базовая инфраструктура + +- Развернут PostgreSQL 16 в Docker +- Создана таблица transactions +- Настроен n8n workflow: Webhook → Auth → Code → PostgreSQL +- Реализован парсинг SMS + +--- + +## СЛЕДУЮЩИЕ ШАГИ + +### Приоритет 1: Интеграция с Google Sheets + +1. Создать Google Sheets таблицу для семейного бюджета +2. Настроить Google Sheets API в n8n (Service Account) +3. Добавить Google Sheets node после merge_confirm_paths +4. Записывать только подтверждённые транзакции +5. Настроить колонки: Дата | Контрагент | Сумма | Категория | Подкатегория +6. Форматирование: цветовое кодирование расходов/доходов + +### Приоритет 2: Автоматизация запуска + +1. Автозапуск LM Studio при загрузке системы (systemd service) +2. Docker restart policy = always для всех контейнеров +3. Health-check для мониторинга LM Studio API +4. Уведомления в Telegram при падении сервисов + +### Приоритет 3: Улучшения (опционально) + +1. Кнопка "❌ Отклонить" для удаления ошибочных транзакций +2. Редактирование подкатегории +3. Валидация пользовательского ввода (длина, спецсимволы) +4. Обработка исторических данных 2025 года +5. Fine-tuning модели на собственных данных +6. Аналитика и отчёты по категориям расходов +7. Экспорт данных в различные форматы + +--- + +**Версия документа**: 3.0 +**Дата последнего обновления**: 2026-01-05 +**Статус**: Основной функционал ЗАВЕРШЁН (100%), готов к продакшн-использованию +**Следующий шаг**: Интеграция с Google Sheets +**Автор обновления**: AI Assistant (Perplexity) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..3fbd796 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/improvement.md b/improvement.md new file mode 100644 index 0000000..f813a8a --- /dev/null +++ b/improvement.md @@ -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 и производительности. diff --git a/index.html b/index.html new file mode 100644 index 0000000..aaf63c0 --- /dev/null +++ b/index.html @@ -0,0 +1,126 @@ + + + + + + + + Document + + +

+ Цель: формирование, отбор и поддержка проектов, + ориентированных на прикладное использование результатов научных + исследований, разработок и учебно-методических заделов МИЭМ. +

+

Ключевые положения:

+
    +
  1. На конкурс могут предлагаться проекты трех типов:
  2. +
      +
    • Ориентированные фундаментальные
    • +
    • Прикладные исследования и разработки
    • +
    • Учебно-методические разработки
    • +
    + +
  3. + Объем финансирования проекта может составлять от 500 тыс. руб. + до 3 млн. руб. на срок до 1 года. +
  4. + +
  5. + К экспертизе каждого из проектов привлекаются не менее 2 + профильных экспертов, их оценка наряду с оценкой членов Научной + комиссии учитывается в итоговом рейтинге проектов. +
  6. +
  7. + Все проекты проходят процедуру публичной защиты проектов, как на + этапе конкурса, так и на этапе отчета о результатах проделанной + работы. +
  8. +
  9. + Результатом должен являться отчет о НИР, отчет о патентных + исследованиях (для проектов 1 и 2 типа), а также те показатели, + которые заявители установили на этапе подачи. +
  10. +
+ +

Требования к участникам:

+
    +
  • + - коллектив должен состоять из 5 и более участников (без + ограничения по максимальному количеству участников, работающих в + ней на постоянной основе); +
  • +
  • + - руководитель - работник МИЭМ НИУ ВШЭ, занимающий должность + работника НИУ ВШЭ по основному месту работы; +
  • +
  • + - участник может быть руководителем только одной проектной группы, + при этом не ограничивается количество научных групп, где этот + участник может быть исполнителем; +
  • +
  • + - проектные группы должны иметь смешанный состав, включая + работников и обучающихся; +
  • +
  • + - привлечение внешних исполнителей возможно с обязательством, при + необходимости, последующего найма на условиях полной занятости + или совместительства в структурное подразделение МИЭМ НИУ ВШЭ. +
  • +
+

+ Предлагаемый проект должен быть оригинальным и не дублировать + проекты, поддерживаемые в рамках действующих централизованных + программ НИУ ВШЭ или за счет внешнего финансирования. +

+

+ Конкурсный отбор, реализация и приемка результатов проектов состоят + из шести этапов: +

+
    +
  • 1 этап: прием заявок;
  • +
  • 2 этап: экспертиза по формальным признакам;
  • +
  • 3 этап: экспертиза заявок по существу;
  • +
  • 4 этап: защита проектов, подведение итогов Конкурса;
  • +
  • 5 этап: реализация проектов;
  • +
  • 6 этап: защита результатов проектов.
  • +
+

Финансирование:

+
    +
  • + Объем финансирования проектной группы составляет от 500 000 до 3 + 000 000 рублей. +
  • +
  • + Доля оплаты труда руководителя не может превышать 25 % фонда + оплаты труда участников проектной группы. +
  • +
  • + Доля оплаты труда обучающихся не может быть ниже 25 % фонда + оплаты труда участников проектной группы. +
  • +
  • + Сбор конкурсных заявок проводится в МИЭМ НИУ ВШЭ ежегодно с + возможностью выделения нескольких туров. +
  • +
  • + Сроки проведения и количество туров Конкурса устанавливаются + приказом директора МИЭМ НИУ ВШЭ. +
  • +
+

Сроки реализации проектов 2026 г.: 01.03 - 31.11.2026 г.

+

+ Заявки принимаются до 22.01.2026 г. по + ссылке +

+ + diff --git a/index.js b/index.js new file mode 100644 index 0000000..a0531b8 --- /dev/null +++ b/index.js @@ -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); diff --git a/style.css b/style.css new file mode 100644 index 0000000..8999572 --- /dev/null +++ b/style.css @@ -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); +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..2872713 --- /dev/null +++ b/test.js @@ -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));