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