initial commit
This commit is contained in:
4
.env
Normal file
4
.env
Normal file
@@ -0,0 +1,4 @@
|
||||
POSTGRES_DB=budget
|
||||
POSTGRES_USER=budget_user
|
||||
POSTGRES_PASSWORD=difficult_P@
|
||||
PGADMIN_DEFAULT_PASSWORD=easy_P@
|
||||
369
backlog_buget.md
Normal file
369
backlog_buget.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# SMS → n8n → LLM → PostgreSQL → Telegram → Google Sheets
|
||||
|
||||
Текущая архитектура workflow (11.01.2026)
|
||||
|
||||
## 1. Общий контур решения
|
||||
|
||||
Источник данных — банковские SMS, которые попадают в n8n через Webhook.
|
||||
Дальше SMS:
|
||||
|
||||
1. парсятся в нормализованный JSON с определением типа операции;
|
||||
2. сохраняются в PostgreSQL (таблица `transactions`);
|
||||
3. обогащаются локальной LLM (Qwen2.5 через LM Studio);
|
||||
4. отправляются в Telegram-бота в виде карточки с кнопками;
|
||||
5. проходят через human-in-the-loop: пользователь подтверждает или редактирует контрагента и категорию;
|
||||
6. после финального подтверждения данные фиксируются в БД и отправляются в Google Sheets для отчётности.
|
||||
|
||||
Проект развернут на Synology NAS: PostgreSQL. n8n в Docker и LM Studio — на локальной машине.
|
||||
|
||||
***
|
||||
|
||||
## 2. База данных
|
||||
|
||||
### 2.1. Таблица `transactions`
|
||||
|
||||
```sql
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
raw_sms TEXT NOT NULL,
|
||||
sms_text TEXT NOT NULL,
|
||||
sms_sender VARCHAR(50),
|
||||
action VARCHAR(20) NOT NULL,
|
||||
amount NUMERIC(15, 2),
|
||||
signed_amount NUMERIC(15, 2),
|
||||
currency VARCHAR(3) DEFAULT 'RUB',
|
||||
balance NUMERIC(15, 2),
|
||||
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
raw_json JSONB NOT NULL,
|
||||
chat_id BIGINT,
|
||||
|
||||
-- LLM / Human-in-the-loop
|
||||
counterparty VARCHAR(255),
|
||||
category VARCHAR(100),
|
||||
llm_processed_at TIMESTAMP WITH TIME ZONE,
|
||||
human_verified BOOLEAN DEFAULT FALSE,
|
||||
human_verified_at TIMESTAMP WITH TIME ZONE
|
||||
```
|
||||
|
||||
**Индексы:**
|
||||
|
||||
- По дате, действию, контрагенту, категории и флагу `human_verified`
|
||||
- `idx_chat_categories` на `(chat_id, category, received_at DESC)` для персонализации
|
||||
|
||||
### 2.2. Таблица `user_sessions`
|
||||
|
||||
Используется для хранения состояния редактирования в Telegram (какую транзакцию и какое поле сейчас меняет пользователь).
|
||||
|
||||
```sql
|
||||
"chatId" BIGINT PRIMARY KEY,
|
||||
"transactionId" BIGINT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
"waitingFor" VARCHAR(20) NOT NULL,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
"tempCounterparty" VARCHAR(255),
|
||||
"tempCategory" VARCHAR(100)
|
||||
```
|
||||
|
||||
### 2.3. Таблица `user_category_stats`
|
||||
|
||||
Кэш популярных категорий пользователей для персонализации.
|
||||
|
||||
```sql
|
||||
chat_id BIGINT,
|
||||
category VARCHAR(100),
|
||||
count INTEGER DEFAULT 1,
|
||||
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chat_id, category)
|
||||
```
|
||||
|
||||
**Индекс:** `idx_user_cats` на `(chat_id, count DESC)`
|
||||
|
||||
***
|
||||
|
||||
## 3. Ветка 1. Обработка входящих SMS (Webhook → LLM → БД → Telegram)
|
||||
|
||||
### 3.1. Webhook (Node: `Webhook`)
|
||||
|
||||
- Method: `POST`
|
||||
- Принимает JSON с полями SMS от прокси/смс-шлюза
|
||||
- Входные данные: `raw_sms`, `sms_text`, `sms_sender`, дата и пр.
|
||||
|
||||
### 3.2. Code: `parse_from_sms`
|
||||
|
||||
Преобразует входящий JSON в нормализованный объект.
|
||||
|
||||
**Типы операций (action):**
|
||||
|
||||
```javascript
|
||||
const rules = [
|
||||
{ action: 'salary', re: /зарплат/i },
|
||||
{ action: 'reversal', re: /(отмена|возврат)/i },
|
||||
{ action: 'payment', re: /(оплата|покупк)/i },
|
||||
{ action: 'transfer', re: /перевод/i },
|
||||
{ action: 'writeoff', re: /списан/i },
|
||||
{ action: 'income', re: /(поступлен|поступление|зачислен)/i },
|
||||
{ action: 'withdrawal', re: /снятие/i },
|
||||
{ action: 'deposit', re: /внесение/i }
|
||||
];
|
||||
```
|
||||
|
||||
**Логика знаков:**
|
||||
|
||||
```javascript
|
||||
// Проверка валидности суммы
|
||||
if (amount === null || amount === 0) {
|
||||
throw new Error('Не удалось распарсить сумму из SMS');
|
||||
}
|
||||
|
||||
let signed_amount = amount;
|
||||
|
||||
// Расходные операции (минус)
|
||||
if (['payment', 'transfer', 'writeoff', 'withdrawal'].includes(action)) {
|
||||
signed_amount = -Math.abs(amount);
|
||||
}
|
||||
|
||||
// Приходные операции (плюс)
|
||||
if (['salary', 'income', 'deposit', 'reversal'].includes(action)) {
|
||||
signed_amount = Math.abs(amount);
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:** объект с полями `action`, `amount`, `signed_amount`, `currency`, `balance`, `received_at`, `raw_sms`, `sms_text`, `sms_sender`, `raw_json`.
|
||||
|
||||
### 3.3. PostgreSQL: `create_from_sms` (INSERT)
|
||||
|
||||
- Operation: **Insert**
|
||||
- Table: `transactions`
|
||||
- Поля: все базовые из `parse_from_sms` (без LLM-полей)
|
||||
- Options: **Always Output Data = ON**
|
||||
|
||||
### 3.4. Set: `Edit Fields`
|
||||
|
||||
Урезает объект перед LLM (data minimization): `sms_text`, `action`, `amount`, `currency`, `balance`.
|
||||
|
||||
### 3.5. HTTP Request: `post_to_llm`
|
||||
|
||||
- Метод: POST
|
||||
- URL: `http://<LM_STUDIO_IP>:1234/v1/chat/completions`
|
||||
- Модель: Qwen2.5-7B-Instruct
|
||||
|
||||
**System prompt (фрагмент):**
|
||||
|
||||
```text
|
||||
ДОПУСТИМЫЕ КАТЕГОРИИ:
|
||||
"Продукты", "Авто", "Здоровье", "Арчи", "ЖКХ", "Дом", "Проезд", "Одежда",
|
||||
"Химия", "Косметика", "Инвестиции", "Развлечения", "Общепит", "Штрафы",
|
||||
"Налоги", "Подписки", "Перевод", "Наличные", "Подарки", "Спорт", "Поступления"
|
||||
|
||||
КАТЕГОРИЗАЦИЯ ПО ТИПУ ОПЕРАЦИИ:
|
||||
- Для снятия наличных категория ВСЕГДА "Наличные".
|
||||
- Для внесения наличных категория ВСЕГДА "Поступления".
|
||||
- Для зарплаты или поступлений категория ВСЕГДА "Поступления".
|
||||
- Для возвратов/отмен категория ВСЕГДА "Поступления".
|
||||
- Для переводов определяй категорию по контрагенту.
|
||||
```
|
||||
|
||||
**Параметры:** `temperature: 0.2`, `max_tokens: 256`
|
||||
|
||||
### 3.6. Code: `parse_from_llm`
|
||||
|
||||
```javascript
|
||||
let content = $input.first().json.choices.message.content;
|
||||
content = content.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
|
||||
const llmResponse = JSON.parse(content);
|
||||
const originalData = $('create_from_sms').first().json;
|
||||
|
||||
return {
|
||||
...originalData,
|
||||
counterparty: llmResponse.counterparty || originalData.counterparty || 'Продукты',
|
||||
category: llmResponse.category || originalData.category || 'Продукты'
|
||||
};
|
||||
```
|
||||
|
||||
### 3.7. PostgreSQL: `update_llm_fields` (UPDATE)
|
||||
|
||||
- Table: `transactions`
|
||||
- Where: `id = {{ $json.id }}`
|
||||
- Columns: `counterparty`, `category`, `llm_processed_at = NOW()`, `human_verified = FALSE`
|
||||
- Options: **Always Output Data = ON**
|
||||
|
||||
### 3.8. Telegram Send Message: `edit_or_confirm`
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text: карточка транзакции с суммой, контрагентом, категорией, датой
|
||||
- Inline Keyboard:
|
||||
- `✅ Подтвердить` → `confirm_{{ $json.id }}`
|
||||
- `✏️ Изменить категорию` → `edit_category_{{ $json.id }}`
|
||||
- `✏️ Изменить контрагента` → `edit_counterparty_{{ $json.id }}`
|
||||
|
||||
***
|
||||
|
||||
## 4. Ветка 2. Telegram Trigger и разветвление
|
||||
|
||||
### 4.1. Telegram Trigger: `answer_trigger`
|
||||
|
||||
- Updates: `callback_query`, `message`
|
||||
- Все события (кнопки и текст) приходят в один триггер
|
||||
|
||||
### 4.2. IF: `split_event_type`
|
||||
|
||||
- Condition: `{{ $json.callback_query }}` Is Empty
|
||||
- TRUE → текстовое сообщение → ветка редактирования
|
||||
- FALSE → callback от кнопки → ветка подтверждения/редактирования
|
||||
|
||||
***
|
||||
|
||||
## 5. Ветка 3. Подтверждение транзакции
|
||||
|
||||
### 5.1. Code: `parse_from_callback`
|
||||
|
||||
Извлекает из callback: `action`, `field`, `transactionId`, `chatId`, `messageId`, `isEditedCard`, `counterparty`, `category`.
|
||||
|
||||
### 5.2. IF: `confirm_or_edit`
|
||||
|
||||
- TRUE → подтверждение
|
||||
- FALSE → редактирование
|
||||
|
||||
### 5.3. IF: `check_if_edited`
|
||||
|
||||
- TRUE → подтверждение после редактирования
|
||||
- FALSE → первое подтверждение
|
||||
|
||||
#### Первое подтверждение
|
||||
|
||||
- PostgreSQL SELECT: `row_select_by_id`
|
||||
- PostgreSQL UPDATE: `update_after_confirm`
|
||||
- Columns: `human_verified = true`, `human_verified_at = {{ $now }}`, `chat_id = {{ $('answer_trigger').first().json.callback_query.message.chat.id }}`
|
||||
|
||||
#### После редактирования
|
||||
|
||||
- Code: `parse_card_text`
|
||||
- PostgreSQL UPDATE: `update_edited_transaction`
|
||||
- Columns: `counterparty`, `category`, `human_verified = true`, `chat_id = {{ $('answer_trigger').first().json.callback_query.message.chat.id }}`
|
||||
- Code: `prepare_confirmation_data`
|
||||
|
||||
```javascript
|
||||
const messageData = $('parse_card_text').first().json;
|
||||
const parseData = $input.first().json;
|
||||
|
||||
return {
|
||||
transactionId: parseData.transactionId,
|
||||
chatId: messageData.chatId,
|
||||
messageId: messageData.messageId,
|
||||
counterparty: parseData.counterparty,
|
||||
category: parseData.category,
|
||||
raw_json: {
|
||||
action: parseData.raw_json.action,
|
||||
signed_amount: parseData.raw_json.signed_amount,
|
||||
currency: parseData.raw_json.currency,
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5.4. Merge: `merge_confirm_paths`
|
||||
|
||||
- Mode: **Append**
|
||||
- Объединяет обе ветки подтверждения
|
||||
|
||||
### 5.5. Telegram Answer Callback Query: `popup_confirm`
|
||||
|
||||
### 5.6. Telegram Send Message: `confirmation_message`
|
||||
|
||||
***
|
||||
|
||||
## 6. Ветка 4. Редактирование транзакции
|
||||
|
||||
### 6.1. PostgreSQL Execute Query: `empty_session`
|
||||
|
||||
Удаляет старую сессию и пробрасывает данные.
|
||||
|
||||
### 6.2. IF: `which_field_to_edit`
|
||||
|
||||
- TRUE → категория
|
||||
- FALSE → контрагент
|
||||
|
||||
### 6.3. PostgreSQL INSERT: `add_session_category` / `add_session_counterparty`
|
||||
|
||||
Создает сессию в `user_sessions`.
|
||||
|
||||
### 6.4. Telegram Send Message: `category_edit` / `counterparty_edit`
|
||||
|
||||
Запрашивает ввод нового значения.
|
||||
|
||||
***
|
||||
|
||||
## 7. Ветка 5. Обработка текстового ответа
|
||||
|
||||
### 7.1. PostgreSQL SELECT: `get_session`
|
||||
|
||||
### 7.2. Code: `merge_user_and_session`
|
||||
|
||||
### 7.3. Code: `parse_text_input`
|
||||
|
||||
### 7.4. PostgreSQL SELECT: `get_current_transaction`
|
||||
|
||||
### 7.5. Code: `merge_changes`
|
||||
|
||||
### 7.6. Telegram Send Message: `show_updated_card`
|
||||
|
||||
Отображает обновленную карточку с надписью "не сохранено".
|
||||
|
||||
***
|
||||
|
||||
## 8. Интеграция с Google Sheets
|
||||
|
||||
### 8.1. Структура таблицы
|
||||
|
||||
Колонки: `дата`, `действие`, `контрагент`, `сумма`, `категория`, `примечание`
|
||||
|
||||
### 8.2. Узел Google Sheets (после `merge_confirm_paths`)
|
||||
|
||||
- Operation: **Append Row**
|
||||
- Credentials: Google Service Account
|
||||
|
||||
**Mapping:**
|
||||
|
||||
```javascript
|
||||
дата: {{ $json.received_at }}
|
||||
действие: {{ $json.raw_json.action === 'salary' ? 'Зарплата' : $json.raw_json.action === 'reversal' ? 'Возврат' : $json.raw_json.action === 'payment' ? 'Оплата' : $json.raw_json.action === 'transfer' ? 'Перевод' : $json.raw_json.action === 'writeoff' ? 'Списание' : $json.raw_json.action === 'income' ? 'Поступление' : $json.raw_json.action === 'withdrawal' ? 'Снятие' : $json.raw_json.action === 'deposit' ? 'Внесение' : $json.raw_json.action }}
|
||||
контрагент: {{ $json.counterparty }}
|
||||
сумма: {{ String($json.raw_json.signed_amount).replace('.', ',') }}
|
||||
категория: {{ $json.category }}
|
||||
примечание:
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## 9. Планируемые доработки
|
||||
|
||||
### 9.1. Архитектурные улучшения (Приоритет 1)
|
||||
|
||||
- [ ] **Переход на stateless редактирование:** Хранить состояние в `callback_data` кнопок вместо таблицы `user_sessions`, убрать 7 узлов workflow, снизить нагрузку на БД на 70%
|
||||
- [ ] **Inline-клавиатура для категорий:** Заменить текстовый ввод на кнопки с персонализированными популярными категориями (топ-3 за 30 дней) + полный список
|
||||
- [ ] **Кэширование популярных категорий:** Использовать таблицу `user_category_stats` для быстрого получения часто используемых категорий
|
||||
- [ ] **Fallback для длинных callback_data:** MD5-хэш + временное хранилище для значений >64 байт
|
||||
|
||||
### 9.2. LLM и категоризация (Приоритет 2)
|
||||
|
||||
- [ ] Собрать статистику по качеству автокатегоризации
|
||||
- [ ] Fine-tuning модели LLM на собственных размеченных данных
|
||||
- [ ] Скорректировать описания категорий/словарь на основе статистики
|
||||
|
||||
### 9.3. Автоматизация запуска (Приоритет 3)
|
||||
|
||||
- [ ] Автозапуск LM Studio при старте системы (service/systemd)
|
||||
- [ ] Docker `restart: always` для n8n и PostgreSQL
|
||||
- [ ] Health-check LM Studio API и уведомления в Telegram при падении
|
||||
|
||||
### 9.4. Улучшения UX (Приоритет 4)
|
||||
|
||||
- [ ] Кнопка «❌ Отклонить» для удаления/игнорирования ошибочных транзакций
|
||||
- [ ] Ограничения и валидация пользовательского ввода (длина, спецсимволы)
|
||||
- [ ] Периодическая очистка старых записей в `user_sessions` (>1 часа)
|
||||
- [ ] Добавление исходного текста SMS в карточку
|
||||
- [ ] Аналитика по категориям, отчёты и экспорт (CSV/Excel/BI)
|
||||
|
||||
***
|
||||
|
||||
**Версия документа:** 5.0
|
||||
**Дата:** 2026-01-11
|
||||
**Статус:** Добавлены типы операций (withdrawal/deposit), логика знаков, категория "Поступления", поле `chat_id`, экспорт в Google Sheets с колонкой "действие". Следующий этап — переход на stateless редактирование и inline-клавиатуры для категорий.
|
||||
564
backlog_buget_260106.md
Normal file
564
backlog_buget_260106.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# SMS → n8n → LLM → PostgreSQL → Telegram
|
||||
|
||||
Текущая архитектура workflow и статус проекта (05.01.2026)
|
||||
|
||||
## 1. Общий контур решения
|
||||
|
||||
Источник данных — банковские SMS, которые попадают в n8n через Webhook.
|
||||
Дальше SMS:
|
||||
|
||||
1. парсятся в нормализованный JSON;
|
||||
2. сохраняются в PostgreSQL (таблица `transactions`);
|
||||
3. обогащаются локальной LLM (Qwen2.5 через LM Studio);
|
||||
4. отправляются в Telegram‑бота в виде карточки с кнопками;
|
||||
5. проходят через 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://<LM_STUDIO_IP>: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
|
||||
**Статус:** Основной функционал реализован, финальная отладка цикла редактирования/подтверждения в процессе; интеграция с внешними отчётами и автоматизация запуска в планах.
|
||||
538
backlog_buget_260107.md
Normal file
538
backlog_buget_260107.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# SMS → n8n → LLM → PostgreSQL → Telegram
|
||||
|
||||
Текущая архитектура workflow (07.01.2026)
|
||||
|
||||
## 1. Общий контур решения
|
||||
|
||||
Источник данных — банковские SMS, которые попадают в n8n через Webhook.
|
||||
Дальше SMS:
|
||||
|
||||
1. парсятся в нормализованный JSON;
|
||||
2. сохраняются в PostgreSQL (таблица `transactions`);
|
||||
3. обогащаются локальной LLM (Qwen2.5 через LM Studio);
|
||||
4. отправляются в Telegram‑бота в виде карточки с кнопками;
|
||||
5. проходят через 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://<LM_STUDIO_IP>: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]
|
||||
618
backlog_buget_260111.md
Normal file
618
backlog_buget_260111.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# SMS → n8n → LLM → PostgreSQL → Telegram
|
||||
|
||||
Текущая архитектура workflow (09.01.2026)
|
||||
|
||||
## 1. Общий контур решения
|
||||
|
||||
Источник данных — банковские SMS, которые попадают в n8n через Webhook.
|
||||
Дальше SMS:
|
||||
|
||||
1. парсятся в нормализованный JSON;
|
||||
2. сохраняются в PostgreSQL (таблица `transactions`);
|
||||
3. обогащаются локальной LLM (Qwen2.5 через LM Studio);
|
||||
4. отправляются в Telegram‑бота в виде карточки с кнопками;
|
||||
5. проходят через 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://<LM_STUDIO_IP>: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.
|
||||
735
backlog_buget_old.md
Normal file
735
backlog_buget_old.md
Normal file
@@ -0,0 +1,735 @@
|
||||
# SMS → n8n → LLM → БД/Telegram: принятые решения и контракты данных
|
||||
|
||||
Контекст: автоматизация "Семейный бюджет". Источник данных — банковские SMS, которые пересылаются в n8n вебхуком, далее парсятся и обогащаются (LLM), затем пишутся в БД и/или отправляются уведомления в Telegram.
|
||||
|
||||
---
|
||||
|
||||
## СТАТУС ПРОЕКТА
|
||||
|
||||
### ✅ ВЫПОЛНЕНО (100% основного функционала)
|
||||
|
||||
#### Инфраструктура
|
||||
|
||||
- [x] PostgreSQL 16 развернут в Docker на Synology NAS (порт 5433)
|
||||
- [x] pgAdmin настроен (порт 5050)
|
||||
- [x] Таблица `transactions` создана и расширена полями для LLM и human-in-the-loop
|
||||
- [x] n8n запущен в Docker-контейнере
|
||||
- [x] Telegram-бот создан через @BotFather
|
||||
- [x] Telegram credentials настроены в n8n (Base URL: <https://api.telegram.org>)
|
||||
|
||||
#### n8n Workflow - Основная ветка обработки SMS
|
||||
|
||||
- [x] Webhook (POST) настроен для приёма SMS
|
||||
- [x] Auth check (проверка x-api-key заголовка)
|
||||
- [x] Code node (parse_sms) для парсинга SMS → NormalizedTransaction JSON
|
||||
- [x] PostgreSQL INSERT (create_from_sms) для записи базовых данных транзакции
|
||||
- Always Output Data = ON для получения id
|
||||
- [x] Edit Fields (Set) для data minimization перед LLM
|
||||
- [x] HTTP Request (post_to_llm) для обращения к LM Studio API
|
||||
- [x] Code node (parse_from_llm) для парсинга JSON-ответа от LLM
|
||||
- Очистка markdown обёртки (```json)
|
||||
- Merge с originalData из create_from_sms
|
||||
- Исправлена ошибка: choices.message → choices[0].message
|
||||
- [x] PostgreSQL UPDATE (update_llm_fields) для записи обогащённых данных
|
||||
- counterparty, category, subcategory
|
||||
- llm_processed_at
|
||||
- human_verified = FALSE (пока не подтверждено)
|
||||
- [x] Telegram Send Message (edit_or_confirm) для отправки карточки транзакции с кнопками
|
||||
- Inline Keyboard: Подтвердить / Изменить категорию / Изменить контрагента
|
||||
|
||||
#### LLM - Локальная модель
|
||||
|
||||
- [x] LM Studio установлен и настроен
|
||||
- [x] Модель Qwen2.5-7B-Instruct (Q6_K, 6 ГБ) загружена
|
||||
- [x] API-сервер запущен (порт 1234, Serve on Local Network включен)
|
||||
- [x] Параметры модели оптимизированы (Context: 8192, Temperature: 0.2, Max Tokens: 512)
|
||||
- [x] Промпт улучшен для корректной работы с неизвестными контрагентами
|
||||
- [x] Обработка markdown-обёртки в ответах (```json)
|
||||
|
||||
#### Human-in-the-loop через Telegram - ПОЛНОСТЬЮ ЗАВЕРШЕНО ✅
|
||||
|
||||
**Единый Telegram Trigger (решена проблема конфликта webhook):**
|
||||
|
||||
- [x] Один answer_trigger для ВСЕХ событий (callback_query + message)
|
||||
- [x] Решена проблема 403 Forbidden при регистрации webhook
|
||||
- [x] Telegram Credential настроен с Base URL = <https://api.telegram.org>
|
||||
- [x] IF node (split_event_type) для разделения типов событий
|
||||
|
||||
**TRUE ветка (подтверждение) - ЗАВЕРШЕНА:**
|
||||
|
||||
- [x] Code node (parse_from_callback) для парсинга callback_data
|
||||
- Извлечение: action, field, transactionId, chatId, messageId, messageText
|
||||
- Определение isEditedCard (проверка текста "не сохранено")
|
||||
- [x] IF node (confirm_or_edit) для разделения подтверждения/редактирования
|
||||
- [x] IF node (check_if_edited) для определения источника подтверждения
|
||||
- TRUE: подтверждение после редактирования → parse_card_text
|
||||
- FALSE: первое подтверждение без изменений → row_select_by_id
|
||||
- [x] Code node (parse_card_text) для извлечения данных из текста карточки
|
||||
- [x] Postgres UPDATE (update_edited_transaction) для сохранения отредактированных данных
|
||||
- counterparty, category, human_verified = TRUE
|
||||
- [x] Postgres SELECT (row_select_by_id) для получения данных транзакции
|
||||
- [x] Postgres UPDATE (update_after_confirm) для первого подтверждения
|
||||
- human_verified = TRUE
|
||||
- human_verified_at = timestamp
|
||||
- [x] Merge node (merge_confirm_paths) для объединения двух путей подтверждения
|
||||
- [x] Telegram Answer Callback Query (popup_confirm) для всплывающего уведомления
|
||||
- [x] Telegram Edit Message Text (confirmation_message) для обновления сообщения
|
||||
|
||||
**FALSE ветка (редактирование) - ЗАВЕРШЕНА:**
|
||||
|
||||
- [x] IF node (which_field_to_edit) для определения редактируемого поля
|
||||
- [x] Telegram Send Message (category_edit) для запроса новой категории
|
||||
- С Reply To Message ID для связи с транзакцией
|
||||
- Напоминание о лимите 12 символов
|
||||
- [x] Telegram Send Message (counterparty_edit) для запроса нового контрагента
|
||||
- С Reply To Message ID для связи с транзакцией
|
||||
- Напоминание о лимите 12 символов
|
||||
- [x] IF node (split_event_type) после единого триггера
|
||||
- isEmpty(callback_query) = TRUE → текстовое сообщение → parse_text_input
|
||||
- isEmpty(callback_query) = FALSE → callback от кнопки → parse_from_callback
|
||||
- [x] Code node (parse_text_input) для парсинга ответа пользователя
|
||||
- Извлечение transactionId из reply_to_message (#123)
|
||||
- Определение changedField по ключевым словам
|
||||
- Игнорирование команд (/)
|
||||
- Output: newValue, chatId, transactionId, changedField
|
||||
- [x] Postgres SELECT (get_current_transaction) для получения текущих данных
|
||||
- [x] Code node (merge_changes) для применения изменений
|
||||
- Merge новых и существующих данных
|
||||
- Не пишет в БД (временное состояние)
|
||||
- [x] Telegram Send Message (show_updated_card) для отображения обновлённой карточки
|
||||
- Маркер "не сохранено" для определения источника
|
||||
- Те же три кнопки для продолжения редактирования
|
||||
- ЦИКЛ ЗАМЫКАЕТСЯ: можно редактировать оба поля многократно
|
||||
|
||||
### 🔄 ОСТАЛОСЬ СДЕЛАТЬ
|
||||
|
||||
#### 1. Интеграция с Google Sheets (следующий приоритет)
|
||||
|
||||
- [ ] Создать Google Sheets таблицу для семейного бюджета
|
||||
- [ ] Настроить Google Sheets API в n8n (Service Account)
|
||||
- [ ] Добавить Google Sheets node ПОСЛЕ merge_confirm_paths
|
||||
- Записывать только подтверждённые транзакции
|
||||
- [ ] Настроить запись строки: Дата | Контрагент | Сумма | Категория
|
||||
- [ ] Форматирование: цветовое кодирование расходов/доходов
|
||||
|
||||
#### 2. Автоматизация запуска
|
||||
|
||||
- [ ] Автозапуск LM Studio при загрузке системы
|
||||
- [ ] Автозапуск Docker-контейнеров (restart policy = always)
|
||||
- [ ] Health-check для мониторинга LM Studio API
|
||||
|
||||
#### 3. Улучшения (опционально)
|
||||
|
||||
- [ ] Кнопка "❌ Отклонить" для удаления ошибочных транзакций
|
||||
- [ ] Редактирование подкатегории
|
||||
- [ ] Валидация пользовательского ввода (длина, спецсимволы)
|
||||
- [ ] Обработка исторических данных 2025 года для создания базы знаний
|
||||
- [ ] Fine-tuning модели на собственных размеченных данных
|
||||
- [ ] Аналитика и отчёты по категориям
|
||||
|
||||
---
|
||||
|
||||
## ФИНАЛЬНАЯ АРХИТЕКТУРА WORKFLOW (05.01.2026)
|
||||
|
||||
### Ветка 1: Обработка входящих SMS (ЗАВЕРШЕНА)
|
||||
|
||||
```text
|
||||
Webhook (POST)
|
||||
→ Auth check (x-api-key)
|
||||
→ Code (parse_sms)
|
||||
→ Postgres INSERT (create_from_sms) [Always Output Data = ON]
|
||||
→ Edit Fields (data minimization)
|
||||
→ HTTP Request (post_to_llm)
|
||||
→ Code (parse_from_llm) [choices[0].message.content, очистка markdown, merge]
|
||||
→ Postgres UPDATE (update_llm_fields) [human_verified=FALSE]
|
||||
→ Telegram Send Message (edit_or_confirm) [карточка с Inline Keyboard]
|
||||
```
|
||||
|
||||
### Ветка 2: Единый Telegram Trigger (КРИТИЧНО)
|
||||
|
||||
```text
|
||||
answer_trigger (Telegram Trigger)
|
||||
Updates: callback_query + message
|
||||
Base URL: https://api.telegram.org
|
||||
↓
|
||||
split_event_type (IF: isEmpty(callback_query))
|
||||
↓ TRUE (текстовое сообщение)
|
||||
parse_text_input → get_current_transaction → merge_changes → show_updated_card
|
||||
↓ FALSE (callback от кнопки)
|
||||
parse_from_callback → confirm_or_edit
|
||||
```
|
||||
|
||||
**ВАЖНО**: Только ОДИН Telegram Trigger на весь workflow! Два триггера конфликтуют за один webhook URL.
|
||||
|
||||
### Ветка 3: Подтверждение транзакции (ЗАВЕРШЕНА)
|
||||
|
||||
```text
|
||||
parse_from_callback [isEditedCard = messageText.includes("не сохранено")]
|
||||
↓
|
||||
confirm_or_edit (IF: action == "confirm")
|
||||
↓ TRUE (подтверждение)
|
||||
check_if_edited (IF: isEditedCard == true)
|
||||
↓ TRUE (после редактирования)
|
||||
parse_card_text → update_edited_transaction
|
||||
↓ FALSE (первое подтверждение)
|
||||
row_select_by_id → update_after_confirm
|
||||
↓
|
||||
merge_confirm_paths (объединение двух путей)
|
||||
↓
|
||||
popup_confirm (Answer Callback Query)
|
||||
↓
|
||||
confirmation_message (Edit Message Text)
|
||||
```
|
||||
|
||||
### Ветка 4: Редактирование транзакции с циклом (ЗАВЕРШЕНА)
|
||||
|
||||
```text
|
||||
parse_from_callback
|
||||
↓
|
||||
confirm_or_edit (IF: action == "confirm")
|
||||
↓ FALSE (редактирование)
|
||||
which_field_to_edit (IF: field == "category")
|
||||
↓ TRUE
|
||||
category_edit (Send Message: "Введите категорию #ID, лимит 12 символов")
|
||||
↓ FALSE
|
||||
counterparty_edit (Send Message: "Введите контрагента #ID, лимит 12 символов")
|
||||
↓
|
||||
[Пользователь вводит текст]
|
||||
↓
|
||||
answer_trigger → split_event_type → parse_text_input
|
||||
↓
|
||||
get_current_transaction (SELECT текущих данных)
|
||||
↓
|
||||
merge_changes (применение изменений БЕЗ записи в БД)
|
||||
↓
|
||||
show_updated_card (карточка с маркером "не сохранено")
|
||||
[Те же три кнопки: Подтвердить / Изменить категорию / Изменить контрагента]
|
||||
↓
|
||||
[ЦИКЛ ЗАМЫКАЕТСЯ: нажатие любой кнопки → answer_trigger]
|
||||
```
|
||||
|
||||
**Ключевая особенность**: Изменения НЕ сохраняются в БД до финального подтверждения. Данные передаются через текст карточки (лимит 12 символов на поле для влезания в callback_data при необходимости).
|
||||
|
||||
---
|
||||
|
||||
## ТЕХНИЧЕСКИЕ ДЕТАЛИ
|
||||
|
||||
### База данных PostgreSQL
|
||||
|
||||
**Connection**:
|
||||
|
||||
- Host: Synology NAS IP
|
||||
- Port: 5433
|
||||
- Database: budget
|
||||
- User: postgres
|
||||
- Credential в n8n: "postgres account"
|
||||
|
||||
**Таблица transactions:**
|
||||
|
||||
```sql
|
||||
-- Базовые поля
|
||||
id BIGSERIAL PRIMARY KEY
|
||||
raw_sms TEXT NOT NULL
|
||||
sms_text TEXT NOT NULL
|
||||
sms_sender VARCHAR(50)
|
||||
action VARCHAR(20) NOT NULL
|
||||
amount NUMERIC(15, 2)
|
||||
signed_amount NUMERIC(15, 2)
|
||||
currency VARCHAR(3) DEFAULT 'RUB'
|
||||
balance NUMERIC(15, 2)
|
||||
received_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
raw_json JSONB NOT NULL
|
||||
|
||||
-- LLM и Human-in-the-loop поля
|
||||
counterparty VARCHAR(255)
|
||||
category VARCHAR(100)
|
||||
subcategory VARCHAR(100)
|
||||
llm_processed_at TIMESTAMP WITH TIME ZONE
|
||||
human_verified BOOLEAN DEFAULT FALSE
|
||||
human_verified_at TIMESTAMP WITH TIME ZONE
|
||||
|
||||
-- Индексы
|
||||
CREATE INDEX idx_transactions_received_at ON transactions(received_at DESC);
|
||||
CREATE INDEX idx_transactions_action ON transactions(action);
|
||||
CREATE INDEX idx_transactions_counterparty ON transactions(counterparty);
|
||||
CREATE INDEX idx_transactions_category ON transactions(category);
|
||||
CREATE INDEX idx_transactions_human_verified ON transactions(human_verified) WHERE human_verified = TRUE;
|
||||
```
|
||||
|
||||
### Telegram Configuration
|
||||
|
||||
**Telegram Credential (в n8n)**:
|
||||
|
||||
- Access Token: токен от @BotFather
|
||||
- Base URL: `https://api.telegram.org` (ВАЖНО!)
|
||||
|
||||
**Почему именно этот Base URL**:
|
||||
|
||||
- НЕ пустой (не polling mode)
|
||||
- НЕ адрес n8n (не webhook mode через собственный сервер)
|
||||
- Стандартный API Telegram - n8n использует гибридный режим получения событий
|
||||
|
||||
**Проблема с двумя триггерами**:
|
||||
|
||||
- Telegram позволяет только ОДИН webhook на бота
|
||||
- Два Telegram Trigger в одном workflow конфликтуют
|
||||
- Решение: единый триггер + IF для разделения типов событий
|
||||
|
||||
### LLM Промпт (финальная версия)
|
||||
|
||||
**System Message:**
|
||||
|
||||
```text
|
||||
Ты эксперт по категоризации расходов семейного бюджета в России. Анализируй банковские SMS и извлекай данные.
|
||||
|
||||
ВАЖНО:
|
||||
- Если контрагент НЕИЗВЕСТЕН или непонятен - укажи confidence < 0.5
|
||||
- Уверенность 1.0 ставь ТОЛЬКО для известных российских брендов
|
||||
- Для неочевидных названий снижай confidence до 0.3-0.6
|
||||
|
||||
Известные сети: PYATEROCHKA, MAGNIT, ЗОЛОТОЕ ЯБЛОКО, WILDBERRIES, YANDEX TAXI
|
||||
|
||||
Для неизвестных: category=Неизвестно, confidence < 0.5
|
||||
|
||||
Отвечай ТОЛЬКО валидным JSON без markdown обёртки.
|
||||
```
|
||||
|
||||
**User Message:**
|
||||
|
||||
```text
|
||||
Проанализируй SMS: {{ $json.sms_text }}
|
||||
Тип: {{ $json.action }}
|
||||
Сумма: {{ $json.amount }} {{ $json.currency }}
|
||||
|
||||
Если контрагент неизвестен - confidence < 0.5!
|
||||
|
||||
Верни JSON: {"counterparty": "", "category": "", "subcategory": "", "confidence": 0.0}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ФИНАЛЬНЫЕ CODE NODES (готовые к использованию)
|
||||
|
||||
### 1. parse_from_llm (ИСПРАВЛЕНА ошибка choices.message)
|
||||
|
||||
```javascript
|
||||
// Исправлено: choices.message → choices[0].message
|
||||
let content = $input.first().json.choices[0].message.content;
|
||||
|
||||
// Очистка markdown обёртки
|
||||
content = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
|
||||
|
||||
const llmResponse = JSON.parse(content);
|
||||
const originalData = $('create_from_sms').first().json;
|
||||
|
||||
return {
|
||||
...originalData,
|
||||
counterparty: llmResponse.counterparty || 'Не определён',
|
||||
category: llmResponse.category || 'Неизвестно',
|
||||
subcategory: llmResponse.subcategory || 'Требует уточнения',
|
||||
confidence: llmResponse.confidence !== undefined ? llmResponse.confidence : 0.0
|
||||
};
|
||||
```
|
||||
|
||||
### 2. parse_from_callback (ОБНОВЛЕНА: добавлено isEditedCard)
|
||||
|
||||
```javascript
|
||||
const callbackData = $input.first().json.callback_query.data;
|
||||
const chatId = $input.first().json.callback_query.message.chat.id;
|
||||
const messageId = $input.first().json.callback_query.message.message_id;
|
||||
const messageText = $input.first().json.callback_query.message.text;
|
||||
|
||||
const parts = callbackData.split('_');
|
||||
const action = parts[0]; // confirm, edit
|
||||
const field = parts.length === 3 ? parts[1] : null; // category, counterparty
|
||||
const transactionId = parseInt(parts[parts.length - 1]);
|
||||
|
||||
// Проверка: это подтверждение после редактирования?
|
||||
const isEditedCard = messageText && messageText.includes('не сохранено');
|
||||
|
||||
return {
|
||||
action: action,
|
||||
field: field,
|
||||
transactionId: transactionId,
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
messageText: messageText,
|
||||
isEditedCard: isEditedCard,
|
||||
originalCallbackData: callbackData
|
||||
};
|
||||
```
|
||||
|
||||
### 3. parse_text_input (для обработки текстовых ответов)
|
||||
|
||||
```javascript
|
||||
const userText = $input.first().json.message.text;
|
||||
const chatId = $input.first().json.message.chat.id;
|
||||
const messageData = $input.first().json.message;
|
||||
|
||||
// Игнорируем команды
|
||||
if (userText.startsWith('/')) {
|
||||
throw new Error('Команда проигнорирована');
|
||||
}
|
||||
|
||||
// Проверяем что это ответ на наше сообщение
|
||||
if (!messageData.reply_to_message) {
|
||||
throw new Error('Сообщение не является ответом на запрос');
|
||||
}
|
||||
|
||||
// Извлекаем ID транзакции из текста запроса
|
||||
const originalText = messageData.reply_to_message.text;
|
||||
const match = originalText.match(/#(\d+)/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Не удалось найти ID транзакции');
|
||||
}
|
||||
|
||||
const transactionId = parseInt(match[1]);
|
||||
|
||||
// Определяем какое поле пользователь меняет
|
||||
let changedField = null;
|
||||
if (originalText.includes('категорию')) {
|
||||
changedField = 'category';
|
||||
} else if (originalText.includes('контрагента')) {
|
||||
changedField = 'counterparty';
|
||||
}
|
||||
|
||||
return {
|
||||
newValue: userText.trim(),
|
||||
chatId: chatId,
|
||||
transactionId: transactionId,
|
||||
changedField: changedField
|
||||
};
|
||||
```
|
||||
|
||||
### 4. merge_changes (применение изменений без записи в БД)
|
||||
|
||||
```javascript
|
||||
const textInput = $('parse_text_input').first().json;
|
||||
const currentData = $('get_current_transaction').first().json;
|
||||
|
||||
// Применяем изменение
|
||||
const updatedCategory = textInput.changedField === 'category'
|
||||
? textInput.newValue
|
||||
: currentData.category;
|
||||
|
||||
const updatedCounterparty = textInput.changedField === 'counterparty'
|
||||
? textInput.newValue
|
||||
: currentData.counterparty;
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
category: updatedCategory,
|
||||
counterparty: updatedCounterparty,
|
||||
chatId: textInput.chatId
|
||||
};
|
||||
```
|
||||
|
||||
### 5. parse_card_text (извлечение данных из текста карточки)
|
||||
|
||||
```javascript
|
||||
const messageText = $input.first().json.messageText;
|
||||
const transactionId = $input.first().json.transactionId;
|
||||
|
||||
// Извлекаем контрагента (между "Контрагент: " и "\n")
|
||||
const counterpartyMatch = messageText.match(/Контрагент:\s*(.+?)(?:\n|$)/);
|
||||
const counterparty = counterpartyMatch ? counterpartyMatch[1].trim() : null;
|
||||
|
||||
// Извлекаем категорию (между "Категория: " и "\n")
|
||||
const categoryMatch = messageText.match(/Категория:\s*(.+?)(?:\n|$)/);
|
||||
const category = categoryMatch ? categoryMatch[1].trim() : null;
|
||||
|
||||
if (!counterparty || !category) {
|
||||
throw new Error('Не удалось извлечь данные из карточки');
|
||||
}
|
||||
|
||||
return {
|
||||
transactionId: transactionId,
|
||||
counterparty: counterparty,
|
||||
category: category,
|
||||
chatId: $input.first().json.chatId,
|
||||
messageId: $input.first().json.messageId
|
||||
};
|
||||
```
|
||||
|
||||
### 6. prepare_confirmation_data (подготовка данных для финальных нод)
|
||||
|
||||
```javascript
|
||||
const parseData = $('parse_card_text').first().json;
|
||||
|
||||
return {
|
||||
transactionId: parseData.transactionId,
|
||||
chatId: parseData.chatId,
|
||||
messageId: parseData.messageId,
|
||||
counterparty: parseData.counterparty,
|
||||
category: parseData.category
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## НАСТРОЙКИ ВСЕХ НОД
|
||||
|
||||
### Telegram Nodes
|
||||
|
||||
**answer_trigger (Telegram Trigger)**:
|
||||
|
||||
- Credential: Telegram account
|
||||
- Trigger On: callback_query
|
||||
- Updates: `callback_query`, `message` (оба!)
|
||||
|
||||
**edit_or_confirm (Telegram Send Message)**:
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text:
|
||||
|
||||
```text
|
||||
Требуется подтверждение транзакции #{{ $json.id }}
|
||||
|
||||
Сумма: {{ $json.signed_amount }} {{ $json.currency }}
|
||||
Контрагент: {{ $json.counterparty }}
|
||||
Категория: {{ $json.category }}
|
||||
Дата: {{ $json.received_at }}
|
||||
```
|
||||
|
||||
- Reply Markup:
|
||||
|
||||
```json
|
||||
{
|
||||
"inline_keyboard": [
|
||||
[{"text": "✅ Подтвердить", "callback_data": "confirm_{{ $json.id }}"}],
|
||||
[{"text": "✏️ Изменить категорию", "callback_data": "edit_category_{{ $json.id }}"}],
|
||||
[{"text": "✏️ Изменить контрагента", "callback_data": "edit_counterparty_{{ $json.id }}"}]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**category_edit (Telegram Send Message)**:
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text: `Введите новую категорию для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов`
|
||||
- Reply To Message ID: `{{ $json.messageId }}`
|
||||
|
||||
**counterparty_edit (Telegram Send Message)**:
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text: `Введите нового контрагента для транзакции #{{ $json.transactionId }}\n\nВАЖНО: Лимит 12 символов`
|
||||
- Reply To Message ID: `{{ $json.messageId }}`
|
||||
|
||||
**show_updated_card (Telegram Send Message)**:
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text:
|
||||
|
||||
```text
|
||||
Данные обновлены (не сохранено)
|
||||
|
||||
Сумма: {{ $json.signed_amount }} {{ $json.currency }}
|
||||
Контрагент: {{ $json.counterparty }}
|
||||
Категория: {{ $json.category }}
|
||||
Дата: {{ $json.received_at }}
|
||||
|
||||
ВАЖНО: Лимит 12 символов на поле
|
||||
```
|
||||
|
||||
- Reply Markup: (те же три кнопки что и в edit_or_confirm)
|
||||
|
||||
**popup_confirm (Telegram Answer Callback Query)**:
|
||||
|
||||
- Query ID: `{{ $json.callback_query.id }}`
|
||||
- Text: `✅ Транзакция подтверждена и сохранена`
|
||||
|
||||
**confirmation_message (Telegram Edit Message Text)**:
|
||||
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Message ID: `{{ $json.messageId }}`
|
||||
- Text: `✅ Транзакция #{{ $json.transactionId }} подтверждена\n\nКонтрагент: {{ $json.counterparty }}\nКатегория: {{ $json.category }}`
|
||||
|
||||
### PostgreSQL Nodes
|
||||
|
||||
**create_from_sms (Postgres INSERT)**:
|
||||
|
||||
- Operation: Insert
|
||||
- Table: transactions
|
||||
- Columns: все базовые поля из parse_sms
|
||||
- Options: Always Output Data = ON
|
||||
|
||||
**update_llm_fields (Postgres UPDATE)**:
|
||||
|
||||
- Operation: Update
|
||||
- Table: transactions
|
||||
- Columns: counterparty, category, subcategory, llm_processed_at, human_verified=FALSE
|
||||
- Where: id = {{ $json.id }}
|
||||
- Options: Always Output Data = ON
|
||||
|
||||
**get_current_transaction (Postgres SELECT)**:
|
||||
|
||||
- Operation: Select
|
||||
- Table: transactions
|
||||
- Return All: OFF
|
||||
- Where: id = {{ $json.transactionId }}
|
||||
- Options: Always Output Data = ON
|
||||
|
||||
**update_edited_transaction (Postgres UPDATE)**:
|
||||
|
||||
- Operation: Update
|
||||
- Table: transactions
|
||||
- Columns:
|
||||
- counterparty = {{ $json.counterparty }}
|
||||
- category = {{ $json.category }}
|
||||
- human_verified = true (toggle ON)
|
||||
- Where: id = {{ $json.transactionId }}
|
||||
|
||||
**row_select_by_id (Postgres SELECT)**:
|
||||
|
||||
- Operation: Select
|
||||
- Table: transactions
|
||||
- Return All: OFF
|
||||
- Where: id = {{ $json.transactionId }}
|
||||
|
||||
**update_after_confirm (Postgres UPDATE)**:
|
||||
|
||||
- Operation: Update
|
||||
- Table: transactions
|
||||
- Columns:
|
||||
- human_verified = true
|
||||
- human_verified_at = {{ $now }}
|
||||
- Where: id = {{ $json.transactionId }}
|
||||
|
||||
### IF Nodes
|
||||
|
||||
**split_event_type (разделение типов событий)**:
|
||||
|
||||
- Condition: `{{ $json.callback_query }}` Is Empty
|
||||
- TRUE → текстовое сообщение → parse_text_input
|
||||
- FALSE → callback от кнопки → parse_from_callback
|
||||
|
||||
**confirm_or_edit (разделение подтверждения/редактирования)**:
|
||||
|
||||
- Condition: `{{ $json.action }}` Equal `confirm`
|
||||
- TRUE → подтверждение
|
||||
- FALSE → редактирование
|
||||
|
||||
**check_if_edited (определение источника подтверждения)**:
|
||||
|
||||
- Condition: `{{ $json.isEditedCard }}` Equal `true`
|
||||
- TRUE → после редактирования → parse_card_text
|
||||
- FALSE → первое подтверждение → row_select_by_id
|
||||
|
||||
**which_field_to_edit (определение редактируемого поля)**:
|
||||
|
||||
- Condition: `{{ $json.field }}` Equal `category`
|
||||
- TRUE → category_edit
|
||||
- FALSE → counterparty_edit
|
||||
|
||||
### Merge Node
|
||||
|
||||
**merge_confirm_paths (объединение путей подтверждения)**:
|
||||
|
||||
- Type: Merge
|
||||
- Mode: Append
|
||||
- Input 1: update_after_confirm (первое подтверждение)
|
||||
- Input 2: prepare_confirmation_data (после редактирования)
|
||||
- Output: popup_confirm
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
### 2026-01-05 - Human-in-the-loop ПОЛНОСТЬЮ ЗАВЕРШЁН ✅
|
||||
|
||||
**Критические исправления:**
|
||||
|
||||
- Исправлена ошибка в parse_from_llm: `choices.message` → `choices[0].message.content`
|
||||
- Решена проблема конфликта двух Telegram триггеров за один webhook
|
||||
- Удалён edit_trigger, всё через единый answer_trigger
|
||||
- Добавлен split_event_type IF для разделения callback_query и message
|
||||
- Исправлена ошибка 403 Forbidden при регистрации webhook
|
||||
- Telegram Credential Base URL установлен в <https://api.telegram.org>
|
||||
|
||||
**Реализован полный цикл редактирования:**
|
||||
|
||||
- parse_text_input: парсинг текстовых ответов пользователя
|
||||
- get_current_transaction: SELECT текущих данных
|
||||
- merge_changes: применение изменений БЕЗ записи в БД
|
||||
- show_updated_card: обновлённая карточка с маркером "не сохранено"
|
||||
- ЦИКЛ: можно редактировать category и counterparty многократно
|
||||
|
||||
**Реализовано двойное подтверждение:**
|
||||
|
||||
- check_if_edited IF: определение источника подтверждения
|
||||
- parse_card_text: извлечение данных из текста карточки
|
||||
- update_edited_transaction: UPDATE после редактирования
|
||||
- prepare_confirmation_data: подготовка данных
|
||||
- merge_confirm_paths: объединение двух путей подтверждения
|
||||
|
||||
**Технические решения:**
|
||||
|
||||
- Лимит 12 символов на поле (для влезания в callback_data)
|
||||
- Маркер "не сохранено" в тексте карточки для определения типа
|
||||
- Данные передаются через текст сообщения, не через БД
|
||||
- Единый триггер + IF вместо двух конфликтующих триггеров
|
||||
|
||||
### 2026-01-02 - Human-in-the-loop через Telegram (90% завершено)
|
||||
|
||||
- Изменена архитектура: все транзакции через человека
|
||||
- LLM-данные сохраняются сразу (human_verified=false)
|
||||
- Создан Telegram-бот, настроены credentials
|
||||
- TRUE ветка (подтверждение) завершена
|
||||
- FALSE ветка (редактирование) на 90%
|
||||
- Парсинг markdown обёртки LLM
|
||||
- Reply To Message для связи с транзакцией
|
||||
|
||||
### 2026-01-01 - LM Studio интегрирован
|
||||
|
||||
- Установлен LM Studio, загружена Qwen2.5-7B-Instruct
|
||||
- Настроены параметры модели
|
||||
- Решена проблема подключения n8n Docker к LM Studio
|
||||
- Создан HTTP Request узел для LLM API
|
||||
- Разработан промпт для категоризации
|
||||
|
||||
### 2025-12-XX - Базовая инфраструктура
|
||||
|
||||
- Развернут PostgreSQL 16 в Docker
|
||||
- Создана таблица transactions
|
||||
- Настроен n8n workflow: Webhook → Auth → Code → PostgreSQL
|
||||
- Реализован парсинг SMS
|
||||
|
||||
---
|
||||
|
||||
## СЛЕДУЮЩИЕ ШАГИ
|
||||
|
||||
### Приоритет 1: Интеграция с Google Sheets
|
||||
|
||||
1. Создать Google Sheets таблицу для семейного бюджета
|
||||
2. Настроить Google Sheets API в n8n (Service Account)
|
||||
3. Добавить Google Sheets node после merge_confirm_paths
|
||||
4. Записывать только подтверждённые транзакции
|
||||
5. Настроить колонки: Дата | Контрагент | Сумма | Категория | Подкатегория
|
||||
6. Форматирование: цветовое кодирование расходов/доходов
|
||||
|
||||
### Приоритет 2: Автоматизация запуска
|
||||
|
||||
1. Автозапуск LM Studio при загрузке системы (systemd service)
|
||||
2. Docker restart policy = always для всех контейнеров
|
||||
3. Health-check для мониторинга LM Studio API
|
||||
4. Уведомления в Telegram при падении сервисов
|
||||
|
||||
### Приоритет 3: Улучшения (опционально)
|
||||
|
||||
1. Кнопка "❌ Отклонить" для удаления ошибочных транзакций
|
||||
2. Редактирование подкатегории
|
||||
3. Валидация пользовательского ввода (длина, спецсимволы)
|
||||
4. Обработка исторических данных 2025 года
|
||||
5. Fine-tuning модели на собственных данных
|
||||
6. Аналитика и отчёты по категориям расходов
|
||||
7. Экспорт данных в различные форматы
|
||||
|
||||
---
|
||||
|
||||
**Версия документа**: 3.0
|
||||
**Дата последнего обновления**: 2026-01-05
|
||||
**Статус**: Основной функционал ЗАВЕРШЁН (100%), готов к продакшн-использованию
|
||||
**Следующий шаг**: Интеграция с Google Sheets
|
||||
**Автор обновления**: AI Assistant (Perplexity)
|
||||
23
compose.yaml
Normal file
23
compose.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: postgres_budget
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- /volume1/docker/postgres/data:/var/lib/postgresql/data
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: pgadmin_budget
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@vakanaut.ru
|
||||
PGADMIN_DEFAULT_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
- postgres
|
||||
265
improvement.md
Normal file
265
improvement.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Улучшения системы обработки банковских транзакций
|
||||
|
||||
## 1. Упрощение логики бесконечного редактирования
|
||||
|
||||
### 1.1 Текущая проблема
|
||||
|
||||
- Используется таблица `user_sessions` для хранения состояния редактирования
|
||||
- Множественные SELECT/UPDATE запросы при каждом действии пользователя
|
||||
- Сложная логика ветвления в n8n для управления сессиями
|
||||
|
||||
### 1.2 Решение: Stateful callback_data с editMessage
|
||||
|
||||
**Принцип работы:** Храним все состояние редактирования прямо в callback_data кнопок Telegram
|
||||
|
||||
#### Формат callback_data
|
||||
|
||||
```text
|
||||
edit_card_{transactionId}_{counterparty}_{category}
|
||||
set_category_{transactionId}_{counterparty}_{newCategory}
|
||||
edit_field_{transactionId}_{counterparty}_{category}_{field}
|
||||
confirm_{transactionId}_{counterparty}_{category}
|
||||
```
|
||||
|
||||
#### Примеры
|
||||
|
||||
```text
|
||||
edit_card_123_Пятёрочка_Продукты
|
||||
set_category_123_Пятёрочка_Авто
|
||||
edit_field_123_Пятёрочка_Продукты_category
|
||||
confirm_123_Пятёрочка_Продукты
|
||||
```
|
||||
|
||||
#### Узлы n8n для удаления
|
||||
|
||||
1. ❌ `user_sessions` таблица
|
||||
2. ❌ `get_session` (SELECT)
|
||||
3. ❌ `add_session_category` (INSERT)
|
||||
4. ❌ `add_session_counterparty` (INSERT)
|
||||
5. ❌ `merge_user_and_session`
|
||||
6. ❌ `parse_text_input` (частично)
|
||||
7. ❌ `merge_changes`
|
||||
|
||||
#### Новые узлы
|
||||
|
||||
1. ✅ `create_edit_card` — генерирует карточку редактирования с inline-клавиатурой
|
||||
2. ✅ `update_card_on_action` — обрабатывает callback и обновляет сообщение через editMessage
|
||||
|
||||
### 1.3 Преимущества
|
||||
|
||||
- **Уменьшение нагрузки на БД на 80%** — убираем 5-7 запросов на одно действие
|
||||
- **Упрощение workflow** — вместо ветвления по сессиям — линейная обработка
|
||||
- **Мгновенная обратная связь** — editMessage вместо новых сообщений
|
||||
- **Stateless архитектура** — нет зависимостей между запросами
|
||||
|
||||
## 2. Улучшение UX для выбора категорий
|
||||
|
||||
### 2.1 Текущая проблема
|
||||
|
||||
- Текстовый ввод категорий требует запоминания всех 20 вариантов
|
||||
- Ошибки при ручном вводе (опечатки, синонимы)
|
||||
- Медленный процесс категоризации
|
||||
|
||||
### 2.2 Решение: Многоуровневая inline-клавиатура
|
||||
|
||||
#### Первый уровень — "умные" популярные категории
|
||||
|
||||
```javascript
|
||||
// Алгоритм выбора популярных категорий:
|
||||
// 1. Берем топ-3 категории пользователя из истории (если есть)
|
||||
// 2. Добавляем глобально популярные категории
|
||||
// 3. Всего 4 кнопки
|
||||
|
||||
const getUserTopCategories = async (chatId) => {
|
||||
// Запрос к БД за последние 30 дней
|
||||
const topCats = await db.query(`
|
||||
SELECT category, COUNT(*) as count
|
||||
FROM transactions
|
||||
WHERE chat_id = $1
|
||||
AND category IS NOT NULL
|
||||
AND received_at > NOW() - INTERVAL '30 days'
|
||||
GROUP BY category
|
||||
ORDER BY count DESC
|
||||
LIMIT 3
|
||||
`, [chatId]);
|
||||
|
||||
return topCats.map(row => row.category);
|
||||
};
|
||||
|
||||
const getDefaultCategories = () => ["Продукты", "Авто", "Дом"];
|
||||
```
|
||||
|
||||
#### Второй уровень — все категории компактно
|
||||
|
||||
```javascript
|
||||
// Без эмодзи для экономии места
|
||||
const ALL_CATEGORIES = [
|
||||
["Продукты", "Авто", "Здоровье", "Арчи"],
|
||||
["ЖКХ", "Дом", "Проезд", "Одежда"],
|
||||
["Химия", "Косметика", "Инвестиции", "Развлечения"],
|
||||
["Общепит", "Штрафы", "Налоги", "Подписки"],
|
||||
["Перевод", "Наличные", "Подарки", "Спорт"],
|
||||
["Другое"]
|
||||
];
|
||||
|
||||
// Каждая кнопка: callback_data = "set_category_{id}_{counterparty}_{category}"
|
||||
```
|
||||
|
||||
#### Узлы n8n для изменения
|
||||
|
||||
1. 🔄 `category_edit` → `send_category_keyboard` (отправляет 4 кнопки)
|
||||
2. ✅ `show_all_categories` → новый узел для отправки полного списка
|
||||
3. ❌ Текстовый ввод категорий удаляется полностью
|
||||
|
||||
#### Кэширование популярных категорий
|
||||
|
||||
```javascript
|
||||
// В Redis или памяти n8n (Global Variables)
|
||||
// Ключ: user_top_cats_{chatId}
|
||||
// TTL: 1 час
|
||||
// Данные: ["Продукты", "Общепит", "Авто"]
|
||||
|
||||
// При запросе:
|
||||
// 1. Проверяем кэш
|
||||
// 2. Если нет → запрос к БД → сохранение в кэш
|
||||
// 3. Возвращаем данные
|
||||
```
|
||||
|
||||
### 2.3 Преимущества
|
||||
|
||||
- **В 3-5 раз быстрее** выбор категории
|
||||
- **Нет ошибок ввода** — только валидные категории
|
||||
- **Персонализация** — показываем часто используемые категории
|
||||
- **Экономия места** — без эмодзи больше категорий на экране
|
||||
|
||||
## 3. Техническая реализация
|
||||
|
||||
### Изменения в БД
|
||||
|
||||
```sql
|
||||
-- Удаляем таблицу user_sessions
|
||||
DROP TABLE IF EXISTS user_sessions;
|
||||
|
||||
-- Добавляем кэш популярных категорий (опционально)
|
||||
CREATE TABLE IF NOT EXISTS user_category_stats (
|
||||
chat_id BIGINT,
|
||||
category VARCHAR(100),
|
||||
count INTEGER DEFAULT 1,
|
||||
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chat_id, category)
|
||||
);
|
||||
|
||||
-- Индекс для быстрого получения топ категорий
|
||||
CREATE INDEX idx_user_cats ON user_category_stats(chat_id, count DESC);
|
||||
```
|
||||
|
||||
### Изменения в workflow n8n
|
||||
|
||||
#### Ветка редактирования (новая)
|
||||
|
||||
```text
|
||||
edit_field (callback) →
|
||||
IF field = 'category' →
|
||||
get_top_categories (Code + возможно DB) →
|
||||
send_category_keyboard (Telegram Message)
|
||||
|
||||
IF field = 'counterparty' →
|
||||
request_counterparty_input (Telegram Message) →
|
||||
(текстовый ответ) →
|
||||
update_card_with_new_counterparty (editMessage)
|
||||
```
|
||||
|
||||
#### Ветка выбора категории
|
||||
|
||||
```text
|
||||
set_category (callback) →
|
||||
update_category_stats (DB) → -- инкрементируем счетчик
|
||||
update_card (editMessage) → -- обновляем карточку
|
||||
(возврат в режим редактирования)
|
||||
```
|
||||
|
||||
#### Ветка "все категории"
|
||||
|
||||
```text
|
||||
show_all_cats (callback) →
|
||||
send_all_categories (Telegram Message) → -- компактная клавиатура 5x4
|
||||
(пользователь выбирает) →
|
||||
set_category (существующая ветка)
|
||||
```
|
||||
|
||||
### Лимиты и ограничения
|
||||
|
||||
1. **Callback_data length** ≤ 64 байт
|
||||
|
||||
```javascript
|
||||
// Решение: кодирование/декодирование длинных строк
|
||||
const encodeData = (str) => encodeURIComponent(str.substring(0, 20));
|
||||
const decodeData = (str) => decodeURIComponent(str);
|
||||
```
|
||||
|
||||
2. **Inline keyboard rows** ≤ 8-10 для удобства
|
||||
3. **Кэш TTL** = 1 час для актуальности
|
||||
|
||||
## 4. Ожидаемые результаты
|
||||
|
||||
### Метрики улучшений
|
||||
|
||||
| Показатель | Было | Стало | Улучшение |
|
||||
| ------------ | ------ | ------- | ----------- |
|
||||
| Запросов к БД на действие | 5-7 | 1-2 | ~70% ↓ |
|
||||
| Время категоризации | 10-30 сек | 2-5 сек | 5x ↑ |
|
||||
| Ошибок категоризации | 15-20% | <2% | 10x ↓ |
|
||||
| Пользовательских кликов | 3-5 | 1-2 | 2x ↓ |
|
||||
|
||||
### Безопасность
|
||||
|
||||
1. Все данные в callback_data URL-encoded
|
||||
2. Валидация transactionId при каждом запросе
|
||||
3. Проверка прав доступа к чату
|
||||
|
||||
### Резервное решение
|
||||
|
||||
Если callback_data превышает лимит Telegram:
|
||||
|
||||
```javascript
|
||||
// Fallback: короткий хэш + временное хранилище
|
||||
const hash = md5(transactionId + counterparty + category).substring(0, 8);
|
||||
cache.set(`card_${hash}`, {transactionId, counterparty, category}, 300); // 5 мин
|
||||
callback_data = `short_${hash}`;
|
||||
```
|
||||
|
||||
## 5. План внедрения
|
||||
|
||||
### Фаза 1: Подготовка (1-2 дня)
|
||||
|
||||
1. Создать новую таблицу `user_category_stats`
|
||||
2. Написать миграцию данных из истории транзакций
|
||||
3. Обновить узлы обработки callback_data
|
||||
|
||||
### Фаза 2: Внедрение (1 день)
|
||||
|
||||
1. Добавить новые узлы n8n параллельно существующим
|
||||
2. Протестировать на отдельном Telegram-боте
|
||||
3. Проверить лимиты callback_data
|
||||
|
||||
### Фаза 3: Переключение (1 день)
|
||||
|
||||
1. Направить 10% трафика на новую систему
|
||||
2. Сравнить метрики
|
||||
3. Полное переключение при успехе
|
||||
|
||||
### Фаза 4: Оптимизация (непрерывно)
|
||||
|
||||
1. Мониторинг нагрузки БД
|
||||
2. Настройка кэширования
|
||||
3. A/B тестирование разных наборов популярных категорий
|
||||
|
||||
## Заключение
|
||||
|
||||
Предложенные изменения устраняют основные боли пользователей:
|
||||
|
||||
- **Упрощают редактирование** через мгновенное обновление карточек
|
||||
- **Ускоряют категоризацию** через умные inline-клавиатуры
|
||||
- **Уменьшают нагрузку** на инфраструктуру через stateless дизайн
|
||||
|
||||
Общее количество узлов n8n сократится с 36 до ~25 при улучшении UX и производительности.
|
||||
126
index.html
Normal file
126
index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css"
|
||||
integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body class="page">
|
||||
<p>
|
||||
<strong>Цель:</strong> формирование, отбор и поддержка проектов,
|
||||
ориентированных на прикладное использование результатов научных
|
||||
исследований, разработок и учебно-методических заделов МИЭМ.
|
||||
</p>
|
||||
<p><strong>Ключевые положения:</strong></p>
|
||||
<ol>
|
||||
<li>На конкурс могут предлагаться проекты трех типов:</li>
|
||||
<ul class="common-list">
|
||||
<li>Ориентированные фундаментальные</li>
|
||||
<li>Прикладные исследования и разработки</li>
|
||||
<li>Учебно-методические разработки</li>
|
||||
</ul>
|
||||
|
||||
<li>
|
||||
Объем финансирования проекта может составлять от 500 тыс. руб.
|
||||
до 3 млн. руб. на срок до 1 года.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
К экспертизе каждого из проектов привлекаются не менее 2
|
||||
профильных экспертов, их оценка наряду с оценкой членов Научной
|
||||
комиссии учитывается в итоговом рейтинге проектов.
|
||||
</li>
|
||||
<li>
|
||||
Все проекты проходят процедуру публичной защиты проектов, как на
|
||||
этапе конкурса, так и на этапе отчета о результатах проделанной
|
||||
работы.
|
||||
</li>
|
||||
<li>
|
||||
Результатом должен являться отчет о НИР, отчет о патентных
|
||||
исследованиях (для проектов 1 и 2 типа), а также те показатели,
|
||||
которые заявители установили на этапе подачи.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>Требования к участникам:</strong></p>
|
||||
<ul class="list-none">
|
||||
<li>
|
||||
- коллектив должен состоять из 5 и более участников (без
|
||||
ограничения по максимальному количеству участников, работающих в
|
||||
ней на постоянной основе);
|
||||
</li>
|
||||
<li>
|
||||
- руководитель - работник МИЭМ НИУ ВШЭ, занимающий должность
|
||||
работника НИУ ВШЭ по основному месту работы;
|
||||
</li>
|
||||
<li>
|
||||
- участник может быть руководителем только одной проектной группы,
|
||||
при этом не ограничивается количество научных групп, где этот
|
||||
участник может быть исполнителем;
|
||||
</li>
|
||||
<li>
|
||||
- проектные группы должны иметь смешанный состав, включая
|
||||
работников и обучающихся;
|
||||
</li>
|
||||
<li>
|
||||
- привлечение внешних исполнителей возможно с обязательством, при
|
||||
необходимости, последующего найма на условиях полной занятости
|
||||
или совместительства в структурное подразделение МИЭМ НИУ ВШЭ.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Предлагаемый проект должен быть оригинальным и не дублировать
|
||||
проекты, поддерживаемые в рамках действующих централизованных
|
||||
программ НИУ ВШЭ или за счет внешнего финансирования.
|
||||
</p>
|
||||
<p>
|
||||
Конкурсный отбор, реализация и приемка результатов проектов состоят
|
||||
из шести этапов:
|
||||
</p>
|
||||
<ul class="list-none">
|
||||
<li>1 этап: прием заявок;</li>
|
||||
<li>2 этап: экспертиза по формальным признакам;</li>
|
||||
<li>3 этап: экспертиза заявок по существу;</li>
|
||||
<li>4 этап: защита проектов, подведение итогов Конкурса;</li>
|
||||
<li>5 этап: реализация проектов;</li>
|
||||
<li>6 этап: защита результатов проектов.</li>
|
||||
</ul>
|
||||
<p><strong>Финансирование:</strong></p>
|
||||
<ul>
|
||||
<li>
|
||||
Объем финансирования проектной группы составляет от 500 000 до 3
|
||||
000 000 рублей.
|
||||
</li>
|
||||
<li>
|
||||
Доля оплаты труда руководителя не может превышать 25 % фонда
|
||||
оплаты труда участников проектной группы.
|
||||
</li>
|
||||
<li>
|
||||
Доля оплаты труда обучающихся не может быть ниже 25 % фонда
|
||||
оплаты труда участников проектной группы.
|
||||
</li>
|
||||
<li>
|
||||
Сбор конкурсных заявок проводится в МИЭМ НИУ ВШЭ ежегодно с
|
||||
возможностью выделения нескольких туров.
|
||||
</li>
|
||||
<li>
|
||||
Сроки проведения и количество туров Конкурса устанавливаются
|
||||
приказом директора МИЭМ НИУ ВШЭ.
|
||||
</li>
|
||||
</ul>
|
||||
<p><strong>Сроки реализации проектов 2026 г.: 01.03 - 31.11.2026 г.</strong></p>
|
||||
<p>
|
||||
Заявки принимаются до 22.01.2026 г. по
|
||||
<a href="https://forms.yandex.ru/cloud/693fd1eb90290261b6f0e1d3/"
|
||||
>ссылке</a
|
||||
>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
143
index.js
Normal file
143
index.js
Normal file
@@ -0,0 +1,143 @@
|
||||
function toNumberRu(s) {
|
||||
if (!s) return null;
|
||||
const cleaned = String(s)
|
||||
.replace(/\s/g, "")
|
||||
.replace(",", ".")
|
||||
.replace(/[^\d.]/g, "");
|
||||
const n = Number(cleaned);
|
||||
console.log("toNumberRu: " + Number.isFinite(n));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function paringSms(sms) {
|
||||
console.log(sms);
|
||||
|
||||
const raw = sms.body.message ?? "";
|
||||
|
||||
console.log("raw: " + raw);
|
||||
|
||||
const text = String(raw).replace(/\s+/g, " ").trim();
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
console.log("text: " + text);
|
||||
|
||||
const sender = sms.body?.sender ?? null;
|
||||
|
||||
console.log("sender: " + sender);
|
||||
|
||||
// 1) Классификация
|
||||
let action = "unknown";
|
||||
|
||||
const rules = [
|
||||
{ action: "salary", re: /зарплат/i },
|
||||
{ action: "reversal", re: /(отмена|возврат)/i },
|
||||
{ action: "payment", re: /(оплата|покупк)/i },
|
||||
{ action: "transfer", re: /перевод/i },
|
||||
{ action: "writeoff", re: /списан/i },
|
||||
{ action: "income", re: /(поступлен|поступление|зачислен)/i },
|
||||
];
|
||||
|
||||
for (const r of rules) {
|
||||
if (r.re.test(lower)) {
|
||||
action = r.action;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("action: " + action);
|
||||
|
||||
// 2) Баланс
|
||||
const balanceRegex =
|
||||
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)(?=\s|$)/i;
|
||||
const balanceMatch = text.match(balanceRegex);
|
||||
console.log("balanceMatch: " + balanceMatch);
|
||||
|
||||
const balance = balanceMatch ? toNumberRu(balanceMatch[2]) : null;
|
||||
|
||||
console.log("balance: " + balance);
|
||||
|
||||
// 3) Сумма операции
|
||||
let amount = null;
|
||||
const amountNearActionRegex =
|
||||
/(отмена[:\s-]*оплата|отмена|списание|оплата|перевод|поступление|зарплат[аы]?)[\s:]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
|
||||
|
||||
const near = text.match(amountNearActionRegex);
|
||||
|
||||
console.log("near: " + near);
|
||||
|
||||
if (near) {
|
||||
amount = toNumberRu(near[2]);
|
||||
console.log(amount);
|
||||
} else {
|
||||
const textWithoutBalance = text.replace(balanceRegex, " ");
|
||||
|
||||
console.log("textWithoutBalance: " + textWithoutBalance);
|
||||
|
||||
const amountRegex = /([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
|
||||
const m = textWithoutBalance.match(amountRegex);
|
||||
amount = m ? toNumberRu(m[1]) : null;
|
||||
console.log("m: " + m);
|
||||
}
|
||||
|
||||
const currency = "RUB";
|
||||
|
||||
// 4) signedAmount
|
||||
let signedAmount = amount;
|
||||
if (amount != null) {
|
||||
if (["payment", "transfer", "writeoff"].includes(action))
|
||||
signedAmount = -Math.abs(amount);
|
||||
if (["income", "salary", "reversal"].includes(action))
|
||||
signedAmount = Math.abs(amount);
|
||||
}
|
||||
|
||||
console.log({
|
||||
raw_sms: raw,
|
||||
sms_text: text,
|
||||
sms_sender: sender,
|
||||
action, // income/payment/transfer/salary/reversal/writeoff
|
||||
amount,
|
||||
signed_amount: signedAmount,
|
||||
currency,
|
||||
balance,
|
||||
received_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
raw_sms: raw,
|
||||
sms_text: text,
|
||||
sms_sender: sender,
|
||||
action, // income/payment/transfer/salary/reversal/writeoff
|
||||
amount,
|
||||
signed_amount: signedAmount,
|
||||
currency,
|
||||
balance,
|
||||
received_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const input_sms = {
|
||||
body: {
|
||||
message:
|
||||
'Оплата 980р Карта*4215 "SKURATOV COFFE Баланс 207033.86р 19:52',
|
||||
sender: "VTB",
|
||||
formatted_message:
|
||||
'📩 *New SMS Received*\n👤 *From:* VTB\n\nОплата 980р Карта*4215 "SKURATOV COFFE Баланс 207033.86р 19:52',
|
||||
contact: "",
|
||||
},
|
||||
};
|
||||
|
||||
const input_sms2 = {
|
||||
body: {
|
||||
formatted_message:
|
||||
"📩 *New SMS Received*\n👤 *From:* VTB\n\nОплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56",
|
||||
contact: "",
|
||||
sender: "VTB",
|
||||
message:
|
||||
"Оплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56",
|
||||
},
|
||||
};
|
||||
|
||||
paringSms(input_sms);
|
||||
// paringSms(input_sms2);
|
||||
34
style.css
Normal file
34
style.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.list-none {
|
||||
list-style: none;
|
||||
margin-left: -15px;
|
||||
}
|
||||
|
||||
.common-list {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
li {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.page {
|
||||
max-width: 896px;
|
||||
font-family: "HSE Sans", "Proxima Nova", "Helvetica Neue", Arial, sans-serif;
|
||||
font: 18px;
|
||||
margin: auto;
|
||||
line-height: 1.33;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
a:not([class]),
|
||||
:link,
|
||||
:visited,
|
||||
.link {
|
||||
color: #007ac5;
|
||||
border-bottom-color: rgba(0, 122, 197, 0.3);
|
||||
}
|
||||
50
test.js
Normal file
50
test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const text = "Оплата 6800р Карта*4215 IP SHARAFETDINO Баланс 199083.86р 09:56";
|
||||
const balanceRegex =
|
||||
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)\b/i;
|
||||
|
||||
console.log("Текст:", text);
|
||||
console.log("Регулярка:", balanceRegex);
|
||||
|
||||
// Проверим по шагам
|
||||
console.log("\n=== ДИАГНОСТИКА ===");
|
||||
|
||||
// 1. Проверим, есть ли слово "баланс" в тексте
|
||||
console.log("1. Содержит 'Баланс':", text.includes("Баланс"));
|
||||
console.log(" Индекс 'Баланс':", text.indexOf("Баланс"));
|
||||
|
||||
// 2. Проверим символ после "Баланс"
|
||||
const balanceIndex = text.indexOf("Баланс");
|
||||
if (balanceIndex > -1) {
|
||||
const afterBalance = text.substring(balanceIndex, balanceIndex + 20);
|
||||
console.log("2. Текст после 'Баланс':", afterBalance);
|
||||
console.log(" Коды символов после 'Баланс':");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const char = text[balanceIndex + 7 + i];
|
||||
console.log(` ${char} (${char?.charCodeAt(0)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Проверим часть с суммой вручную
|
||||
console.log("\n3. Проверка частичными регулярками:");
|
||||
|
||||
// Проверяем только слово "баланс"
|
||||
const test1 = /баланс/i.test(text);
|
||||
console.log(" Находит 'баланс':", test1);
|
||||
|
||||
// Проверяем "баланс" с пробелом и числом
|
||||
const test2 = /баланс\s*\d/i.test(text);
|
||||
console.log(" Находит 'баланс' с числом:", test2);
|
||||
|
||||
// Проверяем всю сумму без валюты
|
||||
const test3 = text.match(/баланс[:\s]*([\d\s.,]+)/i);
|
||||
console.log(" Находит сумму без валюты:", test3);
|
||||
|
||||
// Проверяем валюту отдельно
|
||||
const test4 = text.match(/р\b/);
|
||||
console.log(" Находит 'р' с границей слова:", test4);
|
||||
|
||||
// Альтернативная регулярка без \b
|
||||
const balanceRegexSimple =
|
||||
/(баланс)[:\s]*([0-9][0-9\s]*[.,]?\d{0,2})\s*(₽|руб\.?|rub|р)/i;
|
||||
console.log("\n4. С упрощенной регуляркой (без \\b):");
|
||||
console.log(" Результат:", text.match(balanceRegexSimple));
|
||||
Reference in New Issue
Block a user