Switch AI notification parsing from OpenRouter to DeepSeek API

Replace the OpenRouter HTTP client with a DeepSeek client:
- data/openrouter/ -> data/deepseek/; OpenRouterClient -> DeepSeekClient
  (+ DeepSeekCompletion / DeepSeek*Exception)
- base URL api.deepseek.com, drop OpenRouter-only headers
- DeepSeek has no strict json_schema: use response_format json_object
  and rely on the prompt for shape (ai_schema.dart removed)
- secure-storage key openrouter_api_key -> deepseek_api_key
- default model deepseek/deepseek-v4-flash -> deepseek-chat
- l10n strings, test dart-defines (DEEPSEEK_API_KEY/_TEST_MODEL), docs

Also bundles in-progress findBodyIgnoreRule (body-level ignore rules
applied before the AI call) already present in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:23:38 +03:00
co-authored by Claude Opus 4.8
parent 76f301b758
commit ab66aebc5e
21 changed files with 230 additions and 147 deletions
+2 -2
View File
@@ -79,10 +79,10 @@ lib/
state/selected_category_filter.dart # account/category filter providers (+ kAllAccountsId)
month_summary.dart # client-side aggregates for KPI / donut
analytics/ # analytics_screen + habit_analysis (providers/screen, /analytics/habits)
notification_parsing/ # SMS/notification → transaction parsing (rules + AI/OpenRouter).
notification_parsing/ # SMS/notification → transaction parsing (rules + AI/DeepSeek).
# domain/data/application/presentation; Drift tables + DAOs;
# data/parser/ (dedup, confidence, decision_gate, ai_parser),
# data/openrouter/ (client, prompts, schema), ParsingWorker.
# data/deepseek/ (client, prompts), ParsingWorker.
# Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
profile/ # theme switcher screen
shared/
+1 -1
View File
@@ -1,7 +1,7 @@
# Registers custom test tags so `flutter test --tags integration` does not warn.
#
# The `integration` tag marks tests that hit real external services (e.g. the
# OpenRouter API) and consume API tokens. They are excluded from a default
# DeepSeek API) and consume API tokens. They are excluded from a default
# `flutter test` run and are opt-in via `--tags integration`.
tags:
integration:
+4 -4
View File
@@ -220,7 +220,7 @@
"parsingRulesTile": "Parsing rules",
"parsingRulesCreatedCount": "Rules created: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)",
"parsingAiSectionTitle": "AI (DeepSeek)",
"parsingAiComingSoon": "AI recognition is coming in a future version.",
"parsingNotifAccessTitle": "Notification access",
"parsingNotifAccessGranted": "Granted — notifications are being read",
@@ -347,9 +347,9 @@
"aiConsentKeyMissing": "API Key is missing",
"aiConsentSaved": "Settings saved",
"aiConsentTitle": "AI Parsing",
"aiConsentBody": "We use OpenRouter to parse notifications with AI. Your data is sent to the selected model. You need your own API key. Tokens are used.",
"aiConsentKeyLabel": "OpenRouter API Key",
"aiConsentKeyHint": "sk-or-v1-...",
"aiConsentBody": "We use the DeepSeek API to parse notifications with AI. Your data is sent to the selected model. You need your own API key. Tokens are used.",
"aiConsentKeyLabel": "DeepSeek API Key",
"aiConsentKeyHint": "sk-...",
"aiConsentModelLabel": "AI Model",
"aiConsentAllow": "Allow AI Parsing",
"aiConsentRegexOnly": "Use Regex Only (Local)",
+4 -4
View File
@@ -983,7 +983,7 @@ abstract class AppLocalizations {
/// No description provided for @parsingAiSectionTitle.
///
/// In ru, this message translates to:
/// **'AI (OpenRouter)'**
/// **'AI (DeepSeek)'**
String get parsingAiSectionTitle;
/// No description provided for @parsingAiComingSoon.
@@ -1673,19 +1673,19 @@ abstract class AppLocalizations {
/// No description provided for @aiConsentBody.
///
/// In ru, this message translates to:
/// **'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'**
/// **'Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'**
String get aiConsentBody;
/// No description provided for @aiConsentKeyLabel.
///
/// In ru, this message translates to:
/// **'API-ключ OpenRouter'**
/// **'API-ключ DeepSeek'**
String get aiConsentKeyLabel;
/// No description provided for @aiConsentKeyHint.
///
/// In ru, this message translates to:
/// **'sk-or-v1-...'**
/// **'sk-...'**
String get aiConsentKeyHint;
/// No description provided for @aiConsentModelLabel.
+4 -4
View File
@@ -512,7 +512,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get parsingAiSectionTitle => 'AI (OpenRouter)';
String get parsingAiSectionTitle => 'AI (DeepSeek)';
@override
String get parsingAiComingSoon =>
@@ -878,13 +878,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aiConsentBody =>
'We use OpenRouter to parse notifications with AI. Your data is sent to the selected model. You need your own API key. Tokens are used.';
'We use the DeepSeek API to parse notifications with AI. Your data is sent to the selected model. You need your own API key. Tokens are used.';
@override
String get aiConsentKeyLabel => 'OpenRouter API Key';
String get aiConsentKeyLabel => 'DeepSeek API Key';
@override
String get aiConsentKeyHint => 'sk-or-v1-...';
String get aiConsentKeyHint => 'sk-...';
@override
String get aiConsentModelLabel => 'AI Model';
+4 -4
View File
@@ -523,7 +523,7 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get parsingAiSectionTitle => 'AI (OpenRouter)';
String get parsingAiSectionTitle => 'AI (DeepSeek)';
@override
String get parsingAiComingSoon =>
@@ -890,13 +890,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aiConsentBody =>
'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.';
'Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.';
@override
String get aiConsentKeyLabel => 'API-ключ OpenRouter';
String get aiConsentKeyLabel => 'API-ключ DeepSeek';
@override
String get aiConsentKeyHint => 'sk-or-v1-...';
String get aiConsentKeyHint => 'sk-...';
@override
String get aiConsentModelLabel => 'Модель ИИ';
+4 -4
View File
@@ -220,7 +220,7 @@
"parsingRulesTile": "Правила парсинга",
"parsingRulesCreatedCount": "Правил создано: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)",
"parsingAiSectionTitle": "AI (DeepSeek)",
"parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.",
"parsingNotifAccessTitle": "Доступ к уведомлениям",
"parsingNotifAccessGranted": "Выдан — уведомления читаются",
@@ -347,9 +347,9 @@
"aiConsentKeyMissing": "API-ключ не указан",
"aiConsentSaved": "Настройки сохранены",
"aiConsentTitle": "Парсинг с ИИ",
"aiConsentBody": "Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.",
"aiConsentKeyLabel": "API-ключ OpenRouter",
"aiConsentKeyHint": "sk-or-v1-...",
"aiConsentBody": "Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.",
"aiConsentKeyLabel": "API-ключ DeepSeek",
"aiConsentKeyHint": "sk-...",
"aiConsentModelLabel": "Модель ИИ",
"aiConsentAllow": "Разрешить ИИ-парсинг",
"aiConsentRegexOnly": "Только Regex (Локально)",
@@ -3,13 +3,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/openrouter/openrouter_client.dart';
import '../data/deepseek/deepseek_client.dart';
import '../data/parser/ai_parser.dart';
import '../data/secure/ai_key_store.dart';
part 'ai_providers.g.dart';
/// Защищённое хранилище OpenRouter API key.
/// Защищённое хранилище DeepSeek API key.
@Riverpod(keepAlive: true)
AiKeyStore aiKeyStore(Ref ref) =>
const AiKeyStore(FlutterSecureStorage());
@@ -28,7 +28,7 @@ http.Client httpClient(Ref ref) {
Future<AiParser?> aiParser(Ref ref) async {
final key = await ref.watch(aiKeyStoreProvider).getApiKey();
if (key == null || key.isEmpty) return null;
final client = OpenRouterClient(
final client = DeepSeekClient(
client: ref.watch(httpClientProvider),
apiKey: key,
);
@@ -4,7 +4,7 @@ import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transactions_controller.dart';
import '../data/openrouter/openrouter_client.dart';
import '../data/deepseek/deepseek_client.dart';
import '../data/parser/account_resolver.dart';
import '../data/parser/ai_parser.dart';
import '../data/parser/confidence_scorer.dart';
@@ -62,6 +62,19 @@ class ParsingPipeline {
return;
}
// Ignore-правила по телу (contains/regex) — ДО AI, чтобы не тратить токены
// на пуши, которые пользователь явно просил пропускать («Доставлен заказ»).
// `exact`-правила матчат имя мерчанта, доступное только после AI, — их
// проверяет findIgnoreRule в _runPipeline.
final bodyRules =
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
if (findBodyIgnoreRule(bodyRules, msg.body) != null) {
await _ref
.read(rawMessagesRepositoryProvider)
.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings);
}
@@ -150,13 +163,13 @@ class ParsingPipeline {
await _runPipeline(userId, msg, outcome.draft!, settings,
categories: categories);
}
} on OpenRouterNetworkException {
} on DeepSeekNetworkException {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.pendingAi,
lastParseError: 'network',
);
} on OpenRouterAuthException catch (e) {
} on DeepSeekAuthException catch (e) {
// Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на
// каждом последующем сообщении. Пользователь введёт корректный ключ
// заново на экране согласия (это снова включит AI).
@@ -166,7 +179,7 @@ class ParsingPipeline {
status: RawMessageStatus.failed,
lastParseError: 'AI auth failed (${e.statusCode})',
);
} on OpenRouterException catch (e) {
} on DeepSeekException catch (e) {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.failed,
@@ -28,7 +28,7 @@ class ParsingSettings {
/// Без него AI не вызывается — работает только regex.
final bool aiConsentGiven;
/// Модель OpenRouter по умолчанию.
/// Модель DeepSeek по умолчанию.
final String aiModel;
/// Дневной лимит токенов (null = без лимита). При исчерпании — regex-only.
@@ -68,7 +68,7 @@ const _kAiConsent = 'ai_consent';
const _kAiModel = 'ai_model';
const _kAiDailyLimit = 'ai_daily_token_limit';
const kDefaultAiModel = 'deepseek/deepseek-v4-flash';
const kDefaultAiModel = 'deepseek-chat';
const _defaultSettings = ParsingSettings(
enabled: true,
@@ -1,13 +1,13 @@
/// Промпты для AI-разбора уведомлений (§7).
///
/// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко
/// правился без касания логики клиента/парсера. JSON-схема — рядом в
/// [ai_schema.dart]; здесь только текстовые инструкции.
/// правился без касания логики клиента/парсера. DeepSeek работает в JSON-режиме
/// (`response_format: {type: json_object}`) без строгой схемы, поэтому структуру
/// ответа полностью диктует текст ниже, а сам ответ разбирается tolerant-парсером.
library;
/// System-промпт. Требование структуры **дублируется текстом** (помимо
/// `response_format: json_schema`), т.к. дешёвые модели OpenRouter часто
/// игнорируют schema (§7).
/// System-промпт. Требование структуры задаётся текстом; слово «JSON» в промпте
/// обязательно — без него DeepSeek отклоняет запрос в JSON-режиме.
///
/// [categoryNames] — существующие категории пользователя; модель должна
/// выбирать `categorySuggestion` из них (или вернуть null), а не выдумывать.
@@ -4,9 +4,14 @@ import 'dart:io';
import 'package:http/http.dart' as http;
/// HTTP-клиент OpenRouter (§7). `client` инжектируется для тестируемости.
class OpenRouterClient {
OpenRouterClient({
/// HTTP-клиент DeepSeek API (§7). `client` инжектируется для тестируемости.
///
/// DeepSeek совместим с OpenAI-форматом chat/completions, поэтому тело запроса
/// и разбор ответа те же. Отличия: базовый URL, отсутствие OpenRouter-заголовков
/// и JSON-режим через `response_format: {type: json_object}` (DeepSeek не
/// поддерживает строгий `json_schema`) — структуру диктует текст промпта.
class DeepSeekClient {
DeepSeekClient({
required this.client,
required this.apiKey,
this.timeout = const Duration(seconds: 20),
@@ -16,18 +21,21 @@ class OpenRouterClient {
final String apiKey;
final Duration timeout;
static const _base = 'https://openrouter.ai/api/v1';
static const _base = 'https://api.deepseek.com';
/// Один запрос chat/completions. Возвращает текст ответа модели + usage.
///
/// Бросает [OpenRouterAuthException] на 401/403, [OpenRouterNetworkException]
/// [jsonObject] включает JSON-режим DeepSeek (модель обязана вернуть валидный
/// JSON-объект; для этого в промпте должно встречаться слово «json»).
///
/// Бросает [DeepSeekAuthException] на 401/403, [DeepSeekNetworkException]
/// при отсутствии сети/таймауте (воркер трактует как offline → pending_ai),
/// [OpenRouterApiException] на прочих не-2xx.
Future<OpenRouterCompletion> chatCompletion({
/// [DeepSeekApiException] на прочих не-2xx.
Future<DeepSeekCompletion> chatCompletion({
required String model,
required String systemPrompt,
required String userContent,
Map<String, dynamic>? jsonSchema,
bool jsonObject = false,
}) async {
final body = <String, dynamic>{
'model': model,
@@ -35,11 +43,7 @@ class OpenRouterClient {
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userContent},
],
if (jsonSchema != null)
'response_format': {
'type': 'json_schema',
'json_schema': jsonSchema,
},
if (jsonObject) 'response_format': {'type': 'json_object'},
};
final http.Response res;
@@ -52,22 +56,22 @@ class OpenRouterClient {
)
.timeout(timeout);
} on SocketException catch (e) {
throw OpenRouterNetworkException(e.message);
throw DeepSeekNetworkException(e.message);
} on TimeoutException {
throw OpenRouterNetworkException('timeout');
throw DeepSeekNetworkException('timeout');
} on http.ClientException catch (e) {
throw OpenRouterNetworkException(e.message);
throw DeepSeekNetworkException(e.message);
}
if (res.statusCode == 401 || res.statusCode == 403) {
throw OpenRouterAuthException(res.statusCode, res.body);
throw DeepSeekAuthException(res.statusCode, res.body);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
throw OpenRouterApiException(res.statusCode, res.body);
throw DeepSeekApiException(res.statusCode, res.body);
}
final map = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
return OpenRouterCompletion.fromJson(map);
return DeepSeekCompletion.fromJson(map);
}
/// Список доступных моделей — для дропдауна в настройках.
@@ -94,15 +98,12 @@ class OpenRouterClient {
Map<String, String> get _headers => {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
// OpenRouter рекомендует указывать источник; необязательно.
'HTTP-Referer': 'https://newbudget.app',
'X-Title': 'NewBudget',
};
}
/// Результат успешного вызова: контент модели + расход токенов.
class OpenRouterCompletion {
const OpenRouterCompletion({
class DeepSeekCompletion {
const DeepSeekCompletion({
required this.content,
required this.promptTokens,
required this.completionTokens,
@@ -114,7 +115,7 @@ class OpenRouterCompletion {
final int completionTokens;
final int totalTokens;
factory OpenRouterCompletion.fromJson(Map<String, dynamic> json) {
factory DeepSeekCompletion.fromJson(Map<String, dynamic> json) {
final choices = json['choices'];
var content = '';
if (choices is List && choices.isNotEmpty) {
@@ -125,7 +126,7 @@ class OpenRouterCompletion {
}
final usage = json['usage'] as Map<String, dynamic>?;
int u(String k) => (usage?[k] as num?)?.toInt() ?? 0;
return OpenRouterCompletion(
return DeepSeekCompletion(
content: content,
promptTokens: u('prompt_tokens'),
completionTokens: u('completion_tokens'),
@@ -134,27 +135,27 @@ class OpenRouterCompletion {
}
}
/// Базовое исключение клиента OpenRouter.
sealed class OpenRouterException implements Exception {
const OpenRouterException(this.message);
/// Базовое исключение клиента DeepSeek.
sealed class DeepSeekException implements Exception {
const DeepSeekException(this.message);
final String message;
@override
String toString() => '$runtimeType: $message';
}
/// Сеть недоступна / таймаут — трактуется воркером как offline (pending_ai).
class OpenRouterNetworkException extends OpenRouterException {
const OpenRouterNetworkException(super.message);
class DeepSeekNetworkException extends DeepSeekException {
const DeepSeekNetworkException(super.message);
}
/// Неверный/просроченный API key (401/403).
class OpenRouterAuthException extends OpenRouterException {
OpenRouterAuthException(this.statusCode, String body) : super(body);
class DeepSeekAuthException extends DeepSeekException {
DeepSeekAuthException(this.statusCode, String body) : super(body);
final int statusCode;
}
/// Прочая ошибка API (не-2xx).
class OpenRouterApiException extends OpenRouterException {
OpenRouterApiException(this.statusCode, String body) : super(body);
class DeepSeekApiException extends DeepSeekException {
DeepSeekApiException(this.statusCode, String body) : super(body);
final int statusCode;
}
@@ -1,51 +0,0 @@
/// JSON-схема ответа AI-парсера (§7).
///
/// Передаётся в `response_format: {type: 'json_schema', json_schema: ...}`.
/// Не все модели OpenRouter её уважают — поэтому требование структуры также
/// дублируется текстом в [ai_prompts.dart], а ответ разбирается tolerant-парсером.
const Map<String, dynamic> aiResponseJsonSchema = {
'name': 'bank_notification',
'strict': false,
'schema': {
'type': 'object',
'properties': {
'type': {
'type': 'string',
'enum': ['expense', 'income', 'transfer', 'ignored'],
},
'amount': {'type': 'number'},
'currency': {'type': 'string'},
'cardLast4': {
'type': ['string', 'null'],
},
'merchantRaw': {
'type': ['string', 'null'],
},
'counterpartyName': {
'type': ['string', 'null'],
},
'counterpartyPhone': {
'type': ['string', 'null'],
},
'dateTime': {
'type': ['string', 'null'],
},
'kind': {
'type': 'string',
'enum': [
'purchase',
'refund',
'transfer_out',
'transfer_in',
'fee',
'balance',
'other',
],
},
'categorySuggestion': {
'type': ['string', 'null'],
},
},
'required': ['type', 'amount'],
},
};
@@ -2,20 +2,19 @@ import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/parse_draft.dart';
import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../openrouter/ai_prompts.dart';
import '../openrouter/ai_schema.dart';
import '../openrouter/openrouter_client.dart';
import '../deepseek/ai_prompts.dart';
import '../deepseek/deepseek_client.dart';
import 'ai_tolerant_json.dart';
/// Этап 2 pipeline (§7): разбор тела уведомления через OpenRouter, когда regex
/// Этап 2 pipeline (§7): разбор тела уведомления через DeepSeek, когда regex
/// не справился. Результат — [AiParseOutcome]: draft / partial / ignored.
///
/// Сетевые сбои ([OpenRouterNetworkException]) пробрасываются — воркер трактует
/// Сетевые сбои ([DeepSeekNetworkException]) пробрасываются — воркер трактует
/// их как offline и ставит `pending_ai` для повторной обработки.
class AiParser {
const AiParser(this._client);
final OpenRouterClient _client;
final DeepSeekClient _client;
Future<AiParseOutcome> parse({
required RawMessage msg,
@@ -26,7 +25,7 @@ class AiParser {
model: model,
systemPrompt: buildSystemPrompt(categoryNames: categoryNames),
userContent: msg.body,
jsonSchema: aiResponseJsonSchema,
jsonObject: true,
);
final tokens = completion.totalTokens;
@@ -2,8 +2,8 @@ import 'dart:convert';
/// Tolerant-парсер JSON из произвольного текста ответа модели (§7.3).
///
/// Дешёвые модели OpenRouter часто игнорируют `response_format: json_schema`
/// и оборачивают JSON в прозу или ```json-блоки. Эта функция вытаскивает
/// Модель в JSON-режиме иногда всё равно оборачивает ответ в прозу или
/// ```json-блоки. Эта функция вытаскивает
/// первый сбалансированный `{...}`-объект и декодирует его.
///
/// Возвращает `null`, если сбалансированный объект не найден или не парсится.
@@ -91,6 +91,23 @@ ParseRule? findIgnoreRule(
return matches.isEmpty ? null : matches.first;
}
/// Ищет ignore-правило, матчащееся по ТЕЛУ сообщения до вызова AI (режимы
/// `contains`/`regex`). Позволяет отсеять «Доставлен заказ» и подобный спам,
/// не тратя токены. `exact` сверяется с именем мерчанта, которого до AI ещё
/// нет, — такие правила проверяет [findIgnoreRule] уже в pipeline.
ParseRule? findBodyIgnoreRule(List<ParseRule> rules, String body) {
final matches = rules
.where((r) =>
r.enabled &&
r.kind == ParseRuleKind.ignore &&
(r.matchMode == MatchMode.contains ||
r.matchMode == MatchMode.regex) &&
ruleMatches(r, body: body))
.toList()
..sort(_bySpecificity);
return matches.isEmpty ? null : matches.first;
}
int _bySpecificity(ParseRule a, ParseRule b) {
final byLen = b.pattern.length.compareTo(a.pattern.length);
if (byLen != 0) return byLen;
@@ -1,6 +1,6 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Хранилище OpenRouter API key в защищённом storage (§14).
/// Хранилище DeepSeek API key в защищённом storage (§14).
///
/// Ключ никогда не пишется в логи и не хранится в `app_preferences`.
class AiKeyStore {
@@ -8,7 +8,7 @@ class AiKeyStore {
final FlutterSecureStorage _storage;
static const _key = 'openrouter_api_key';
static const _key = 'deepseek_api_key';
Future<String?> getApiKey() => _storage.read(key: _key);
+1 -1
View File
@@ -42,7 +42,7 @@ dependencies:
# Charts
fl_chart: ^1.2.0
# AI fallback (OpenRouter)
# AI fallback (DeepSeek)
http: ^1.2.2
flutter_secure_storage: ^9.2.2
connectivity_plus: ^6.1.0
@@ -14,7 +14,7 @@ import 'package:new_budget/src/features/notification_parsing/application/ai_prov
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
import 'package:new_budget/src/features/notification_parsing/data/openrouter/openrouter_client.dart';
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
@@ -56,7 +56,7 @@ AiParser _fakeAiParser({
headers: {'content-type': 'application/json'},
);
});
return AiParser(OpenRouterClient(client: mock, apiKey: 'k'));
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
}
Future<void> _seed(AppDatabase db) async {
@@ -151,6 +151,52 @@ void main() {
expect(aiCalled, isFalse, reason: 'AI must not be called for non-allowlisted package');
});
test('ignore-правило (contains) по телу → ignored, AI не вызывается',
() async {
var aiCalled = false;
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith((ref) async => _fakeAiParser(
merchantRaw: 'LENTA',
amount: 1500,
onCall: () => aiCalled = true,
)),
]);
repo = container.read(rawMessagesRepositoryProvider);
await enableAi(container);
// Банк в allowlist, чтобы дойти до ignore-проверки.
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-1',
userId: _userId,
packageName: _bank,
));
// Ignore-правило: «Доставлен заказ» пропускать.
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
kind: ParseRuleKind.ignore,
matchMode: MatchMode.contains,
pattern: 'Доставлен заказ',
);
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: 'Доставлен заказ №123 на сумму 1500 руб',
receivedAt: DateTime(2026, 5, 31, 12),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.ignored);
expect(aiCalled, isFalse,
reason: 'body ignore rule must short-circuit before the AI call');
});
test(
'включённый банк без карты + глобальный дефолт + правило категории '
'→ авто-применение на дефолтный счёт', () async {
@@ -21,30 +21,30 @@ import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
/// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ
/// OpenRouter API. Проверяют, что после получения сообщения оно
/// DeepSeek API. Проверяют, что после получения сообщения оно
/// автоматически подхватывается [ParsingWorker] и проходит pipeline
/// AI → терминальный статус (inbox / ignored / failed).
///
/// Ключ НЕ хардкодится: берётся из --dart-define=OPENROUTER_API_KEY=...
/// Ключ НЕ хардкодится: берётся из --dart-define=DEEPSEEK_API_KEY=...
/// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся
/// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт
/// всегда.
///
/// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \
/// --tags integration -p vm \
/// --dart-define=OPENROUTER_API_KEY=sk-or-...
const _apiKey = String.fromEnvironment('OPENROUTER_API_KEY');
/// --dart-define=DEEPSEEK_API_KEY=sk-...
const _apiKey = String.fromEnvironment('DEEPSEEK_API_KEY');
const _model =
String.fromEnvironment('OPENROUTER_TEST_MODEL', defaultValue: kDefaultAiModel);
String.fromEnvironment('DEEPSEEK_TEST_MODEL', defaultValue: kDefaultAiModel);
final Object _skip =
_apiKey.isEmpty ? 'set --dart-define=OPENROUTER_API_KEY to run' : false;
_apiKey.isEmpty ? 'set --dart-define=DEEPSEEK_API_KEY to run' : false;
const _userId = 'user-1';
const _accountId = 'acc-1';
/// Подменяет secure storage, отдавая ключ из dart-define. Так собирается
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/OpenRouterClient —
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/DeepSeekClient —
/// без обращения к платформенному FlutterSecureStorage.
class _FakeKeyStore extends AiKeyStore {
const _FakeKeyStore(this.key) : super(const FlutterSecureStorage());
@@ -144,8 +144,8 @@ Future<RawMessage> _waitTerminal(
void main() {
const netTimeout = Timeout(Duration(seconds: 90));
// ── AI-обработка через реальный OpenRouter (требует ключ) ────────────────
group('AI auto-processing (real OpenRouter)', () {
// ── AI-обработка через реальный DeepSeek (требует ключ) ──────────────────
group('AI auto-processing (real DeepSeek)', () {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
@@ -272,7 +272,7 @@ void main() {
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = _container(db, 'sk-or-invalid-key-for-test');
container = _container(db, 'sk-invalid-key-for-test');
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
@@ -163,4 +163,62 @@ void main() {
expect(r?.id, 'i');
});
});
group('findBodyIgnoreRule', () {
test('matches contains ignore rule by body (pre-AI)', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: 'Доставлен заказ',
kind: ParseRuleKind.ignore),
],
'Доставлен заказ №123',
);
expect(r?.id, 'i');
});
test('matches regex ignore rule by body', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: r'заказ \d+',
mode: MatchMode.regex,
kind: ParseRuleKind.ignore),
],
'Доставлен заказ 123',
);
expect(r?.id, 'i');
});
test('skips exact ignore rules (need merchant, only known after AI)', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'i',
pattern: 'Доставлен заказ №123',
mode: MatchMode.exact,
kind: ParseRuleKind.ignore),
],
'Доставлен заказ №123',
);
expect(r, isNull);
});
test('skips disabled and non-ignore rules', () {
final r = findBodyIgnoreRule(
[
_rule(
id: 'a',
pattern: 'спам',
kind: ParseRuleKind.ignore,
enabled: false),
_rule(id: 'b', pattern: 'спам', kind: ParseRuleKind.merchantToCategory),
],
'это спам',
);
expect(r, isNull);
});
});
}