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) state/selected_category_filter.dart # account/category filter providers (+ kAllAccountsId)
month_summary.dart # client-side aggregates for KPI / donut month_summary.dart # client-side aggregates for KPI / donut
analytics/ # analytics_screen + habit_analysis (providers/screen, /analytics/habits) 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; # domain/data/application/presentation; Drift tables + DAOs;
# data/parser/ (dedup, confidence, decision_gate, ai_parser), # 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 # Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
profile/ # theme switcher screen profile/ # theme switcher screen
shared/ shared/
+1 -1
View File
@@ -1,7 +1,7 @@
# Registers custom test tags so `flutter test --tags integration` does not warn. # 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 # 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`. # `flutter test` run and are opt-in via `--tags integration`.
tags: tags:
integration: integration:
+4 -4
View File
@@ -220,7 +220,7 @@
"parsingRulesTile": "Parsing rules", "parsingRulesTile": "Parsing rules",
"parsingRulesCreatedCount": "Rules created: {count}", "parsingRulesCreatedCount": "Rules created: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)", "parsingAiSectionTitle": "AI (DeepSeek)",
"parsingAiComingSoon": "AI recognition is coming in a future version.", "parsingAiComingSoon": "AI recognition is coming in a future version.",
"parsingNotifAccessTitle": "Notification access", "parsingNotifAccessTitle": "Notification access",
"parsingNotifAccessGranted": "Granted — notifications are being read", "parsingNotifAccessGranted": "Granted — notifications are being read",
@@ -347,9 +347,9 @@
"aiConsentKeyMissing": "API Key is missing", "aiConsentKeyMissing": "API Key is missing",
"aiConsentSaved": "Settings saved", "aiConsentSaved": "Settings saved",
"aiConsentTitle": "AI Parsing", "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.", "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": "OpenRouter API Key", "aiConsentKeyLabel": "DeepSeek API Key",
"aiConsentKeyHint": "sk-or-v1-...", "aiConsentKeyHint": "sk-...",
"aiConsentModelLabel": "AI Model", "aiConsentModelLabel": "AI Model",
"aiConsentAllow": "Allow AI Parsing", "aiConsentAllow": "Allow AI Parsing",
"aiConsentRegexOnly": "Use Regex Only (Local)", "aiConsentRegexOnly": "Use Regex Only (Local)",
+4 -4
View File
@@ -983,7 +983,7 @@ abstract class AppLocalizations {
/// No description provided for @parsingAiSectionTitle. /// No description provided for @parsingAiSectionTitle.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'AI (OpenRouter)'** /// **'AI (DeepSeek)'**
String get parsingAiSectionTitle; String get parsingAiSectionTitle;
/// No description provided for @parsingAiComingSoon. /// No description provided for @parsingAiComingSoon.
@@ -1673,19 +1673,19 @@ abstract class AppLocalizations {
/// No description provided for @aiConsentBody. /// No description provided for @aiConsentBody.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'** /// **'Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'**
String get aiConsentBody; String get aiConsentBody;
/// No description provided for @aiConsentKeyLabel. /// No description provided for @aiConsentKeyLabel.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'API-ключ OpenRouter'** /// **'API-ключ DeepSeek'**
String get aiConsentKeyLabel; String get aiConsentKeyLabel;
/// No description provided for @aiConsentKeyHint. /// No description provided for @aiConsentKeyHint.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'sk-or-v1-...'** /// **'sk-...'**
String get aiConsentKeyHint; String get aiConsentKeyHint;
/// No description provided for @aiConsentModelLabel. /// No description provided for @aiConsentModelLabel.
+4 -4
View File
@@ -512,7 +512,7 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get parsingAiSectionTitle => 'AI (OpenRouter)'; String get parsingAiSectionTitle => 'AI (DeepSeek)';
@override @override
String get parsingAiComingSoon => String get parsingAiComingSoon =>
@@ -878,13 +878,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aiConsentBody => 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 @override
String get aiConsentKeyLabel => 'OpenRouter API Key'; String get aiConsentKeyLabel => 'DeepSeek API Key';
@override @override
String get aiConsentKeyHint => 'sk-or-v1-...'; String get aiConsentKeyHint => 'sk-...';
@override @override
String get aiConsentModelLabel => 'AI Model'; String get aiConsentModelLabel => 'AI Model';
+4 -4
View File
@@ -523,7 +523,7 @@ class AppLocalizationsRu extends AppLocalizations {
} }
@override @override
String get parsingAiSectionTitle => 'AI (OpenRouter)'; String get parsingAiSectionTitle => 'AI (DeepSeek)';
@override @override
String get parsingAiComingSoon => String get parsingAiComingSoon =>
@@ -890,13 +890,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get aiConsentBody => String get aiConsentBody =>
'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'; 'Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.';
@override @override
String get aiConsentKeyLabel => 'API-ключ OpenRouter'; String get aiConsentKeyLabel => 'API-ключ DeepSeek';
@override @override
String get aiConsentKeyHint => 'sk-or-v1-...'; String get aiConsentKeyHint => 'sk-...';
@override @override
String get aiConsentModelLabel => 'Модель ИИ'; String get aiConsentModelLabel => 'Модель ИИ';
+4 -4
View File
@@ -220,7 +220,7 @@
"parsingRulesTile": "Правила парсинга", "parsingRulesTile": "Правила парсинга",
"parsingRulesCreatedCount": "Правил создано: {count}", "parsingRulesCreatedCount": "Правил создано: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } }, "@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)", "parsingAiSectionTitle": "AI (DeepSeek)",
"parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.", "parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.",
"parsingNotifAccessTitle": "Доступ к уведомлениям", "parsingNotifAccessTitle": "Доступ к уведомлениям",
"parsingNotifAccessGranted": "Выдан — уведомления читаются", "parsingNotifAccessGranted": "Выдан — уведомления читаются",
@@ -347,9 +347,9 @@
"aiConsentKeyMissing": "API-ключ не указан", "aiConsentKeyMissing": "API-ключ не указан",
"aiConsentSaved": "Настройки сохранены", "aiConsentSaved": "Настройки сохранены",
"aiConsentTitle": "Парсинг с ИИ", "aiConsentTitle": "Парсинг с ИИ",
"aiConsentBody": "Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.", "aiConsentBody": "Мы используем DeepSeek API для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.",
"aiConsentKeyLabel": "API-ключ OpenRouter", "aiConsentKeyLabel": "API-ключ DeepSeek",
"aiConsentKeyHint": "sk-or-v1-...", "aiConsentKeyHint": "sk-...",
"aiConsentModelLabel": "Модель ИИ", "aiConsentModelLabel": "Модель ИИ",
"aiConsentAllow": "Разрешить ИИ-парсинг", "aiConsentAllow": "Разрешить ИИ-парсинг",
"aiConsentRegexOnly": "Только Regex (Локально)", "aiConsentRegexOnly": "Только Regex (Локально)",
@@ -3,13 +3,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart'; 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/parser/ai_parser.dart';
import '../data/secure/ai_key_store.dart'; import '../data/secure/ai_key_store.dart';
part 'ai_providers.g.dart'; part 'ai_providers.g.dart';
/// Защищённое хранилище OpenRouter API key. /// Защищённое хранилище DeepSeek API key.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
AiKeyStore aiKeyStore(Ref ref) => AiKeyStore aiKeyStore(Ref ref) =>
const AiKeyStore(FlutterSecureStorage()); const AiKeyStore(FlutterSecureStorage());
@@ -28,7 +28,7 @@ http.Client httpClient(Ref ref) {
Future<AiParser?> aiParser(Ref ref) async { Future<AiParser?> aiParser(Ref ref) async {
final key = await ref.watch(aiKeyStoreProvider).getApiKey(); final key = await ref.watch(aiKeyStoreProvider).getApiKey();
if (key == null || key.isEmpty) return null; if (key == null || key.isEmpty) return null;
final client = OpenRouterClient( final client = DeepSeekClient(
client: ref.watch(httpClientProvider), client: ref.watch(httpClientProvider),
apiKey: key, apiKey: key,
); );
@@ -4,7 +4,7 @@ import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart'; import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart'; import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transactions_controller.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/account_resolver.dart';
import '../data/parser/ai_parser.dart'; import '../data/parser/ai_parser.dart';
import '../data/parser/confidence_scorer.dart'; import '../data/parser/confidence_scorer.dart';
@@ -62,6 +62,19 @@ class ParsingPipeline {
return; 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. // Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings); await _extractViaAi(userId, msg, settings);
} }
@@ -150,13 +163,13 @@ class ParsingPipeline {
await _runPipeline(userId, msg, outcome.draft!, settings, await _runPipeline(userId, msg, outcome.draft!, settings,
categories: categories); categories: categories);
} }
} on OpenRouterNetworkException { } on DeepSeekNetworkException {
await repo.updateAfterParse( await repo.updateAfterParse(
id: msg.id, id: msg.id,
status: RawMessageStatus.pendingAi, status: RawMessageStatus.pendingAi,
lastParseError: 'network', lastParseError: 'network',
); );
} on OpenRouterAuthException catch (e) { } on DeepSeekAuthException catch (e) {
// Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на // Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на
// каждом последующем сообщении. Пользователь введёт корректный ключ // каждом последующем сообщении. Пользователь введёт корректный ключ
// заново на экране согласия (это снова включит AI). // заново на экране согласия (это снова включит AI).
@@ -166,7 +179,7 @@ class ParsingPipeline {
status: RawMessageStatus.failed, status: RawMessageStatus.failed,
lastParseError: 'AI auth failed (${e.statusCode})', lastParseError: 'AI auth failed (${e.statusCode})',
); );
} on OpenRouterException catch (e) { } on DeepSeekException catch (e) {
await repo.updateAfterParse( await repo.updateAfterParse(
id: msg.id, id: msg.id,
status: RawMessageStatus.failed, status: RawMessageStatus.failed,
@@ -28,7 +28,7 @@ class ParsingSettings {
/// Без него AI не вызывается — работает только regex. /// Без него AI не вызывается — работает только regex.
final bool aiConsentGiven; final bool aiConsentGiven;
/// Модель OpenRouter по умолчанию. /// Модель DeepSeek по умолчанию.
final String aiModel; final String aiModel;
/// Дневной лимит токенов (null = без лимита). При исчерпании — regex-only. /// Дневной лимит токенов (null = без лимита). При исчерпании — regex-only.
@@ -68,7 +68,7 @@ const _kAiConsent = 'ai_consent';
const _kAiModel = 'ai_model'; const _kAiModel = 'ai_model';
const _kAiDailyLimit = 'ai_daily_token_limit'; const _kAiDailyLimit = 'ai_daily_token_limit';
const kDefaultAiModel = 'deepseek/deepseek-v4-flash'; const kDefaultAiModel = 'deepseek-chat';
const _defaultSettings = ParsingSettings( const _defaultSettings = ParsingSettings(
enabled: true, enabled: true,
@@ -1,13 +1,13 @@
/// Промпты для AI-разбора уведомлений (§7). /// Промпты для AI-разбора уведомлений (§7).
/// ///
/// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко /// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко
/// правился без касания логики клиента/парсера. JSON-схема — рядом в /// правился без касания логики клиента/парсера. DeepSeek работает в JSON-режиме
/// [ai_schema.dart]; здесь только текстовые инструкции. /// (`response_format: {type: json_object}`) без строгой схемы, поэтому структуру
/// ответа полностью диктует текст ниже, а сам ответ разбирается tolerant-парсером.
library; library;
/// System-промпт. Требование структуры **дублируется текстом** (помимо /// System-промпт. Требование структуры задаётся текстом; слово «JSON» в промпте
/// `response_format: json_schema`), т.к. дешёвые модели OpenRouter часто /// обязательно — без него DeepSeek отклоняет запрос в JSON-режиме.
/// игнорируют schema (§7).
/// ///
/// [categoryNames] — существующие категории пользователя; модель должна /// [categoryNames] — существующие категории пользователя; модель должна
/// выбирать `categorySuggestion` из них (или вернуть null), а не выдумывать. /// выбирать `categorySuggestion` из них (или вернуть null), а не выдумывать.
@@ -4,9 +4,14 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
/// HTTP-клиент OpenRouter (§7). `client` инжектируется для тестируемости. /// HTTP-клиент DeepSeek API (§7). `client` инжектируется для тестируемости.
class OpenRouterClient { ///
OpenRouterClient({ /// DeepSeek совместим с OpenAI-форматом chat/completions, поэтому тело запроса
/// и разбор ответа те же. Отличия: базовый URL, отсутствие OpenRouter-заголовков
/// и JSON-режим через `response_format: {type: json_object}` (DeepSeek не
/// поддерживает строгий `json_schema`) — структуру диктует текст промпта.
class DeepSeekClient {
DeepSeekClient({
required this.client, required this.client,
required this.apiKey, required this.apiKey,
this.timeout = const Duration(seconds: 20), this.timeout = const Duration(seconds: 20),
@@ -16,18 +21,21 @@ class OpenRouterClient {
final String apiKey; final String apiKey;
final Duration timeout; final Duration timeout;
static const _base = 'https://openrouter.ai/api/v1'; static const _base = 'https://api.deepseek.com';
/// Один запрос chat/completions. Возвращает текст ответа модели + usage. /// Один запрос chat/completions. Возвращает текст ответа модели + usage.
/// ///
/// Бросает [OpenRouterAuthException] на 401/403, [OpenRouterNetworkException] /// [jsonObject] включает JSON-режим DeepSeek (модель обязана вернуть валидный
/// JSON-объект; для этого в промпте должно встречаться слово «json»).
///
/// Бросает [DeepSeekAuthException] на 401/403, [DeepSeekNetworkException]
/// при отсутствии сети/таймауте (воркер трактует как offline → pending_ai), /// при отсутствии сети/таймауте (воркер трактует как offline → pending_ai),
/// [OpenRouterApiException] на прочих не-2xx. /// [DeepSeekApiException] на прочих не-2xx.
Future<OpenRouterCompletion> chatCompletion({ Future<DeepSeekCompletion> chatCompletion({
required String model, required String model,
required String systemPrompt, required String systemPrompt,
required String userContent, required String userContent,
Map<String, dynamic>? jsonSchema, bool jsonObject = false,
}) async { }) async {
final body = <String, dynamic>{ final body = <String, dynamic>{
'model': model, 'model': model,
@@ -35,11 +43,7 @@ class OpenRouterClient {
{'role': 'system', 'content': systemPrompt}, {'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userContent}, {'role': 'user', 'content': userContent},
], ],
if (jsonSchema != null) if (jsonObject) 'response_format': {'type': 'json_object'},
'response_format': {
'type': 'json_schema',
'json_schema': jsonSchema,
},
}; };
final http.Response res; final http.Response res;
@@ -52,22 +56,22 @@ class OpenRouterClient {
) )
.timeout(timeout); .timeout(timeout);
} on SocketException catch (e) { } on SocketException catch (e) {
throw OpenRouterNetworkException(e.message); throw DeepSeekNetworkException(e.message);
} on TimeoutException { } on TimeoutException {
throw OpenRouterNetworkException('timeout'); throw DeepSeekNetworkException('timeout');
} on http.ClientException catch (e) { } on http.ClientException catch (e) {
throw OpenRouterNetworkException(e.message); throw DeepSeekNetworkException(e.message);
} }
if (res.statusCode == 401 || res.statusCode == 403) { 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) { 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>; 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 => { Map<String, String> get _headers => {
'Authorization': 'Bearer $apiKey', 'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
// OpenRouter рекомендует указывать источник; необязательно.
'HTTP-Referer': 'https://newbudget.app',
'X-Title': 'NewBudget',
}; };
} }
/// Результат успешного вызова: контент модели + расход токенов. /// Результат успешного вызова: контент модели + расход токенов.
class OpenRouterCompletion { class DeepSeekCompletion {
const OpenRouterCompletion({ const DeepSeekCompletion({
required this.content, required this.content,
required this.promptTokens, required this.promptTokens,
required this.completionTokens, required this.completionTokens,
@@ -114,7 +115,7 @@ class OpenRouterCompletion {
final int completionTokens; final int completionTokens;
final int totalTokens; final int totalTokens;
factory OpenRouterCompletion.fromJson(Map<String, dynamic> json) { factory DeepSeekCompletion.fromJson(Map<String, dynamic> json) {
final choices = json['choices']; final choices = json['choices'];
var content = ''; var content = '';
if (choices is List && choices.isNotEmpty) { if (choices is List && choices.isNotEmpty) {
@@ -125,7 +126,7 @@ class OpenRouterCompletion {
} }
final usage = json['usage'] as Map<String, dynamic>?; final usage = json['usage'] as Map<String, dynamic>?;
int u(String k) => (usage?[k] as num?)?.toInt() ?? 0; int u(String k) => (usage?[k] as num?)?.toInt() ?? 0;
return OpenRouterCompletion( return DeepSeekCompletion(
content: content, content: content,
promptTokens: u('prompt_tokens'), promptTokens: u('prompt_tokens'),
completionTokens: u('completion_tokens'), completionTokens: u('completion_tokens'),
@@ -134,27 +135,27 @@ class OpenRouterCompletion {
} }
} }
/// Базовое исключение клиента OpenRouter. /// Базовое исключение клиента DeepSeek.
sealed class OpenRouterException implements Exception { sealed class DeepSeekException implements Exception {
const OpenRouterException(this.message); const DeepSeekException(this.message);
final String message; final String message;
@override @override
String toString() => '$runtimeType: $message'; String toString() => '$runtimeType: $message';
} }
/// Сеть недоступна / таймаут — трактуется воркером как offline (pending_ai). /// Сеть недоступна / таймаут — трактуется воркером как offline (pending_ai).
class OpenRouterNetworkException extends OpenRouterException { class DeepSeekNetworkException extends DeepSeekException {
const OpenRouterNetworkException(super.message); const DeepSeekNetworkException(super.message);
} }
/// Неверный/просроченный API key (401/403). /// Неверный/просроченный API key (401/403).
class OpenRouterAuthException extends OpenRouterException { class DeepSeekAuthException extends DeepSeekException {
OpenRouterAuthException(this.statusCode, String body) : super(body); DeepSeekAuthException(this.statusCode, String body) : super(body);
final int statusCode; final int statusCode;
} }
/// Прочая ошибка API (не-2xx). /// Прочая ошибка API (не-2xx).
class OpenRouterApiException extends OpenRouterException { class DeepSeekApiException extends DeepSeekException {
OpenRouterApiException(this.statusCode, String body) : super(body); DeepSeekApiException(this.statusCode, String body) : super(body);
final int statusCode; 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/parse_draft.dart';
import '../../domain/entities/raw_message.dart'; import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart'; import '../../domain/enums.dart';
import '../openrouter/ai_prompts.dart'; import '../deepseek/ai_prompts.dart';
import '../openrouter/ai_schema.dart'; import '../deepseek/deepseek_client.dart';
import '../openrouter/openrouter_client.dart';
import 'ai_tolerant_json.dart'; import 'ai_tolerant_json.dart';
/// Этап 2 pipeline (§7): разбор тела уведомления через OpenRouter, когда regex /// Этап 2 pipeline (§7): разбор тела уведомления через DeepSeek, когда regex
/// не справился. Результат — [AiParseOutcome]: draft / partial / ignored. /// не справился. Результат — [AiParseOutcome]: draft / partial / ignored.
/// ///
/// Сетевые сбои ([OpenRouterNetworkException]) пробрасываются — воркер трактует /// Сетевые сбои ([DeepSeekNetworkException]) пробрасываются — воркер трактует
/// их как offline и ставит `pending_ai` для повторной обработки. /// их как offline и ставит `pending_ai` для повторной обработки.
class AiParser { class AiParser {
const AiParser(this._client); const AiParser(this._client);
final OpenRouterClient _client; final DeepSeekClient _client;
Future<AiParseOutcome> parse({ Future<AiParseOutcome> parse({
required RawMessage msg, required RawMessage msg,
@@ -26,7 +25,7 @@ class AiParser {
model: model, model: model,
systemPrompt: buildSystemPrompt(categoryNames: categoryNames), systemPrompt: buildSystemPrompt(categoryNames: categoryNames),
userContent: msg.body, userContent: msg.body,
jsonSchema: aiResponseJsonSchema, jsonObject: true,
); );
final tokens = completion.totalTokens; final tokens = completion.totalTokens;
@@ -2,8 +2,8 @@ import 'dart:convert';
/// Tolerant-парсер JSON из произвольного текста ответа модели (§7.3). /// Tolerant-парсер JSON из произвольного текста ответа модели (§7.3).
/// ///
/// Дешёвые модели OpenRouter часто игнорируют `response_format: json_schema` /// Модель в JSON-режиме иногда всё равно оборачивает ответ в прозу или
/// и оборачивают JSON в прозу или ```json-блоки. Эта функция вытаскивает /// ```json-блоки. Эта функция вытаскивает
/// первый сбалансированный `{...}`-объект и декодирует его. /// первый сбалансированный `{...}`-объект и декодирует его.
/// ///
/// Возвращает `null`, если сбалансированный объект не найден или не парсится. /// Возвращает `null`, если сбалансированный объект не найден или не парсится.
@@ -91,6 +91,23 @@ ParseRule? findIgnoreRule(
return matches.isEmpty ? null : matches.first; 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) { int _bySpecificity(ParseRule a, ParseRule b) {
final byLen = b.pattern.length.compareTo(a.pattern.length); final byLen = b.pattern.length.compareTo(a.pattern.length);
if (byLen != 0) return byLen; if (byLen != 0) return byLen;
@@ -1,6 +1,6 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Хранилище OpenRouter API key в защищённом storage (§14). /// Хранилище DeepSeek API key в защищённом storage (§14).
/// ///
/// Ключ никогда не пишется в логи и не хранится в `app_preferences`. /// Ключ никогда не пишется в логи и не хранится в `app_preferences`.
class AiKeyStore { class AiKeyStore {
@@ -8,7 +8,7 @@ class AiKeyStore {
final FlutterSecureStorage _storage; final FlutterSecureStorage _storage;
static const _key = 'openrouter_api_key'; static const _key = 'deepseek_api_key';
Future<String?> getApiKey() => _storage.read(key: _key); Future<String?> getApiKey() => _storage.read(key: _key);
+1 -1
View File
@@ -42,7 +42,7 @@ dependencies:
# Charts # Charts
fl_chart: ^1.2.0 fl_chart: ^1.2.0
# AI fallback (OpenRouter) # AI fallback (DeepSeek)
http: ^1.2.2 http: ^1.2.2
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^9.2.2
connectivity_plus: ^6.1.0 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/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_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.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/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/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart'; import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
@@ -56,7 +56,7 @@ AiParser _fakeAiParser({
headers: {'content-type': 'application/json'}, 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 { 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'); 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( test(
'включённый банк без карты + глобальный дефолт + правило категории ' 'включённый банк без карты + глобальный дефолт + правило категории '
'→ авто-применение на дефолтный счёт', () async { '→ авто-применение на дефолтный счёт', () 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'; import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
/// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ /// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ
/// OpenRouter API. Проверяют, что после получения сообщения оно /// DeepSeek API. Проверяют, что после получения сообщения оно
/// автоматически подхватывается [ParsingWorker] и проходит pipeline /// автоматически подхватывается [ParsingWorker] и проходит pipeline
/// AI → терминальный статус (inbox / ignored / failed). /// AI → терминальный статус (inbox / ignored / failed).
/// ///
/// Ключ НЕ хардкодится: берётся из --dart-define=OPENROUTER_API_KEY=... /// Ключ НЕ хардкодится: берётся из --dart-define=DEEPSEEK_API_KEY=...
/// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся /// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся
/// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт /// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт
/// всегда. /// всегда.
/// ///
/// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \ /// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \
/// --tags integration -p vm \ /// --tags integration -p vm \
/// --dart-define=OPENROUTER_API_KEY=sk-or-... /// --dart-define=DEEPSEEK_API_KEY=sk-...
const _apiKey = String.fromEnvironment('OPENROUTER_API_KEY'); const _apiKey = String.fromEnvironment('DEEPSEEK_API_KEY');
const _model = const _model =
String.fromEnvironment('OPENROUTER_TEST_MODEL', defaultValue: kDefaultAiModel); String.fromEnvironment('DEEPSEEK_TEST_MODEL', defaultValue: kDefaultAiModel);
final Object _skip = 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 _userId = 'user-1';
const _accountId = 'acc-1'; const _accountId = 'acc-1';
/// Подменяет secure storage, отдавая ключ из dart-define. Так собирается /// Подменяет secure storage, отдавая ключ из dart-define. Так собирается
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/OpenRouterClient — /// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/DeepSeekClient —
/// без обращения к платформенному FlutterSecureStorage. /// без обращения к платформенному FlutterSecureStorage.
class _FakeKeyStore extends AiKeyStore { class _FakeKeyStore extends AiKeyStore {
const _FakeKeyStore(this.key) : super(const FlutterSecureStorage()); const _FakeKeyStore(this.key) : super(const FlutterSecureStorage());
@@ -144,8 +144,8 @@ Future<RawMessage> _waitTerminal(
void main() { void main() {
const netTimeout = Timeout(Duration(seconds: 90)); const netTimeout = Timeout(Duration(seconds: 90));
// ── AI-обработка через реальный OpenRouter (требует ключ) ──────────────── // ── AI-обработка через реальный DeepSeek (требует ключ) ──────────────────
group('AI auto-processing (real OpenRouter)', () { group('AI auto-processing (real DeepSeek)', () {
late AppDatabase db; late AppDatabase db;
late ProviderContainer container; late ProviderContainer container;
late RawMessagesRepository repo; late RawMessagesRepository repo;
@@ -272,7 +272,7 @@ void main() {
setUp(() async { setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory()); db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db); await _seed(db);
container = _container(db, 'sk-or-invalid-key-for-test'); container = _container(db, 'sk-invalid-key-for-test');
repo = container.read(rawMessagesRepositoryProvider); repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier); final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future); await container.read(parsingSettingsControllerProvider.future);
@@ -163,4 +163,62 @@ void main() {
expect(r?.id, 'i'); 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);
});
});
} }