Stage ai for sms
This commit is contained in:
+32
-1
@@ -275,5 +275,36 @@
|
||||
"debugInjectBodyLabel": "Notification text",
|
||||
"debugInjectSend": "Send to pipeline",
|
||||
"debugInjectSent": "Sent",
|
||||
"debugInjectFillSample": "Sample: purchase"
|
||||
"debugInjectFillSample": "Sample: purchase",
|
||||
|
||||
"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-...",
|
||||
"aiConsentModelLabel": "AI Model",
|
||||
"aiConsentAllow": "Allow AI Parsing",
|
||||
"aiConsentRegexOnly": "Use Regex Only (Local)",
|
||||
|
||||
"parsingAiConfigure": "Configure AI",
|
||||
"parsingAiTokensToday": "Tokens used today: {tokens}",
|
||||
"@parsingAiTokensToday": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parsingAiTokensTodayLimited": "Tokens today: {tokens} / {limit}",
|
||||
"@parsingAiTokensTodayLimited": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "int"
|
||||
},
|
||||
"limit": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1279,78 @@ abstract class AppLocalizations {
|
||||
/// In ru, this message translates to:
|
||||
/// **'Пример: покупка'**
|
||||
String get debugInjectFillSample;
|
||||
|
||||
/// No description provided for @aiConsentKeyMissing.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'API-ключ не указан'**
|
||||
String get aiConsentKeyMissing;
|
||||
|
||||
/// No description provided for @aiConsentSaved.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Настройки сохранены'**
|
||||
String get aiConsentSaved;
|
||||
|
||||
/// No description provided for @aiConsentTitle.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Парсинг с ИИ'**
|
||||
String get aiConsentTitle;
|
||||
|
||||
/// No description provided for @aiConsentBody.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.'**
|
||||
String get aiConsentBody;
|
||||
|
||||
/// No description provided for @aiConsentKeyLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'API-ключ OpenRouter'**
|
||||
String get aiConsentKeyLabel;
|
||||
|
||||
/// No description provided for @aiConsentKeyHint.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'sk-or-v1-...'**
|
||||
String get aiConsentKeyHint;
|
||||
|
||||
/// No description provided for @aiConsentModelLabel.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Модель ИИ'**
|
||||
String get aiConsentModelLabel;
|
||||
|
||||
/// No description provided for @aiConsentAllow.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Разрешить ИИ-парсинг'**
|
||||
String get aiConsentAllow;
|
||||
|
||||
/// No description provided for @aiConsentRegexOnly.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Только Regex (Локально)'**
|
||||
String get aiConsentRegexOnly;
|
||||
|
||||
/// No description provided for @parsingAiConfigure.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Настроить ИИ'**
|
||||
String get parsingAiConfigure;
|
||||
|
||||
/// No description provided for @parsingAiTokensToday.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Токенов за сегодня: {tokens}'**
|
||||
String parsingAiTokensToday(int tokens);
|
||||
|
||||
/// No description provided for @parsingAiTokensTodayLimited.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Токенов за сегодня: {tokens} / {limit}'**
|
||||
String parsingAiTokensTodayLimited(int tokens, int limit);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -672,4 +672,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get debugInjectFillSample => 'Sample: purchase';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyMissing => 'API Key is missing';
|
||||
|
||||
@override
|
||||
String get aiConsentSaved => 'Settings saved';
|
||||
|
||||
@override
|
||||
String get aiConsentTitle => 'AI Parsing';
|
||||
|
||||
@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.';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyLabel => 'OpenRouter API Key';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyHint => 'sk-or-v1-...';
|
||||
|
||||
@override
|
||||
String get aiConsentModelLabel => 'AI Model';
|
||||
|
||||
@override
|
||||
String get aiConsentAllow => 'Allow AI Parsing';
|
||||
|
||||
@override
|
||||
String get aiConsentRegexOnly => 'Use Regex Only (Local)';
|
||||
|
||||
@override
|
||||
String get parsingAiConfigure => 'Configure AI';
|
||||
|
||||
@override
|
||||
String parsingAiTokensToday(int tokens) {
|
||||
return 'Tokens used today: $tokens';
|
||||
}
|
||||
|
||||
@override
|
||||
String parsingAiTokensTodayLimited(int tokens, int limit) {
|
||||
return 'Tokens today: $tokens / $limit';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,4 +685,45 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get debugInjectFillSample => 'Пример: покупка';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyMissing => 'API-ключ не указан';
|
||||
|
||||
@override
|
||||
String get aiConsentSaved => 'Настройки сохранены';
|
||||
|
||||
@override
|
||||
String get aiConsentTitle => 'Парсинг с ИИ';
|
||||
|
||||
@override
|
||||
String get aiConsentBody =>
|
||||
'Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyLabel => 'API-ключ OpenRouter';
|
||||
|
||||
@override
|
||||
String get aiConsentKeyHint => 'sk-or-v1-...';
|
||||
|
||||
@override
|
||||
String get aiConsentModelLabel => 'Модель ИИ';
|
||||
|
||||
@override
|
||||
String get aiConsentAllow => 'Разрешить ИИ-парсинг';
|
||||
|
||||
@override
|
||||
String get aiConsentRegexOnly => 'Только Regex (Локально)';
|
||||
|
||||
@override
|
||||
String get parsingAiConfigure => 'Настроить ИИ';
|
||||
|
||||
@override
|
||||
String parsingAiTokensToday(int tokens) {
|
||||
return 'Токенов за сегодня: $tokens';
|
||||
}
|
||||
|
||||
@override
|
||||
String parsingAiTokensTodayLimited(int tokens, int limit) {
|
||||
return 'Токенов за сегодня: $tokens / $limit';
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -275,5 +275,36 @@
|
||||
"debugInjectBodyLabel": "Текст уведомления",
|
||||
"debugInjectSend": "Отправить в обработку",
|
||||
"debugInjectSent": "Отправлено",
|
||||
"debugInjectFillSample": "Пример: покупка"
|
||||
"debugInjectFillSample": "Пример: покупка",
|
||||
|
||||
"aiConsentKeyMissing": "API-ключ не указан",
|
||||
"aiConsentSaved": "Настройки сохранены",
|
||||
"aiConsentTitle": "Парсинг с ИИ",
|
||||
"aiConsentBody": "Мы используем OpenRouter для парсинга уведомлений с помощью ИИ. Ваши данные отправляются выбранной модели. Вам понадобится свой API-ключ. Расходуются токены.",
|
||||
"aiConsentKeyLabel": "API-ключ OpenRouter",
|
||||
"aiConsentKeyHint": "sk-or-v1-...",
|
||||
"aiConsentModelLabel": "Модель ИИ",
|
||||
"aiConsentAllow": "Разрешить ИИ-парсинг",
|
||||
"aiConsentRegexOnly": "Только Regex (Локально)",
|
||||
|
||||
"parsingAiConfigure": "Настроить ИИ",
|
||||
"parsingAiTokensToday": "Токенов за сегодня: {tokens}",
|
||||
"@parsingAiTokensToday": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parsingAiTokensTodayLimited": "Токенов за сегодня: {tokens} / {limit}",
|
||||
"@parsingAiTokensTodayLimited": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "int"
|
||||
},
|
||||
"limit": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../features/analytics/presentation/screens/analytics_screen.dart';
|
||||
import '../../features/categories/presentation/screens/categories_list_screen.dart';
|
||||
import '../../features/categories/presentation/screens/category_form_screen.dart';
|
||||
import '../../features/home/presentation/screens/home_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
|
||||
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
|
||||
@@ -110,6 +111,10 @@ GoRouter appRouter(Ref ref) {
|
||||
path: AppRoutes.parsingSettings,
|
||||
builder: (context, state) => const ParsingSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.parsingAi,
|
||||
builder: (context, state) => const AiConsentScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.parsingRules,
|
||||
builder: (context, state) => const RulesListScreen(),
|
||||
|
||||
@@ -23,6 +23,7 @@ class AppRoutes {
|
||||
// Notification parsing
|
||||
static const inbox = '/inbox';
|
||||
static const parsingSettings = '/settings/parsing';
|
||||
static const parsingAi = '/settings/parsing/ai';
|
||||
static const parsingRules = '/settings/parsing/rules';
|
||||
static const parsingRuleNew = '/settings/parsing/rules/new';
|
||||
static const parsingRuleEditPattern = '/settings/parsing/rules/:id';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
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/parser/ai_parser.dart';
|
||||
import '../data/secure/ai_key_store.dart';
|
||||
|
||||
part 'ai_providers.g.dart';
|
||||
|
||||
/// Защищённое хранилище OpenRouter API key.
|
||||
@Riverpod(keepAlive: true)
|
||||
AiKeyStore aiKeyStore(Ref ref) =>
|
||||
const AiKeyStore(FlutterSecureStorage());
|
||||
|
||||
/// Общий HTTP-клиент (закрывается при dispose контейнера).
|
||||
@Riverpod(keepAlive: true)
|
||||
http.Client httpClient(Ref ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// AI-парсер, собранный на текущем API key. `null`, если ключ не задан —
|
||||
/// воркер в этом случае не зовёт AI. В тестах провайдер переопределяется.
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<AiParser?> aiParser(Ref ref) async {
|
||||
final key = await ref.watch(aiKeyStoreProvider).getApiKey();
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final client = OpenRouterClient(
|
||||
client: ref.watch(httpClientProvider),
|
||||
apiKey: key,
|
||||
);
|
||||
return AiParser(client);
|
||||
}
|
||||
|
||||
/// Поток «есть сеть» — воркер ретраит `pending_ai` при восстановлении (§7).
|
||||
///
|
||||
/// `onConnectivityChanged` не реплеит текущее состояние при подписке, поэтому
|
||||
/// первым значением отдаём результат явной проверки — иначе `.value` остаётся
|
||||
/// `null` (и offline-ветка воркера ошибочно считает, что сеть есть).
|
||||
@riverpod
|
||||
Stream<bool> isOnline(Ref ref) async* {
|
||||
final connectivity = Connectivity();
|
||||
bool online(List<ConnectivityResult> results) =>
|
||||
results.any((r) => r != ConnectivityResult.none);
|
||||
yield online(await connectivity.checkConnectivity());
|
||||
yield* connectivity.onConnectivityChanged.map(online);
|
||||
}
|
||||
+129
-15
@@ -4,10 +4,18 @@ import '../../../core/providers/database_provider.dart';
|
||||
|
||||
part 'parsing_settings_controller.g.dart';
|
||||
|
||||
/// Настройки фичи парсинга (Phase 1) — хранятся в key-value `app_preferences`,
|
||||
/// без изменения схемы.
|
||||
/// Настройки фичи парсинга — хранятся в key-value `app_preferences`,
|
||||
/// без изменения схемы. AI-поля (Phase 2) добавлены тут же; сам API key
|
||||
/// живёт в secure storage (см. `ai_key_store.dart`), здесь только флаги.
|
||||
class ParsingSettings {
|
||||
const ParsingSettings({required this.enabled, required this.strictness});
|
||||
const ParsingSettings({
|
||||
required this.enabled,
|
||||
required this.strictness,
|
||||
required this.aiConsentGiven,
|
||||
required this.aiModel,
|
||||
required this.aiDailyTokenLimit,
|
||||
required this.tokensUsedToday,
|
||||
});
|
||||
|
||||
/// Распознавать уведомления (мастер-тумблер всей фичи).
|
||||
final bool enabled;
|
||||
@@ -15,15 +23,67 @@ class ParsingSettings {
|
||||
/// Порог строгости авто-добавления для прочих полей (§8.7): 75 / 85 / 95.
|
||||
final int strictness;
|
||||
|
||||
ParsingSettings copyWith({bool? enabled, int? strictness}) => ParsingSettings(
|
||||
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
|
||||
/// Без него AI не вызывается — работает только regex.
|
||||
final bool aiConsentGiven;
|
||||
|
||||
/// Модель OpenRouter по умолчанию.
|
||||
final String aiModel;
|
||||
|
||||
/// Дневной лимит токенов (null = без лимита). При исчерпании — regex-only.
|
||||
final int? aiDailyTokenLimit;
|
||||
|
||||
/// Израсходовано токенов сегодня (для UI и daily-limit).
|
||||
final int tokensUsedToday;
|
||||
|
||||
bool get dailyLimitReached =>
|
||||
aiDailyTokenLimit != null && tokensUsedToday >= aiDailyTokenLimit!;
|
||||
|
||||
ParsingSettings copyWith({
|
||||
bool? enabled,
|
||||
int? strictness,
|
||||
bool? aiConsentGiven,
|
||||
String? aiModel,
|
||||
int? aiDailyTokenLimit,
|
||||
bool clearDailyLimit = false,
|
||||
int? tokensUsedToday,
|
||||
}) =>
|
||||
ParsingSettings(
|
||||
enabled: enabled ?? this.enabled,
|
||||
strictness: strictness ?? this.strictness,
|
||||
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
|
||||
aiModel: aiModel ?? this.aiModel,
|
||||
aiDailyTokenLimit:
|
||||
clearDailyLimit ? null : (aiDailyTokenLimit ?? this.aiDailyTokenLimit),
|
||||
tokensUsedToday: tokensUsedToday ?? this.tokensUsedToday,
|
||||
);
|
||||
}
|
||||
|
||||
const _kEnabled = 'parsing_enabled';
|
||||
const _kStrictness = 'parsing_auto_apply_strictness';
|
||||
const _defaultSettings = ParsingSettings(enabled: true, strictness: 85);
|
||||
const _kAiConsent = 'ai_consent';
|
||||
const _kAiModel = 'ai_model';
|
||||
const _kAiDailyLimit = 'ai_daily_token_limit';
|
||||
|
||||
const kDefaultAiModel = 'deepseek/deepseek-v4-flash';
|
||||
|
||||
const _defaultSettings = ParsingSettings(
|
||||
enabled: true,
|
||||
strictness: 85,
|
||||
aiConsentGiven: false,
|
||||
aiModel: kDefaultAiModel,
|
||||
aiDailyTokenLimit: null,
|
||||
tokensUsedToday: 0,
|
||||
);
|
||||
|
||||
/// Ключ дневного расхода токенов — с датой в имени, чтобы счётчик сам
|
||||
/// «обнулялся» в новый день (старые ключи остаются, но не читаются).
|
||||
String _tokenUsageKey([DateTime? now]) {
|
||||
final d = now ?? DateTime.now();
|
||||
final mm = d.month.toString().padLeft(2, '0');
|
||||
final dd = d.day.toString().padLeft(2, '0');
|
||||
return 'ai_token_usage_${d.year}-$mm-$dd';
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ParsingSettingsController extends _$ParsingSettingsController {
|
||||
@@ -32,27 +92,81 @@ class ParsingSettingsController extends _$ParsingSettingsController {
|
||||
final dao = ref.watch(appDatabaseProvider).settingsDao;
|
||||
final enabledStr = await dao.getPreference(_kEnabled);
|
||||
final strictnessStr = await dao.getPreference(_kStrictness);
|
||||
final consentStr = await dao.getPreference(_kAiConsent);
|
||||
final modelStr = await dao.getPreference(_kAiModel);
|
||||
final limitStr = await dao.getPreference(_kAiDailyLimit);
|
||||
final usageStr = await dao.getPreference(_tokenUsageKey());
|
||||
|
||||
return ParsingSettings(
|
||||
enabled: enabledStr == null ? _defaultSettings.enabled : enabledStr == 'true',
|
||||
strictness: int.tryParse(strictnessStr ?? '') ?? _defaultSettings.strictness,
|
||||
enabled:
|
||||
enabledStr == null ? _defaultSettings.enabled : enabledStr == 'true',
|
||||
strictness:
|
||||
int.tryParse(strictnessStr ?? '') ?? _defaultSettings.strictness,
|
||||
aiConsentGiven: consentStr == 'true',
|
||||
aiModel: (modelStr != null && modelStr.isNotEmpty)
|
||||
? modelStr
|
||||
: kDefaultAiModel,
|
||||
aiDailyTokenLimit: int.tryParse(limitStr ?? ''),
|
||||
tokensUsedToday: int.tryParse(usageStr ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _set(String key, String value) =>
|
||||
ref.read(appDatabaseProvider).settingsDao.setPreference(key, value);
|
||||
|
||||
Future<void> setEnabled(bool value) async {
|
||||
await ref
|
||||
.read(appDatabaseProvider)
|
||||
.settingsDao
|
||||
.setPreference(_kEnabled, '$value');
|
||||
await _set(_kEnabled, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(enabled: value));
|
||||
}
|
||||
|
||||
Future<void> setStrictness(int value) async {
|
||||
await ref
|
||||
.read(appDatabaseProvider)
|
||||
.settingsDao
|
||||
.setPreference(_kStrictness, '$value');
|
||||
await _set(_kStrictness, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(strictness: value));
|
||||
}
|
||||
|
||||
Future<void> setAiConsent(bool value) async {
|
||||
await _set(_kAiConsent, '$value');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(aiConsentGiven: value));
|
||||
}
|
||||
|
||||
Future<void> setAiModel(String value) async {
|
||||
await _set(_kAiModel, value);
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(current.copyWith(aiModel: value));
|
||||
}
|
||||
|
||||
Future<void> setAiDailyTokenLimit(int? value) async {
|
||||
await _set(_kAiDailyLimit, value?.toString() ?? '');
|
||||
final current = state.value ?? _defaultSettings;
|
||||
state = AsyncData(value == null
|
||||
? current.copyWith(clearDailyLimit: true)
|
||||
: current.copyWith(aiDailyTokenLimit: value));
|
||||
}
|
||||
|
||||
/// Свежая проверка дневного лимита: читает счётчик по сегодняшнему ключу,
|
||||
/// поэтому корректна даже после смены суток (когда кешированное
|
||||
/// [ParsingSettings.tokensUsedToday] ещё относится ко вчерашнему дню).
|
||||
Future<bool> isDailyLimitReached() async {
|
||||
final limit = (state.value ?? _defaultSettings).aiDailyTokenLimit;
|
||||
if (limit == null) return false;
|
||||
final dao = ref.read(appDatabaseProvider).settingsDao;
|
||||
final used =
|
||||
int.tryParse(await dao.getPreference(_tokenUsageKey()) ?? '') ?? 0;
|
||||
return used >= limit;
|
||||
}
|
||||
|
||||
/// Прибавляет израсходованные токены к счётчику текущего дня.
|
||||
Future<void> addTokenUsage(int tokens) async {
|
||||
if (tokens <= 0) return;
|
||||
final dao = ref.read(appDatabaseProvider).settingsDao;
|
||||
final key = _tokenUsageKey();
|
||||
final current = int.tryParse(await dao.getPreference(key) ?? '') ?? 0;
|
||||
final next = current + tokens;
|
||||
await dao.setPreference(key, '$next');
|
||||
final s = state.value ?? _defaultSettings;
|
||||
state = AsyncData(s.copyWith(tokensUsedToday: next));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.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/parser/account_resolver.dart';
|
||||
import '../data/parser/ai_parser.dart';
|
||||
import '../data/parser/confidence_scorer.dart';
|
||||
import '../data/parser/decision_gate.dart';
|
||||
import '../data/parser/draft_codec.dart';
|
||||
@@ -14,6 +17,7 @@ import '../domain/entities/raw_message.dart';
|
||||
import '../domain/entities/rule_candidate.dart';
|
||||
import '../domain/entities/rule_suggestion.dart';
|
||||
import '../domain/enums.dart';
|
||||
import 'ai_providers.dart';
|
||||
import 'notification_parsing_providers.dart';
|
||||
import 'parsing_settings_controller.dart';
|
||||
|
||||
@@ -25,13 +29,17 @@ Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
|
||||
|
||||
/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline
|
||||
/// regex → resolver → rule_lookup → suggester → scorer → gate по одному.
|
||||
/// regex → (AI fallback) → resolver → rule_lookup → suggester → scorer → gate.
|
||||
///
|
||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
|
||||
@Riverpod(keepAlive: true)
|
||||
class ParsingWorker extends _$ParsingWorker {
|
||||
final Set<String> _inFlight = {};
|
||||
// Стартуем как «офлайн», чтобы первая эмиссия `isOnline` со значением
|
||||
// `true` дала переход false→true и реквью застрявших `pending_ai`
|
||||
// (иначе при перезапуске приложения они не ретраятся — §7).
|
||||
bool _wasOnline = false;
|
||||
|
||||
@override
|
||||
void build(String userId) {
|
||||
@@ -46,6 +54,29 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.onDispose(sub.close);
|
||||
|
||||
// Retry `pending_ai` при восстановлении сети (§7).
|
||||
final onlineSub = ref.listen(
|
||||
isOnlineProvider,
|
||||
(_, next) {
|
||||
final online = next.value;
|
||||
if (online == null) return;
|
||||
if (online && !_wasOnline) {
|
||||
_requeuePendingAi(userId);
|
||||
}
|
||||
_wasOnline = online;
|
||||
},
|
||||
);
|
||||
ref.onDispose(onlineSub.close);
|
||||
}
|
||||
|
||||
/// Переводит ожидающие сети `pending_ai` обратно в `pending` (retry).
|
||||
Future<void> _requeuePendingAi(String userId) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
final list = await repo.watchPendingAi(userId).first;
|
||||
for (final m in list) {
|
||||
await repo.updateStatus(m.id, RawMessageStatus.pending);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _drain(String userId, List<RawMessage> pending) async {
|
||||
@@ -54,12 +85,12 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
_inFlight.add(msg.id);
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
try {
|
||||
await repo.incrementParseAttempts(msg.id);
|
||||
await _process(userId, msg);
|
||||
} catch (e) {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.failed,
|
||||
lastParseError: e.toString(),
|
||||
);
|
||||
} finally {
|
||||
_inFlight.remove(msg.id);
|
||||
@@ -68,15 +99,38 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
}
|
||||
|
||||
Future<void> _process(String userId, RawMessage msg) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
|
||||
final settings = await ref.read(parsingSettingsControllerProvider.future);
|
||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||
|
||||
// 1. Regex (Phase 1 — без AI fallback).
|
||||
// 1. Regex (этап 1).
|
||||
final parsed = const RegexParser().parse(msg);
|
||||
if (parsed == null) {
|
||||
// Не распознано: баланс/реклама → ignored, иначе → Inbox (вручную).
|
||||
if (parsed != null) {
|
||||
await _runPipeline(userId, msg, parsed, settings);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Regex не справился → AI fallback (этап 2) или legacy-поведение.
|
||||
await _handleRegexMiss(userId, msg, settings);
|
||||
}
|
||||
|
||||
/// Ветка «regex не распознал»: зовём AI (если разрешено), иначе Inbox/ignored.
|
||||
Future<void> _handleRegexMiss(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings,
|
||||
) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = ref.read(parsingSettingsControllerProvider.notifier);
|
||||
|
||||
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
|
||||
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
|
||||
// состояние контроллера могло устареть при смене суток.
|
||||
final aiAllowed =
|
||||
settings.aiConsentGiven && !(await settingsCtrl.isDailyLimitReached());
|
||||
final aiParser = aiAllowed ? await ref.read(aiParserProvider.future) : null;
|
||||
|
||||
if (aiParser == null) {
|
||||
// Legacy (Phase 1): баланс/реклама → ignored, иначе → Inbox вручную.
|
||||
if (looksLikeNonTransaction(msg.body)) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
} else {
|
||||
@@ -85,16 +139,102 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
// Слишком много РЕАЛЬНЫХ попыток AI — сдаёмся (§7). Счётчик инкрементится
|
||||
// только перед фактическим вызовом модели (ниже), офлайн-ожидание не в счёт.
|
||||
if (msg.parseAttemptCount >= 5) {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.failed,
|
||||
lastParseError: 'AI retry limit reached',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline → отложим до восстановления сети (без расхода попытки).
|
||||
final online = ref.read(isOnlineProvider).value ?? true;
|
||||
if (!online) {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.pendingAi,
|
||||
lastParseError: 'offline',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Реальная попытка обращения к модели — учитываем в счётчике попыток.
|
||||
await repo.incrementParseAttempts(msg.id);
|
||||
|
||||
try {
|
||||
final categories =
|
||||
await ref.read(categoryRepositoryProvider).watchByUser(userId).first;
|
||||
final outcome = await aiParser.parse(
|
||||
msg: msg,
|
||||
model: settings.aiModel,
|
||||
categoryNames: categories.map((c) => c.name).toList(),
|
||||
);
|
||||
if (outcome.tokensUsed > 0) {
|
||||
await ref
|
||||
.read(parsingSettingsControllerProvider.notifier)
|
||||
.addTokenUsage(outcome.tokensUsed);
|
||||
}
|
||||
switch (outcome.status) {
|
||||
case AiParseStatus.ignored:
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
case AiParseStatus.partial:
|
||||
// Недостаточно данных → Inbox с сырым текстом (без draft).
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.parsedPartial,
|
||||
);
|
||||
case AiParseStatus.draft:
|
||||
await _runPipeline(userId, msg, outcome.draft!, settings,
|
||||
categories: categories);
|
||||
}
|
||||
} on OpenRouterNetworkException {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.pendingAi,
|
||||
lastParseError: 'network',
|
||||
);
|
||||
} on OpenRouterAuthException catch (e) {
|
||||
// Неверный/просроченный ключ: отключаем AI, чтобы не долбить API на
|
||||
// каждом последующем сообщении. Пользователь введёт корректный ключ
|
||||
// заново на экране согласия (это снова включит AI).
|
||||
await settingsCtrl.setAiConsent(false);
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.failed,
|
||||
lastParseError: 'AI auth failed (${e.statusCode})',
|
||||
);
|
||||
} on OpenRouterException catch (e) {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.failed,
|
||||
lastParseError: e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Общий хвост pipeline (§5, шаги 3–8) для regex- и AI-draft.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParseDraft draft0,
|
||||
ParsingSettings settings, {
|
||||
List<Category>? categories,
|
||||
}) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
|
||||
// 3. Разрешение счёта.
|
||||
final resolver =
|
||||
AccountResolver(ref.read(accountBindingsRepositoryProvider));
|
||||
final resolution = await resolver.resolve(
|
||||
userId: userId,
|
||||
packageName: msg.packageName,
|
||||
cardLast4: parsed.cardLast4,
|
||||
phone: parsed.counterpartyPhone,
|
||||
cardLast4: draft0.cardLast4,
|
||||
phone: draft0.counterpartyPhone,
|
||||
);
|
||||
var draft = parsed.copyWith(accountId: resolution.accountId);
|
||||
var draft = draft0.copyWith(accountId: resolution.accountId);
|
||||
|
||||
// 4. Правила: исключение → ignored; иначе ищем merchantToCategory.
|
||||
final rules =
|
||||
@@ -109,11 +249,12 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
|
||||
RuleCandidate? candidate;
|
||||
if (draft.merchantRaw != null) {
|
||||
candidate = await ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
|
||||
userId,
|
||||
ParseRuleKind.merchantToCategory,
|
||||
draft.merchantRaw!,
|
||||
);
|
||||
candidate =
|
||||
await ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
|
||||
userId,
|
||||
ParseRuleKind.merchantToCategory,
|
||||
draft.merchantRaw!,
|
||||
);
|
||||
}
|
||||
|
||||
// Применяем действие правила к draft.
|
||||
@@ -128,9 +269,27 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
// Предложение правила для Inbox (только для незнакомого мерчанта).
|
||||
RuleSuggestion? suggestion;
|
||||
if (rule == null && draft.merchantRaw != null) {
|
||||
suggestion =
|
||||
suggestRule(merchantRaw: draft.merchantRaw!, categoryCandidate: candidate);
|
||||
if (suggestion.categoryId != null) {
|
||||
// AI-подсказка категории: матчим имя на существующую категорию (§7).
|
||||
String? aiCategoryId;
|
||||
String? aiCategoryName;
|
||||
if (candidate == null && draft.categorySuggestion != null) {
|
||||
final cats = categories ??
|
||||
await ref.read(categoryRepositoryProvider).watchByUser(userId).first;
|
||||
final match = _matchCategoryByName(cats, draft.categorySuggestion!);
|
||||
if (match != null) {
|
||||
aiCategoryId = match.id;
|
||||
aiCategoryName = match.name;
|
||||
}
|
||||
}
|
||||
|
||||
suggestion = suggestRule(
|
||||
merchantRaw: draft.merchantRaw!,
|
||||
categoryCandidate: candidate,
|
||||
aiCategoryId: aiCategoryId,
|
||||
aiCategoryName: aiCategoryName,
|
||||
);
|
||||
// Имя категории для случая кандидата (когда AI-имя не подставлено).
|
||||
if (suggestion.categoryId != null && suggestion.categoryName == null) {
|
||||
final cat = await ref
|
||||
.read(categoryRepositoryProvider)
|
||||
.findById(suggestion.categoryId!);
|
||||
@@ -174,6 +333,14 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
}
|
||||
}
|
||||
|
||||
Category? _matchCategoryByName(List<Category> cats, String name) {
|
||||
final target = name.trim().toLowerCase();
|
||||
for (final c in cats) {
|
||||
if (c.name.trim().toLowerCase() == target) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _autoApply(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
|
||||
@@ -21,20 +21,34 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Поток сообщений, ожидающих подтверждения в Inbox.
|
||||
/// Поток сообщений, ожидающих сети для AI-разбора — для retry воркером (§7).
|
||||
Stream<List<RawMessagesTableData>> watchPendingAi(String userId) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) &
|
||||
t.status.equalsValue(RawMessageStatus.pendingAi))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Поток сообщений, ожидающих внимания в Inbox: распознанные (`inbox`),
|
||||
/// неполные (`parsed_partial`) и ошибочные (`failed`) — последние две для
|
||||
/// AI-ветки (§7) показываются с сырым текстом и кнопкой «попробовать снова».
|
||||
Stream<List<RawMessagesTableData>> watchInbox(String userId) =>
|
||||
(select(rawMessagesTable)
|
||||
..where((t) =>
|
||||
t.userId.equals(userId) &
|
||||
t.status.equalsValue(RawMessageStatus.inbox))
|
||||
(t.status.equalsValue(RawMessageStatus.inbox) |
|
||||
t.status.equalsValue(RawMessageStatus.parsedPartial) |
|
||||
t.status.equalsValue(RawMessageStatus.failed)))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||
.watch();
|
||||
|
||||
/// Реактивный счётчик для бэджа на Home.
|
||||
/// Реактивный счётчик для бэджа на Home (inbox + parsed_partial + failed).
|
||||
Stream<int> watchInboxCount(String userId) {
|
||||
final query = customSelect(
|
||||
'SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? AND status = ?',
|
||||
variables: [Variable<String>(userId), Variable<String>('inbox')],
|
||||
"SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? "
|
||||
"AND status IN ('inbox', 'parsedPartial', 'failed')",
|
||||
variables: [Variable<String>(userId)],
|
||||
readsFrom: {rawMessagesTable},
|
||||
);
|
||||
return query
|
||||
@@ -83,6 +97,9 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
);
|
||||
|
||||
/// Запись результатов парсинга (draft + confidence-оценки).
|
||||
///
|
||||
/// [lastParseError] пишется только если передан (диагностика
|
||||
/// `pending_ai`/`failed`); иначе колонка не трогается.
|
||||
Future<void> updateAfterParse({
|
||||
required String id,
|
||||
required RawMessageStatus status,
|
||||
@@ -92,6 +109,7 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
String? lastParseError,
|
||||
}) =>
|
||||
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
|
||||
RawMessagesTableCompanion(
|
||||
@@ -102,6 +120,9 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
|
||||
confidenceType: Value(confidenceType),
|
||||
confidenceMerchant: Value(confidenceMerchant),
|
||||
confidenceCategory: Value(confidenceCategory),
|
||||
lastParseError: lastParseError == null
|
||||
? const Value.absent()
|
||||
: Value(lastParseError),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Промпты для AI-разбора уведомлений (§7).
|
||||
///
|
||||
/// Вынесено в отдельный файл, чтобы текст промптов был в одном месте и легко
|
||||
/// правился без касания логики клиента/парсера. JSON-схема — рядом в
|
||||
/// [ai_schema.dart]; здесь только текстовые инструкции.
|
||||
|
||||
/// System-промпт. Требование структуры **дублируется текстом** (помимо
|
||||
/// `response_format: json_schema`), т.к. дешёвые модели OpenRouter часто
|
||||
/// игнорируют schema (§7).
|
||||
///
|
||||
/// [categoryNames] — существующие категории пользователя; модель должна
|
||||
/// выбирать `categorySuggestion` из них (или вернуть null), а не выдумывать.
|
||||
String buildSystemPrompt({required List<String> categoryNames}) {
|
||||
final categoriesLine = categoryNames.isEmpty
|
||||
? '(у пользователя нет категорий — верни null для categorySuggestion)'
|
||||
: categoryNames.join(', ');
|
||||
|
||||
return '''
|
||||
Ты — парсер банковских push-уведомлений. На вход дан текст одного уведомления
|
||||
(возможно на русском). Извлеки из него данные о финансовой операции и верни
|
||||
СТРОГО один JSON-объект без какого-либо текста до или после него.
|
||||
|
||||
Поля JSON:
|
||||
- "type": одно из "expense" | "income" | "transfer" | "ignored".
|
||||
"ignored" — если это не финансовая операция (баланс, реклама, код, доставка).
|
||||
- "amount": число (сумма операции в основной валюте, например 1240.50). > 0.
|
||||
- "currency": ISO-код, например "RUB". Если не ясно — "RUB".
|
||||
- "cardLast4": последние 4 цифры карты/счёта строкой, либо null.
|
||||
- "merchantRaw": сырое имя продавца/отправителя как в тексте, либо null.
|
||||
- "counterpartyName": ФИО контрагента (для переводов), либо null.
|
||||
- "counterpartyPhone": телефон контрагента (для переводов по СБП), либо null.
|
||||
- "dateTime": ISO-8601 дата-время операции из текста, либо null.
|
||||
- "kind": одно из "purchase" | "refund" | "transfer_out" | "transfer_in" | "fee" | "balance" | "other".
|
||||
- "categorySuggestion": наиболее подходящая категория из списка пользователя
|
||||
(точное название из списка) или null, если ничего не подходит.
|
||||
|
||||
Список категорий пользователя: $categoriesLine
|
||||
|
||||
Не придумывай суммы, которых нет в тексте. Если суммы нет — type="ignored".
|
||||
Верни только JSON-объект.''';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// 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'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// HTTP-клиент OpenRouter (§7). `client` инжектируется для тестируемости.
|
||||
class OpenRouterClient {
|
||||
OpenRouterClient({
|
||||
required http.Client client,
|
||||
required String apiKey,
|
||||
this.timeout = const Duration(seconds: 20),
|
||||
}) : _client = client,
|
||||
_apiKey = apiKey;
|
||||
|
||||
final http.Client _client;
|
||||
final String _apiKey;
|
||||
final Duration timeout;
|
||||
|
||||
static const _base = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/// Один запрос chat/completions. Возвращает текст ответа модели + usage.
|
||||
///
|
||||
/// Бросает [OpenRouterAuthException] на 401/403, [OpenRouterNetworkException]
|
||||
/// при отсутствии сети/таймауте (воркер трактует как offline → pending_ai),
|
||||
/// [OpenRouterApiException] на прочих не-2xx.
|
||||
Future<OpenRouterCompletion> chatCompletion({
|
||||
required String model,
|
||||
required String systemPrompt,
|
||||
required String userContent,
|
||||
Map<String, dynamic>? jsonSchema,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'model': model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
{'role': 'user', 'content': userContent},
|
||||
],
|
||||
if (jsonSchema != null)
|
||||
'response_format': {
|
||||
'type': 'json_schema',
|
||||
'json_schema': jsonSchema,
|
||||
},
|
||||
};
|
||||
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client
|
||||
.post(
|
||||
Uri.parse('$_base/chat/completions'),
|
||||
headers: _headers,
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(timeout);
|
||||
} on SocketException catch (e) {
|
||||
throw OpenRouterNetworkException(e.message);
|
||||
} on TimeoutException {
|
||||
throw OpenRouterNetworkException('timeout');
|
||||
} on http.ClientException catch (e) {
|
||||
throw OpenRouterNetworkException(e.message);
|
||||
}
|
||||
|
||||
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
throw OpenRouterAuthException(res.statusCode, res.body);
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw OpenRouterApiException(res.statusCode, res.body);
|
||||
}
|
||||
|
||||
final map = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
return OpenRouterCompletion.fromJson(map);
|
||||
}
|
||||
|
||||
/// Список доступных моделей — для дропдауна в настройках.
|
||||
/// При любой ошибке возвращает пустой список (UI откатывается на статику).
|
||||
Future<List<String>> listModels() async {
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$_base/models'), headers: _headers)
|
||||
.timeout(timeout);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) return const [];
|
||||
final map = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = map['data'];
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((m) => m['id'])
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
required this.content,
|
||||
required this.promptTokens,
|
||||
required this.completionTokens,
|
||||
required this.totalTokens,
|
||||
});
|
||||
|
||||
final String content;
|
||||
final int promptTokens;
|
||||
final int completionTokens;
|
||||
final int totalTokens;
|
||||
|
||||
factory OpenRouterCompletion.fromJson(Map<String, dynamic> json) {
|
||||
final choices = json['choices'];
|
||||
var content = '';
|
||||
if (choices is List && choices.isNotEmpty) {
|
||||
final msg = (choices.first as Map<String, dynamic>)['message'];
|
||||
if (msg is Map<String, dynamic>) {
|
||||
content = (msg['content'] as String?) ?? '';
|
||||
}
|
||||
}
|
||||
final usage = json['usage'] as Map<String, dynamic>?;
|
||||
int u(String k) => (usage?[k] as num?)?.toInt() ?? 0;
|
||||
return OpenRouterCompletion(
|
||||
content: content,
|
||||
promptTokens: u('prompt_tokens'),
|
||||
completionTokens: u('completion_tokens'),
|
||||
totalTokens: u('total_tokens'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Базовое исключение клиента OpenRouter.
|
||||
sealed class OpenRouterException implements Exception {
|
||||
const OpenRouterException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
}
|
||||
|
||||
/// Сеть недоступна / таймаут — трактуется воркером как offline (pending_ai).
|
||||
class OpenRouterNetworkException extends OpenRouterException {
|
||||
const OpenRouterNetworkException(super.message);
|
||||
}
|
||||
|
||||
/// Неверный/просроченный API key (401/403).
|
||||
class OpenRouterAuthException extends OpenRouterException {
|
||||
OpenRouterAuthException(this.statusCode, String body) : super(body);
|
||||
final int statusCode;
|
||||
}
|
||||
|
||||
/// Прочая ошибка API (не-2xx).
|
||||
class OpenRouterApiException extends OpenRouterException {
|
||||
OpenRouterApiException(this.statusCode, String body) : super(body);
|
||||
final int statusCode;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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 'ai_tolerant_json.dart';
|
||||
|
||||
/// Этап 2 pipeline (§7): разбор тела уведомления через OpenRouter, когда regex
|
||||
/// не справился. Результат — [AiParseOutcome]: draft / partial / ignored.
|
||||
///
|
||||
/// Сетевые сбои ([OpenRouterNetworkException]) пробрасываются — воркер трактует
|
||||
/// их как offline и ставит `pending_ai` для повторной обработки.
|
||||
class AiParser {
|
||||
const AiParser(this._client);
|
||||
|
||||
final OpenRouterClient _client;
|
||||
|
||||
Future<AiParseOutcome> parse({
|
||||
required RawMessage msg,
|
||||
required String model,
|
||||
required List<String> categoryNames,
|
||||
}) async {
|
||||
final completion = await _client.chatCompletion(
|
||||
model: model,
|
||||
systemPrompt: buildSystemPrompt(categoryNames: categoryNames),
|
||||
userContent: msg.body,
|
||||
jsonSchema: aiResponseJsonSchema,
|
||||
);
|
||||
final tokens = completion.totalTokens;
|
||||
|
||||
final json = extractJsonObject(completion.content);
|
||||
if (json == null) return AiParseOutcome.partial(tokens);
|
||||
|
||||
final typeStr = (json['type'] as String?)?.toLowerCase();
|
||||
final kind = _kindFrom(json['kind'] as String?);
|
||||
if (typeStr == 'ignored' || kind == TxKind.balance) {
|
||||
return AiParseOutcome.ignored(tokens);
|
||||
}
|
||||
|
||||
final type = _typeFrom(typeStr);
|
||||
final amount = _amountMinor(json['amount']);
|
||||
if (type == null || amount == null || amount <= 0) {
|
||||
return AiParseOutcome.partial(tokens);
|
||||
}
|
||||
|
||||
final draft = ParseDraft(
|
||||
rawMessageId: msg.id,
|
||||
type: type,
|
||||
amount: amount,
|
||||
currency: (json['currency'] as String?)?.trim().isNotEmpty == true
|
||||
? (json['currency'] as String).trim()
|
||||
: 'RUB',
|
||||
cardLast4: _str(json['cardLast4']),
|
||||
merchantRaw: _str(json['merchantRaw']),
|
||||
counterpartyName: _str(json['counterpartyName']),
|
||||
counterpartyPhone: _str(json['counterpartyPhone']),
|
||||
dateTime: _dateTime(json['dateTime']),
|
||||
kind: kind,
|
||||
categorySuggestion: _str(json['categorySuggestion']),
|
||||
source: ParseSource.ai,
|
||||
);
|
||||
return AiParseOutcome.draft(draft, tokens);
|
||||
}
|
||||
|
||||
TransactionType? _typeFrom(String? s) => switch (s) {
|
||||
'expense' => TransactionType.expense,
|
||||
'income' => TransactionType.income,
|
||||
'transfer' => TransactionType.transfer,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
TxKind? _kindFrom(String? s) => switch (s) {
|
||||
'purchase' => TxKind.purchase,
|
||||
'refund' => TxKind.refund,
|
||||
'transfer_out' => TxKind.transferOut,
|
||||
'transfer_in' => TxKind.transferIn,
|
||||
'fee' => TxKind.fee,
|
||||
'balance' => TxKind.balance,
|
||||
'other' => TxKind.other,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Сумма приходит как число в основной валюте (1240.50) → минорные единицы.
|
||||
int? _amountMinor(Object? raw) {
|
||||
if (raw is num) return (raw * 100).round();
|
||||
if (raw is String) {
|
||||
final v = double.tryParse(raw.replaceAll(',', '.').replaceAll(' ', ''));
|
||||
return v == null ? null : (v * 100).round();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _str(Object? v) {
|
||||
if (v is! String) return null;
|
||||
final t = v.trim();
|
||||
return t.isEmpty ? null : t;
|
||||
}
|
||||
|
||||
DateTime? _dateTime(Object? v) {
|
||||
if (v is! String || v.isEmpty) return null;
|
||||
return DateTime.tryParse(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Исход AI-разбора.
|
||||
enum AiParseStatus { draft, partial, ignored }
|
||||
|
||||
class AiParseOutcome {
|
||||
const AiParseOutcome._(this.status, this.draft, this.tokensUsed);
|
||||
|
||||
final AiParseStatus status;
|
||||
final ParseDraft? draft;
|
||||
final int tokensUsed;
|
||||
|
||||
factory AiParseOutcome.draft(ParseDraft draft, int tokens) =>
|
||||
AiParseOutcome._(AiParseStatus.draft, draft, tokens);
|
||||
|
||||
factory AiParseOutcome.partial(int tokens) =>
|
||||
AiParseOutcome._(AiParseStatus.partial, null, tokens);
|
||||
|
||||
factory AiParseOutcome.ignored(int tokens) =>
|
||||
AiParseOutcome._(AiParseStatus.ignored, null, tokens);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Tolerant-парсер JSON из произвольного текста ответа модели (§7.3).
|
||||
///
|
||||
/// Дешёвые модели OpenRouter часто игнорируют `response_format: json_schema`
|
||||
/// и оборачивают JSON в прозу или ```json-блоки. Эта функция вытаскивает
|
||||
/// первый сбалансированный `{...}`-объект и декодирует его.
|
||||
///
|
||||
/// Возвращает `null`, если сбалансированный объект не найден или не парсится.
|
||||
Map<String, dynamic>? extractJsonObject(String text) {
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
// Быстрый путь: весь текст — валидный JSON-объект.
|
||||
final trimmed = text.trim();
|
||||
final fast = _tryDecodeObject(trimmed);
|
||||
if (fast != null) return fast;
|
||||
|
||||
// Сканируем по сбалансированным скобкам, уважая строки и экранирование.
|
||||
var depth = 0;
|
||||
var start = -1;
|
||||
var inString = false;
|
||||
var escaped = false;
|
||||
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final ch = text[i];
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch == r'\') {
|
||||
escaped = true;
|
||||
} else if (ch == '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case '"':
|
||||
inString = true;
|
||||
case '{':
|
||||
if (depth == 0) start = i;
|
||||
depth++;
|
||||
case '}':
|
||||
if (depth > 0) {
|
||||
depth--;
|
||||
if (depth == 0 && start >= 0) {
|
||||
final candidate = text.substring(start, i + 1);
|
||||
final decoded = _tryDecodeObject(candidate);
|
||||
if (decoded != null) return decoded;
|
||||
start = -1; // не распарсилось — ищем следующий объект
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _tryDecodeObject(String s) {
|
||||
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(s);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import '../../domain/entities/parse_draft.dart';
|
||||
import '../../domain/entities/parse_rule.dart';
|
||||
import '../../domain/entities/raw_message.dart';
|
||||
import '../../domain/entities/rule_candidate.dart';
|
||||
import '../../domain/enums.dart';
|
||||
|
||||
/// Пять per-field оценок уверенности (0..100), сохраняются в `raw_messages`.
|
||||
class FieldScores {
|
||||
@@ -29,8 +30,8 @@ class FieldScores {
|
||||
/// Confidence играет вспомогательную роль: судьбу мерчанта решает наличие
|
||||
/// правила (§5), а эти оценки гейтят прочие поля и дают подсветку «?».
|
||||
///
|
||||
/// В Phase 1 источник draft — только regex (AI нет), поэтому amount/type при
|
||||
/// успешном шаблоне = 100; account приходит из resolver ([accountScore]).
|
||||
/// Источник draft влияет на amount/type: regex выдаёт максимум (100), AI —
|
||||
/// меньше (§8.1/§8.3), т.к. возможны галлюцинации.
|
||||
FieldScores scoreDraft({
|
||||
required ParseDraft draft,
|
||||
required RawMessage message,
|
||||
@@ -39,11 +40,18 @@ FieldScores scoreDraft({
|
||||
RuleCandidate? merchantCandidate,
|
||||
RuleCandidate? categoryCandidate,
|
||||
}) {
|
||||
// Amount: regex выдал единственную сумму → 100 (§8.1).
|
||||
var amount = 100;
|
||||
|
||||
// Type: bank template явно указал тип → 100 (§8.3).
|
||||
const type = 100;
|
||||
// Amount/Type зависят от источника draft.
|
||||
// regex: шаблон выдал единственную сумму и явный тип → 100 (§8.1, §8.3).
|
||||
// ai: §8.1 — есть ли вытащенное число в теле; §8.3 — тип «по контексту».
|
||||
int amount;
|
||||
int type;
|
||||
if (draft.source == ParseSource.ai) {
|
||||
amount = _amountAppearsInBody(draft.amount, message.body) ? 60 : 10;
|
||||
type = 70;
|
||||
} else {
|
||||
amount = 100;
|
||||
type = 100;
|
||||
}
|
||||
|
||||
// Merchant (§8.4).
|
||||
var merchant = _scoreMerchant(draft, merchantRule, merchantCandidate);
|
||||
@@ -100,12 +108,35 @@ int _scoreCategory(
|
||||
if (candidate != null) {
|
||||
return candidate.seenCount >= 3 ? 85 : 65;
|
||||
}
|
||||
// Новый мерчант без AI-подсказки (Phase 1) — слабо.
|
||||
// Новый мерчант: AI назвал «очевидную» категорию → 45 (§8.5), иначе слабо.
|
||||
if (draft.source == ParseSource.ai && draft.categorySuggestion != null) {
|
||||
return 45;
|
||||
}
|
||||
return 25;
|
||||
}
|
||||
|
||||
int _capAt(int value, int cap) => value > cap ? cap : value;
|
||||
|
||||
/// Грубая проверка §8.1 для AI: встречается ли вытащенная сумма в теле.
|
||||
/// Сверяем целую часть (рубли) — десятичные банк может опускать/округлять.
|
||||
///
|
||||
/// Сравниваем **числа целиком**, а не подстроку: иначе сумма 50 ложно
|
||||
/// «находилась» бы в номере карты *5012 или в любой группе цифр, содержащей
|
||||
/// «50». Вытаскиваем из тела числовые токены (с разделителями тысяч/копеек),
|
||||
/// отбрасываем дробную часть и сравниваем как целые.
|
||||
bool _amountAppearsInBody(int amountMinor, String body) {
|
||||
final major = amountMinor ~/ 100;
|
||||
if (major <= 0) return false;
|
||||
for (final m in RegExp(r'\d[\d\s.,]*\d|\d').allMatches(body)) {
|
||||
final token = m.group(0)!;
|
||||
// Убираем дробную часть (1 240,50 → 1 240) и все разделители.
|
||||
final intPart = token.replaceAll(RegExp(r'[.,]\d{1,2}$'), '');
|
||||
final digits = intPart.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digits.isNotEmpty && int.tryParse(digits) == major) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Сообщение похоже на «не транзакцию» (баланс/реклама) — §8.6 последнее правило.
|
||||
bool looksLikeNonTransaction(String body) {
|
||||
final lower = body.toLowerCase();
|
||||
|
||||
@@ -9,15 +9,21 @@ import 'merchant_normalizer.dart';
|
||||
/// «Создать правило «merchant → category»».
|
||||
///
|
||||
/// [categoryCandidate] — кандидат kind=merchantToCategory с тем же rawValue
|
||||
/// (его `resolvedValue` = categoryId). [categoryName] заполняется выше
|
||||
/// (контроллером) по справочнику категорий.
|
||||
/// (его `resolvedValue` = categoryId). Имеет приоритет над AI-подсказкой.
|
||||
/// [aiCategoryId]/[aiCategoryName] — категория, предложенная AI (§7):
|
||||
/// используется, когда кандидата ещё нет. [categoryName] заполняется выше
|
||||
/// (контроллером) по справочнику категорий для случая кандидата.
|
||||
RuleSuggestion suggestRule({
|
||||
required String merchantRaw,
|
||||
RuleCandidate? categoryCandidate,
|
||||
String? aiCategoryId,
|
||||
String? aiCategoryName,
|
||||
}) {
|
||||
final categoryId = categoryCandidate?.resolvedValue ?? aiCategoryId;
|
||||
return RuleSuggestion(
|
||||
merchantRaw: merchantRaw,
|
||||
merchantCanonical: normalizeMerchant(merchantRaw),
|
||||
categoryId: categoryCandidate?.resolvedValue,
|
||||
categoryId: categoryId,
|
||||
categoryName: categoryCandidate == null ? aiCategoryName : null,
|
||||
);
|
||||
}
|
||||
|
||||
+7
@@ -18,6 +18,11 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
.watchPending(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<List<RawMessage>> watchPendingAi(String userId) => _dao
|
||||
.watchPendingAi(userId)
|
||||
.map((rows) => rows.map((r) => r.toDomain()).toList());
|
||||
|
||||
@override
|
||||
Stream<List<RawMessage>> watchInbox(String userId) => _dao
|
||||
.watchInbox(userId)
|
||||
@@ -83,6 +88,7 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
String? lastParseError,
|
||||
}) =>
|
||||
_dao.updateAfterParse(
|
||||
id: id,
|
||||
@@ -93,6 +99,7 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
||||
confidenceType: confidenceType,
|
||||
confidenceMerchant: confidenceMerchant,
|
||||
confidenceCategory: confidenceCategory,
|
||||
lastParseError: lastParseError,
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Хранилище OpenRouter API key в защищённом storage (§14).
|
||||
///
|
||||
/// Ключ никогда не пишется в логи и не хранится в `app_preferences`.
|
||||
class AiKeyStore {
|
||||
const AiKeyStore(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const _key = 'openrouter_api_key';
|
||||
|
||||
Future<String?> getApiKey() => _storage.read(key: _key);
|
||||
|
||||
Future<void> setApiKey(String value) =>
|
||||
_storage.write(key: _key, value: value);
|
||||
|
||||
Future<void> clear() => _storage.delete(key: _key);
|
||||
|
||||
Future<bool> hasKey() async {
|
||||
final v = await _storage.read(key: _key);
|
||||
return v != null && v.isNotEmpty;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ abstract interface class RawMessagesRepository {
|
||||
/// Необработанные сообщения — источник для ParsingWorker.
|
||||
Stream<List<RawMessage>> watchPending(String userId);
|
||||
|
||||
/// Сообщения, ожидающие сети для AI-разбора — для retry воркером (§7).
|
||||
Stream<List<RawMessage>> watchPendingAi(String userId);
|
||||
|
||||
/// Сообщения, ожидающие подтверждения в Inbox.
|
||||
Stream<List<RawMessage>> watchInbox(String userId);
|
||||
|
||||
@@ -45,6 +48,7 @@ abstract interface class RawMessagesRepository {
|
||||
int? confidenceType,
|
||||
int? confidenceMerchant,
|
||||
int? confidenceCategory,
|
||||
String? lastParseError,
|
||||
});
|
||||
|
||||
/// Привязка к созданной транзакции (status → applied).
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../app/l10n/l10n.dart';
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../application/ai_providers.dart';
|
||||
import '../../application/parsing_settings_controller.dart';
|
||||
|
||||
/// Экран privacy-consent + ввод API key и выбор модели (§7, §12.6).
|
||||
///
|
||||
/// До явного согласия здесь AI не вызывается воркером. «Только regex»
|
||||
/// выключает AI полностью.
|
||||
class AiConsentScreen extends ConsumerStatefulWidget {
|
||||
const AiConsentScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AiConsentScreen> createState() => _AiConsentScreenState();
|
||||
}
|
||||
|
||||
class _AiConsentScreenState extends ConsumerState<AiConsentScreen> {
|
||||
final _keyController = TextEditingController();
|
||||
final _modelController = TextEditingController();
|
||||
bool _loaded = false;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final key = await ref.read(aiKeyStoreProvider).getApiKey();
|
||||
final settings = await ref.read(parsingSettingsControllerProvider.future);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_keyController.text = key ?? '';
|
||||
_modelController.text = settings.aiModel;
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyController.dispose();
|
||||
_modelController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _enableAi() async {
|
||||
final l10n = context.l10n;
|
||||
final key = _keyController.text.trim();
|
||||
if (key.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.aiConsentKeyMissing)));
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
await ref.read(aiKeyStoreProvider).setApiKey(key);
|
||||
// Сбросить кеш AI-парсера, чтобы он пересобрался на новом ключе.
|
||||
ref.invalidate(aiParserProvider);
|
||||
final controller = ref.read(parsingSettingsControllerProvider.notifier);
|
||||
final model = _modelController.text.trim();
|
||||
if (model.isNotEmpty) await controller.setAiModel(model);
|
||||
await controller.setAiConsent(true);
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.aiConsentSaved)));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
Future<void> _regexOnly() async {
|
||||
await ref
|
||||
.read(parsingSettingsControllerProvider.notifier)
|
||||
.setAiConsent(false);
|
||||
if (!mounted) return;
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
appBar: AppBar(
|
||||
backgroundColor: p.paper,
|
||||
title: Text(l10n.aiConsentTitle),
|
||||
),
|
||||
body: !_loaded
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
children: [
|
||||
Text(
|
||||
l10n.aiConsentBody,
|
||||
style: TextStyle(fontSize: 14, color: p.ink2, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.aiConsentKeyLabel,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _keyController,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.aiConsentKeyHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.aiConsentModelLabel,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _modelController,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _enableAi,
|
||||
child: Text(l10n.aiConsentAllow),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _saving ? null : _regexOnly,
|
||||
child: Text(l10n.aiConsentRegexOnly),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -80,14 +80,40 @@ class ParsingSettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
_Card(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.smart_toy_outlined, color: p.line2),
|
||||
title: Text(l10n.parsingAiComingSoon,
|
||||
style: TextStyle(fontSize: 13, color: p.ink2)),
|
||||
enabled: false,
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
secondary: Icon(Icons.smart_toy_outlined, color: p.ink2),
|
||||
title: Text(l10n.parsingAiSectionTitle,
|
||||
style: TextStyle(fontSize: 14, color: p.ink)),
|
||||
value: settings?.aiConsentGiven ?? false,
|
||||
activeThumbColor: p.accent,
|
||||
onChanged: settings == null
|
||||
? null
|
||||
: (v) {
|
||||
if (v) {
|
||||
context.push(AppRoutes.parsingAi);
|
||||
} else {
|
||||
controller.setAiConsent(false);
|
||||
}
|
||||
},
|
||||
),
|
||||
_NavTile(
|
||||
icon: Icons.tune_outlined,
|
||||
title: l10n.parsingAiConfigure,
|
||||
onTap: () => context.push(AppRoutes.parsingAi),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (settings != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
settings.aiDailyTokenLimit == null
|
||||
? l10n.parsingAiTokensToday(settings.tokensUsedToday)
|
||||
: l10n.parsingAiTokensTodayLimited(
|
||||
settings.tokensUsedToday, settings.aiDailyTokenLimit!),
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.parsingRulesCreatedCount(rulesCount),
|
||||
style: TextStyle(fontSize: 12, color: p.ink2)),
|
||||
|
||||
Reference in New Issue
Block a user