first plan

This commit is contained in:
2026-05-29 12:26:56 +03:00
parent 0b4ae9cd2d
commit 5e97506d55
+773
View File
@@ -0,0 +1,773 @@
# Парсинг push-уведомлений банков → транзакции
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback. Auto-apply при высокой уверенности, остальное — в Inbox.
---
## 1. Цели и принципы
- **Тихий ассистент.** Если всё распознано уверенно — транзакция появляется в ленте Home без диалогов. Бэдж/Inbox возникают только когда нужно внимание.
- **Никаких процентов в UI.** Пользователю показываем не «confidence 87%», а подсветку слабых полей.
- **Правила учатся молча.** Пользователь правит драфт — система создаёт/усиливает правило сама. Полный список правил доступен в Settings для контроля.
- **Минимальные изменения схемы.** Переиспользуем существующее поле `transactions.note` под мерчанта; `extraInfo` становится полем пользовательского комментария.
- **MVP-ориентированно.** Без iOS, без cloud-sync правил, без сложных универсальных банк-шаблонов — сначала Inbox-first строгий режим, потом расслабляемся.
## 2. Источники данных
| Источник | Статус |
|---|---|
| **Notification Listener Service** (push от банковских приложений) | ✅ единственный источник в MVP |
| SMS (READ_SMS) | ❌ отложено |
| Share Extension | ❌ отложено |
Notification listener фильтрует входящие по `packageName` — встроенный allow-list популярных банковских приложений (Tinkoff, Sber, Alfa, VTB, Ozon Bank, Pochta Bank, Raiffeisen, …) + возможность добавлять свои пакеты в Settings.
Из уведомления извлекаем: `packageName`, `title`, `body`, `receivedAt`.
> ⚠️ **Технический риск-фундамент.** Надёжность всей фичи держится на `NotificationListenerService`: OEM-киллеры (Xiaomi/Huawei/Samsung) убивают сервис, доступ слетает после ребута, нет хорошо поддерживаемого Flutter-плагина. Это требует platform channel + persistent/foreground service + перехват `BOOT_COMPLETED`. Перед Phase 1 — отдельный технический спайк (см. § 17, Phase 0).
## 3. Архитектура
Слои по существующей конвенции (`presentation → application → domain ← data`).
```
lib/src/features/notification_parsing/
domain/
entities/
raw_message.dart
parse_draft.dart
parse_rule.dart
rule_candidate.dart
account_binding.dart
bank_template.dart
repositories/
raw_messages_repository.dart
parse_rules_repository.dart
account_bindings_repository.dart
data/
drift/
tables/raw_messages_table.dart
tables/parse_rules_table.dart
tables/rule_candidates_table.dart
tables/account_bindings_table.dart
tables/transfer_pairing_blocklist_table.dart
daos/raw_messages_dao.dart
daos/parse_rules_dao.dart
daos/account_bindings_dao.dart
bank_templates/
bank_templates_catalog.dart # built-in regex шаблоны
parser/
regex_parser.dart # этап 1 pipeline
ai_parser.dart # этап 2 pipeline (OpenRouter)
confidence_scorer.dart # детерминированные правила
transfer_pairing.dart
notification/
notification_listener_service.dart # platform channel → Android
openrouter/
openrouter_client.dart
application/
notification_parsing_controller.dart # @riverpod, оркестрация pipeline
inbox_controller.dart
rules_controller.dart
presentation/
screens/
inbox_screen.dart
rules_list_screen.dart
rule_editor_screen.dart
parsing_settings_screen.dart
widgets/
inbox_card.dart
rule_card.dart
pair_suggestion_card.dart
confidence_badge.dart # «?» подсветка для слабых полей
```
## 4. Модель данных
### 4.1 Изменения в существующих таблицах
Файл: `lib/src/core/database/tables/transactions_table.dart`.
**`transactions`:**
- `note` → переименовать в `merchant` (string, nullable). UI продолжает показывать его в той же позиции, где раньше показывался note.
- **`extraInfo` → роль «пользовательский комментарий».** Поле уже существует (string, nullable, max 500). Отдельную колонку под комментарий не заводим — переиспользуем `extraInfo`. В UI редактора лейбл меняем на «Комментарий».
- `rawMessageId` (string, nullable) — FK на `raw_messages`. Источник, из которого появилась транзакция. Null для ручных.
- `autoApplied` (bool, default false) — отличает auto-applied от ручных.
> **Confidence НЕ хранится в `transactions`.** 5 per-field оценок живут на `raw_messages` (см. § 4.2), чтобы не раздувать горячую таблицу полями, нужными меньшинству строк. Этого достаточно для калибровки: она происходит при правке драфта, когда `raw_message` ещё доступен по `rawMessageId`.
>
> **Поля `fee` нет.** Принято допущение: при переводе приход равен расходу (см. § 10.3). Комиссию отдельным полем не моделируем.
### 4.2 Новые таблицы
**`raw_messages`** — сырое входящее сообщение, идемпотентность, переобработка и хранение confidence.
| Поле | Тип |
|---|---|
| id | String (uuid) |
| userId | String FK |
| packageName | String |
| title | String? |
| body | String |
| receivedAt | DateTime |
| dedupHash | String — `hash(packageName, body)`, для дедупликации (см. § 15) |
| status | Enum(`pending`, `parsing`, `parsed`, `parsed_partial`, `pending_ai`, `inbox`, `applied`, `ignored`, `failed`) |
| parseAttemptCount | int |
| lastParseError | String? |
| draftJson | String? — кешированный draft на случай переоткрытия Inbox |
| confidenceAmount | int? (0..100) |
| confidenceAccount | int? (0..100) |
| confidenceType | int? (0..100) |
| confidenceMerchant | int? (0..100) |
| confidenceCategory | int? (0..100) |
| transactionId | String? FK — куда привязано (если applied) |
| createdAt | DateTime |
**`parse_rules`** — все типы пользовательских правил.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| kind | Enum(`merchantToCategory`, `senderToAccount`, `ignore`) |
| matchMode | Enum(`contains`, `exact`, `regex`) |
| pattern | String |
| priority | int (0 — авто, можно поднять вручную) |
| weight | int — счётчик «насколько правило проверено» (см. § 9.1 lifecycle) |
| matchCount | int |
| lastMatchAt | DateTime? |
| **Поля действия** (nullable, зависят от kind): | |
| merchantCanonical | String? |
| categoryId | String? FK |
| accountId | String? FK |
| enabled | bool default true |
| createdAt | DateTime |
**`rule_candidates`** — связки, встреченные один раз; при второй такой же связке превращаются в `parse_rule`. Отдельная таблица (не in-memory), т.к. на мобильном lifecycle процесс часто убивают.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| kind | Enum(как в parse_rules) |
| rawValue | String — что встретили (merchant_raw / packageName+last4) |
| resolvedValue | String — во что разрешили (categoryId / accountId / merchantCanonical) |
| seenCount | int |
| firstSeenAt | DateTime |
| lastSeenAt | DateTime |
**`account_bindings`** — частный случай правил для счетов, отдельной таблицей для скорости лукапа.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| packageName | String? |
| bankKey | String? — нормализованный ключ банка из шаблонов |
| cardLast4 | String? |
| phone | String? |
| accountId | String FK |
| matchCount | int |
| createdAt | DateTime |
Уникальный индекс по `(userId, packageName, cardLast4)` для разрешения коллизий.
**`transfer_pairing_blocklist`** — пары, которые пользователь явно «разъединил» (см. § 10.4), чтобы не склеивать впредь.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| signature | String — нормализованная сигнатура пары (accountFrom+accountTo+amount-bucket или packageName-пара) |
| createdAt | DateTime |
**`bank_templates`** — built-in регулярки. Хранятся в коде, не в БД (см. § 6). В БД попадают только пользовательские шаблоны (Advanced settings) — для них при необходимости заводится отдельная таблица в этой же фиче.
### 4.3 Schema migration
`schemaVersion` сейчас 4 — поднимаем на следующее значение (конкретный номер не важен; ниже шаги для блока `if (from < N)` в `onUpgrade` в `lib/src/core/database/app_database.dart`):
1. `ALTER TABLE transactions RENAME COLUMN note TO merchant`
2. `ALTER TABLE transactions ADD COLUMN raw_message_id TEXT`
3. `ALTER TABLE transactions ADD COLUMN auto_applied INTEGER NOT NULL DEFAULT 0`
4. `CREATE TABLE raw_messages …` (включая 5 confidence-колонок)
5. `CREATE TABLE parse_rules …`
6. `CREATE TABLE rule_candidates …`
7. `CREATE TABLE account_bindings …`
8. `CREATE TABLE transfer_pairing_blocklist …`
> `extraInfo` уже существует — миграция его не трогает, меняется только лейбл в UI. Confidence-полей и `fee` в `transactions` не добавляем.
## 5. Pipeline парсинга
```
[Android] Notification posted
NotificationListenerService (Dart) ────► raw_messages (status=pending)
ParsingWorker (Riverpod stream over raw_messages.pending)
1. regex_parser.parse(body)
├ template matched ──► structured draft (no AI cost)
└ no template ──► continue
2. ai_parser.parse(body) # OpenRouter
├ network OK ──► AI draft
└ offline ──► raw_messages.status = pending_ai (retry by worker)
3. account_bindings.resolve(draft) → accountId
4. parse_rules.apply(draft) → merchant, category
5. transfer_pairing.tryPair(draft) → возможно перевод
6. confidence_scorer.score(draft) → 5 per-field scores → пишем в raw_messages
7. sanity_checks.cap(scores)
8. Decision gate (см. § 8.7):
├ min(scores) ≥ 85 AND grace period passed ──► auto-apply
├ 60 ≤ min < 85 ──► Inbox (highlight weak fields)
└ min < 60 ──► Inbox (review all)
```
Worker — Riverpod-stream notifier, слушает `raw_messages.watchPending()`, обрабатывает по одному. Идемпотентен: можно перезапускать парсинг сообщения, статус возвращается в `pending`, draft и confidence перезаписываются.
## 6. Bank templates (regex first)
Не привязываемся к конкретным банкам — пишем универсальные паттерны под распространённые форматы. Шаблоны хранятся в `bank_templates_catalog.dart` как список:
```dart
const BankTemplate(
key: 'generic_purchase_ru',
pattern: r'(?:Покупка|Оплата|Списание)\s+(\d+[\.,]?\d*)\s*(?:₽|руб|RUB).*?(?:Карта|карты)\s*\*?(\d{4})',
extract: {
'amount': r'\$1',
'cardLast4': r'\$2',
},
type: TxType.expense,
);
```
Стартовый набор покрывает:
- покупка/списание с карты с явной суммой и last4
- зачисление/перевод поступление
- перевод по СБП (получатель — телефон или ФИО)
- комиссия
- возврат
Если ни один паттерн не совпал → этап 2 (AI fallback). Это и есть «regex first»: для большинства SMS известных банков ИИ вообще не зовётся.
> Шаблоны и ключевые слова сейчас RU-only (форматы РФ-банков). Локализация шаблонов — за рамками MVP, помечено как задел.
**Свои шаблоны.** Settings → Advanced → «Шаблоны парсинга» — список с тумблерами «вкл/выкл» + кнопка «+ Свой шаблон». Пользователь может выключить неработающий шаблон или добавить свой.
## 7. AI fallback (OpenRouter)
Вызов делается только если regex не справился. Запрос:
```
POST https://openrouter.ai/api/v1/chat/completions
{
"model": <user's pick or default>,
"messages": [
{ "role": "system", "content": "<schema-instruction>" },
{ "role": "user", "content": "<body>" }
],
"response_format": { "type": "json_schema", "json_schema": {...} }
}
```
Структура ответа (строгий JSON Schema):
```json
{
"type": "expense | income | transfer | ignored",
"amount": 1240.00,
"currency": "RUB",
"cardLast4": "1234",
"merchantRaw": "WBSPB*MOSCOW",
"counterpartyName": null,
"counterpartyPhone": null,
"dateTime": "2026-05-28T18:32:00+03:00",
"kind": "purchase | refund | transfer_out | transfer_in | fee | balance | other"
}
```
ИИ **не** просит confidence — мы его не используем. Уверенность считаем сами в § 8.
> **Не все модели OpenRouter поддерживают `response_format: json_schema`.** Дешёвые модели часто его игнорируют. Поэтому `ai_parser`:
> 1. передаёт схему через `response_format`, **и** дублирует требование структуры в system-промпте;
> 2. при разборе ответа делает tolerant-parse (вытаскивает JSON из произвольного текста), валидирует по схеме;
> 3. при невалидном ответе → `status = parsed_partial` (в Inbox с сырым текстом), не падает.
**Конфигурация в Settings:**
- API key (flutter_secure_storage)
- Default model — выбор из списка (примеры дешёвых на момент написания: Gemini Flash, Haiku, GPT-4o mini; список подтягиваем динамически из OpenRouter)
- Daily token budget (опционально, default — нет лимита, в Advanced)
- Статус: последний успешный вызов / последняя ошибка
> **Privacy-consent (обязательно).** Тела банковских уведомлений уходят на сторонние модели (Google/OpenAI/…). До первого AI-вызова — явный экран согласия в onboarding (§ 12.6): «Текст уведомлений будет отправляться выбранной AI-модели для распознавания. Regex-режим работает без отправки данных наружу». Кнопка «Только regex» отключает AI полностью.
**Offline / API недоступен:** `raw_message.status = pending_ai`. Worker ретраит при появлении сети (используем `connectivity_plus` для слушателя). После 5 неудач — `status = failed`, попадает в Inbox с сырым текстом и кнопкой «попробовать снова».
## 8. Confidence scoring
Считаем 5 независимых per-field оценок (int 0..100), сохраняем в `raw_messages`. Gate решает по `min`. Везде шкала **0..100** (не доли).
### 8.1 Amount
| Условие | Score |
|---|---|
| regex template выдал единственную сумму | 100 |
| regex template, но в SMS ещё цифры — взяли первую после ключевого слова | 85 |
| regex template, несколько сумм одинакового веса | 70 |
| AI вытащил, в SMS есть однозначное число | 60 |
| AI вытащил, число в SMS неоднозначно | 40 |
| AI вытащил, в SMS вообще нет такой цифры (галлюцинация) | 10 |
### 8.2 Account
| Условие | Score |
|---|---|
| binding `(packageName, cardLast4) → accountId` совпал | 100 |
| binding `(bankKey, cardLast4)` совпал | 90 |
| packageName совпал, у юзера один счёт для этого банка | 75 |
| packageName совпал, несколько счетов, выбран по эвристике (последний использованный) | 45 |
| binding не нашёлся | 15 |
### 8.3 Type
| Условие | Score |
|---|---|
| bank template явно указал тип | 100 |
| AI выдал тип, в SMS однозначные ключевые слова | 90 |
| AI выдал тип по знаку суммы / контексту | 70 |
| AI угадал | 45 |
| Transfer pairing завершён успешно с high-conf обеих сторон | 95 |
### 8.4 Merchant
| Условие | Score |
|---|---|
| Сработало `parse_rule` (merchantToCategory) с weight≥3 | 100 |
| `merchant_raw` совпал с правилом ровно (weight=2) | 85 |
| `merchant_raw` встречался ≥3 раза в истории | 80 |
| `merchant_raw` встречался 1–2 раза | 60 |
| Новый, прошёл sanity-check | 40 |
| Новый, не прошёл sanity-check | 15 |
### 8.5 Category
| Условие | Score |
|---|---|
| `parse_rule` явно мэппит merchant → category | 100 |
| Тот же merchant_raw → та же category, подтверждено ≥3 раза | 85 |
| Подтверждено 1–2 раза | 65 |
| Новый merchant, AI назвал «очевидную» категорию (Пятёрочка → Продукты) | 45 |
| Новый merchant + короткое имя + AI угадал | 25 |
И жёсткое правило: `category ≤ merchant`. Категория не может быть увереннее, чем мерчант.
### 8.6 Sanity checks (capping rules)
Выполняются после base-scoring, **обнуляют** уверенность независимо от того, что сказал ИИ:
```
if len(merchant_raw) < 5: cap merchant ≤ 30
if /^[\d\s\*\+\-]+$/.match(merchant_raw): cap merchant ≤ 20
if amount.currency == null: cap amount ≤ 40
if amount > 100 × median user spend (90d): cap amount ≤ 50
if body.length < 20: cap all ≤ 50
if body contains "баланс" but not transaction
keywords: → status=ignored
if merchant is new AND category was guessed: cap category ≤ 50
```
### 8.7 Gate
```dart
final score = [amount, account, type, merchant, category].reduce(min); // int 0..100
final inGracePeriod = !graceCompleted; // см. § 11
if (score >= 85 && !inGracePeriod) autoApply();
else if (score >= 60) toInbox(weakFields: ...);
else toInbox(reviewAll: true);
```
Порог `85` — дефолт. Слайдер «строгость» в Advanced: мягко 75 / нормально 85 / строго 95.
### 8.8 Калибровка
Каждое исправление auto-applied транзакции логируется (источник — `raw_messages` по `rawMessageId`, confidence уже там):
```
applied_score_min, field_corrected, merchant_raw, was_correct_per_field
```
В Settings → Advanced → «Точность» показываем по диапазонам:
```
95100: N transactions, X% ошибок ✓
8595: N transactions, X% ошибок ⚠
```
Если в диапазоне 85–95 ошибок > 10% — автоматически поднимаем порог auto-apply до 90 (показываем пользователю баннер «строгость повышена из-за неточностей»).
## 9. Правила (rules)
### 9.1 Молчаливое обучение
При каждом подтверждении/правке драфта (в Inbox или из ленты):
```
on user confirms draft:
for each (raw_value, resolved_value) in [(merchant_raw, merchant), (merchant, category), (packageName+last4, accountId)]:
rule = findExistingRule(raw_value)
if rule:
rule.weight += 1
rule.matchCount += 1
rule.lastMatchAt = now()
elif candidate = findCandidate(raw_value) with same resolved_value:
createRule(raw_value, resolved_value, weight=2) // вторая встреча → правило
deleteCandidate(candidate)
else:
upsertCandidate(raw_value, resolved_value) // первая встреча — кандидат в таблице rule_candidates
```
**Lifecycle `weight` (явно):**
- `weight >= 2` — правило **активно**, применяется.
- `weight == 1` — правило **спящее** (не применяется, но хранится; например после одной правки понизилось 2→1).
- `weight <= 0` — правило **удаляется**.
При исправлении (пользователь дал другой mapping) — `rule.weight -= 1`.
### 9.2 Экран Settings → Правила парсинга
Список с фильтр-чипами: `[Все] [Мерчанты] [Счета] [Игнор]`.
Карточка правила:
```
🛒 WBSPB ⚙ regex
→ Wildberries · Покупки
23 совпадения · вчера
```
Свайп влево — выключить, тап — редактор.
### 9.3 Редактор правила (progressive disclosure)
Один экран для всех `matchMode`. По умолчанию `contains`.
```
Если SMS содержит [_____________]
○ Содержит ○ Точно ○ Regex ← переключатель
То это:
Мерчант [ Wildberries ▾ ]
Категория [ Покупки · 🛒 ▾ ]
Счёт [ — не менять — ▾ ]
▼ Дополнительно
Приоритет: [auto / +1 / +2]
Только от: [packageName ▾ ]
── Совпадает с (последние 30):
1240₽ WBSPB*MOSCOW 18 мая
890₽ WBSPB SPB 12 мая
```
Live-превью: подгружаем `raw_messages` за последние 30 дней и показываем матчи. Снижает риск сломанной regex.
### 9.4 Приоритет при конфликте
Если на одно сообщение подошли несколько правил:
1. Более специфичное (длиннее `pattern`).
2. С большим `matchCount`.
3. С более высоким `priority` (если пользователь поднял).
Никакого «merge actions» — побеждает одно правило целиком.
### 9.5 Откат правила из транзакции
В деталях транзакции, если она applied правилом: кнопка «Это правило сработало неправильно» → понижает `weight` правила (см. lifecycle § 9.1), удаляет если ≤ 0.
## 10. Переводы между своими счетами
Модель — **одна запись** `transaction(type=transfer, transferToAccountId=...)` (вариант из CLAUDE.md).
> **Состояние `month_summary` уже корректно.** В [features/home/presentation/month_summary.dart](../lib/src/features/home/presentation/month_summary.dart) переводы для конкретного счёта уже учитываются (исходящий счёт `expense += amount`, входящий `income += amount`). `case transfer: break;` остался **только** в ветке «Все счета» — и это намеренно: внутренние перемещения не должны влиять на общий итог. Поэтому отдельной правки агрегации **не требуется** (пункт «What's left #5» из CLAUDE.md фактически закрыт). От фичи переводов нужно лишь:
- **Pairing → одна запись.** При склейке двух SMS создаём одну транзакцию `type=transfer` + линкуем **оба** `raw_messages.transactionId` к ней.
### 10.1 Алгоритм детектирования
Когда парсер выдал draft `expense` или `income` И счёт распознан как мой:
```
1. Если в SMS извлечён получатель/отправитель И binding указывает на мой счёт:
→ это перевод с явным указанием контрагента
→ ищем зеркало (incoming с тем же amount) за ±10 минут
2. Иначе — fallback по эвристике:
→ ищем противоположный draft за ±10 минут
→ оба счёта — мои (account_bindings → accountId)
3. Found?
high confidence (явное указание) → склейка, status=applied
low confidence (только эвристика) → обе строки в Inbox с плашкой «Объединить?»
not found → см. § 10.1a про auto-apply
```
**Суммы.** Принято допущение: при переводе **приход равен расходу** (комиссию отдельно не моделируем, поля `fee` нет). Поэтому совпадение ищем по **точному** равенству `amount`; tolerance не нужен.
Окно (±10 мин) — константа в `transfer_pairing.dart`, можно вынести в Advanced.
### 10.1a Auto-apply одиночного расхода (решение по задержке)
Чтобы не ломать «мгновенность» ленты, задержку на ожидание pairing включаем **только при подозрении на перевод**:
```
draft = expense/income, счёт — мой
если ЕСТЬ признак перевода (binding контрагента / извлечён получатель-свой-счёт):
→ ждём окно pairing (±10 мин), retry, потом завершаем как expense/income
иначе (обычная покупка/оплата, контрагент — внешний):
→ НЕ ждём, gate решает сразу (auto-apply / Inbox)
```
Итог: обычный расход попадает в ленту мгновенно; задержку платит только то, что реально похоже на внутренний перевод.
### 10.2 Manual transfer + SMS
Когда пользователь создал перевод в app, и через минуту приходит SMS от банка:
При обработке нового `raw_message`:
1. Распарсили draft с amount/account.
2. Проверяем `transactions WHERE userId=... AND amount = draft.amount AND createdAt > now()-10min AND rawMessageId IS NULL`.
3. Нашли → линкуем `raw_message.transactionId`, не создаём новую запись.
4. Не нашли — обычный поток.
### 10.3 Расхождение сумм
Не моделируется. Принято допущение: **приход = расход** (см. § 10.1). Поля `fee` нет; если в реальности суммы разойдутся (комиссия) — точное совпадение не сработает, и обе стороны просто останутся отдельными записями (expense + income), что приемлемо для MVP.
### 10.4 «Разъединить»
В деталях transfer-транзакции — список двух raw_messages + кнопка «Разъединить». Создаёт две независимые транзакции expense + income, и пишет сигнатуру пары в `transfer_pairing_blocklist`, чтобы не склеивать впредь.
## 11. Cold start (первые 7 дней)
**Strict mode:** `autoApplyEnabled = false`. Всё распарсенное идёт в Inbox, пользователь подтверждает каждое.
Включение auto-apply, когда выполнено **любое** из:
- прошло 7 дней с первого распарсенного сообщения, ИЛИ
- ≥ 30 подтверждённых драфтов (пользователь прошёл достаточно для обучения правил).
При включении показываем баннер: «Автодобавление активировано — теперь уверенные транзакции попадают в ленту автоматически». В Settings — toggle, чтобы пользователь мог сам ускорить или, наоборот, отключить.
## 12. UI экраны
### 12.1 Бэдж на Home
```
┌──────────────────────────────┐
│ Май 2026 ✉3 ⚙ │ ← ✉ появляется при count > 0
└──────────────────────────────┘
```
Тап на ✉ → InboxScreen. Источник count — `inboxControllerProvider`, считает `raw_messages WHERE status='inbox'`.
### 12.2 Inbox screen
Компактные карточки. Свайп вправо — подтвердить, влево — игнорировать, тап — редактор транзакции.
```
┌──────────────────────────────┐
│ Inbox (3) ⋮ │
├──────────────────────────────┤
│ −1 240 ₽ Пятёрочка │
│ Продукты · Тинькофф Black │
│ 18 мая, 14:32 │
├──────────────────────────────┤
3 500 ₽ WBSPB │
│ ? Покупки · Тинькофф Black │ ← «?» = score < 70 на этом поле
│ ▾ показать SMS │
├──────────────────────────────┤
│ ╭ Похоже на перевод ────╮ │
│ │ −10000 Тинькофф │ │
│ │ +10000 Сбер │ │
│ │ [Объединить] [Нет] │ │
│ ╰───────────────────────╯ │
└──────────────────────────────┘
```
> Подсветка слабых полей («?») читает confidence из `raw_messages`. Базовый scoring нужен уже на этом экране — см. порядок фаз в § 17 (scoring переносим в Phase 1, а не Phase 2).
«⋮» в AppBar → «Показать игнорированные», «Архив raw_messages».
### 12.3 Rules list screen
```
┌──────────────────────────────────┐
│ ← Правила парсинга + ⋮ │
├──────────────────────────────────┤
│ [Все 14] [Мерчанты 9] [Счета 4] │
│ [Игнор 1] │
├──────────────────────────────────┤
│ 🛒 WBSPB │
│ → Wildberries · Покупки │
│ 23 совпадения · вчера │
├──────────────────────────────────┤
│ 💳 Тинькофф · *1234 │
│ → счёт «Тинькофф Black» │
│ 108 · сегодня │
└──────────────────────────────────┘
```
### 12.4 Rule editor screen
Как в § 9.3.
### 12.5 Parsing settings screen
```
Парсинг уведомлений
─────────────────────────
[●] Включить ──○
[ ] Доступ к уведомлениям Разрешено
[ ] OpenRouter API key ••••••••
[ ] Default model Gemini Flash ▾
[ ] Строгость auto-apply Нормально ▾
▼ Дополнительно
Шаблоны парсинга (12) →
Точность →
Token usage today: 0 / unlimited
Cold start: завершён 23 мая
```
### 12.6 Onboarding для фичи
Один экран:
1. Объяснение «что это»
2. Кнопка «Разрешить доступ к уведомлениям» (→ системный диалог `BIND_NOTIFICATION_LISTENER_SERVICE`)
3. **Privacy-consent для AI** + поле «OpenRouter API key» (с кнопкой «Пропустить — только regex»). Явный текст: данные уведомлений уходят выбранной модели.
4. Notice «Первые 7 дней всё попадает в Inbox»
Можно открыть из главных Settings или поверх Home при первом запуске после обновления.
## 13. Permissions
- **`BIND_NOTIFICATION_LISTENER_SERVICE`** — основная (через системные настройки, не runtime).
- **`POST_NOTIFICATIONS`** — не нужна (системных уведомлений не шлём, только бэдж в приложении).
- **`INTERNET`** — обычная.
- **`FOREGROUND_SERVICE`** — если придётся держать listener живым (зависит от реализации, см. § 15).
Если доступ к notification listener отозван — Settings показывает баннер «Доступ потерян, парсинг отключён» с кнопкой «открыть настройки Android».
## 14. Privacy и стоимость
- API key хранится в `flutter_secure_storage`. Не пишется в логи.
- **Явное согласие** на отправку данных в AI до первого вызова (§ 7, § 12.6). Без согласия — regex-only.
- Regex first → большая часть SMS вообще не уходит наружу.
- В Settings → Advanced → «Token usage» показываем расход за день/месяц (приходит в response `usage` от OpenRouter).
- Опциональный daily-limit. При превышении — fallback в regex-only до конца дня.
- Список «отправленных в ИИ сообщений» (по requestId) можно показать в Advanced для аудита.
## 15. Краевые случаи и риски
| Кейс | Решение |
|---|---|
| Уведомление обновляется (банк меняет «обрабатывается» → «выполнено») | Использовать `Notification.tag` / `id`; обновлять существующий `raw_message`, не создавать новый |
| Дубль уведомлений (push приходит и снова при перезагрузке) | Идемпотентность: `dedupHash = hash(packageName, body)` (точный хэш, без времени) + проверка по временно́му окну запросом: при вставке ищем `raw_messages WHERE dedupHash=? AND receivedAt within ±N сек`; найдено → upsert, иначе insert |
| Notification listener убит Android (низкий приоритет) | Foreground service или `WorkManager` с периодической проверкой; ловим перезапуск через `BOOT_COMPLETED` (см. Phase 0 спайк) |
| Push без полного текста («У вас новая операция») | Status `parsed_partial` → Inbox с пометкой «недостаточно данных», пользователь дополняет вручную |
| Push не от банка, но packageName в allowlist (баланс / маркетинг) | Sanity-check на ключевые слова; промо-тексты → `status=ignored`, регулярки blocklist |
| Маркетинг / рекламные пуши банка | Authoring: правила kind=ignore. Молчаливое обучение: после 2 «игнор» от того же пакета с похожим телом — создаётся правило ignore |
| Несколько активных юзеров (multi-user) | Все таблицы scope'нуты по `userId`. Listener привязан к active user (из `app_preferences.active_user_id`) |
| Очень крупная сумма (защита от галлюцинации ИИ) | Sanity check § 8.6 + дополнительно: amount > 100k ₽ → всегда Inbox независимо от score |
## 16. Изменения существующего кода
- **`features/home/presentation/screens/home_screen.dart`** — добавить badge `✉N` в AppBar; провайдер `inboxPendingCountProvider`.
- **`features/home/presentation/month_summary.dart`** — правок по агрегации переводов **не требуется** (см. § 10); трогаем только если меняется модель.
- **`features/transactions/`** — поле `note``merchant` в форме и UI; лейбл `extraInfo` → «Комментарий»; `MoneyText` остаётся как есть.
- **`core/database/tables/transactions_table.dart`** — переименование колонки `note``merchant` + новые поля `raw_message_id`, `auto_applied`.
- **`core/database/app_database.dart`** — поднять `schemaVersion`, добавить блок миграции (§ 4.3).
- **`app/router/app_routes.dart` + `app_router.dart`** — новые routes: `/inbox`, `/settings/parsing`, `/settings/parsing/rules`, `/settings/parsing/rules/:id`.
- **`features/profile/`** — добавить вход в «Настройки парсинга».
- **`l10n/app_*.arb`** — все строки фичи (Inbox, правила, settings).
## 17. Этапы (MVP → расширение)
**Phase 0 — Технический спайк (до всего остального).**
- Notification listener на Android: platform channel, persistent/foreground service, поведение после ребута и под OEM-киллерами. Проверить на реальных устройствах. Это самый рискованный кусок — без него фича не работает.
**Phase 1 — Capture & manual review (без ИИ).**
- Notification listener + raw_messages (идемпотентность по dedupHash)
- Bank templates (regex) — минимальный набор
- **Confidence scoring + sanity checks** (перенесено сюда: Inbox показывает «?» по слабым полям)
- Inbox screen с свайпами
- Confirm/edit → создание transaction
- Schema migration (note→merchant, extraInfo как комментарий, новые поля/таблицы)
**Phase 2 — Rules.**
- parse_rules + rule_candidates + молчаливое обучение
- account_bindings
- Rules list/editor screens
- Cold start (strict 7 дней)
**Phase 3 — AI fallback.**
- OpenRouter client + API key + privacy-consent onboarding
- ai_parser в pipeline (с tolerant-parse fallback для моделей без json_schema)
- Token usage UI
- Offline queue + retry
**Phase 4 — Transfers.**
- transfer_pairing (точное совпадение сумм, задержка только при подозрении)
- «Объединить» plate в Inbox
- «Разъединить» в деталях + transfer_pairing_blocklist
**Phase 5 — Калибровка.**
- Логирование исправлений
- Stats screen
- Авто-подстройка порога
## 18. Открытые вопросы (отложены)
- **iOS-поддержка** — out of scope MVP.
- **Cloud-sync правил** между устройствами — после введения backend / sync layer.
- **Конвертация валют в переводах** (RUB → USD account) — пока выводим в Inbox с двумя суммами вручную.
- **Комиссия при переводе** — сейчас допущение «приход = расход»; моделирование разницы отложено (поля `fee` нет).
- **Локализация regex-шаблонов** — сейчас RU-only.
- **Multi-account heuristics для СБП между своими счетами** — telephone-based binding покрывает основной кейс.
- **Глубокая интеграция с категориями (auto-creation новых)** — пока используем существующие категории пользователя; «новая категория» = ручное действие в редакторе.
- **Удалить демо-транзакции из `UserSeeder`** — уже значится в CLAUDE.md, но связано с этой фичей: после внедрения парсинга seed-демки больше не нужен.
## 19. Решения, зафиксированные в этой спеке
| Развилка | Решение |
|---|---|
| Источник данных | Notification Listener (Android) |
| Хранение перевода | Одна запись `transferToAccountId` |
| Сущность мерчанта | Строка в `transactions.merchant` (rename из `note`), без отдельной таблицы |
| Пользовательский комментарий | Переиспользуем существующее `transactions.extraInfo` (лейбл «Комментарий») |
| Хранение confidence | На `raw_messages` (5 int-колонок), НЕ в `transactions` |
| Комиссия при переводе | Не моделируем; допущение «приход = расход», поля `fee` нет |
| Pairing-задержка auto-apply | Ждём окно только при подозрении на перевод; обычный расход — мгновенно |
| Privacy | Regex first, AI fallback только когда regex не справился + явный consent |
| Offline | Очередь + auto-retry при восстановлении сети |
| Бэдж/пуши | Только бэдж в приложении, никаких системных push |
| Cold start | Strict 7 дней / 30 подтверждений |
| Bank templates | Универсальные паттерны, не привязаны к конкретным банкам |
| Confidence | Per-field (0..100), gate по `min`, sanity-checks обнуляют враньё ИИ |
| Rule learning | Молчаливое + видимый список; кандидаты в таблице; weight≥2 активно, ≤0 удаляется |