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:
@@ -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
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+5
-5
@@ -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), а не выдумывать.
|
||||
+36
-35
@@ -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
@@ -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 {
|
||||
|
||||
+10
-10
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user