Stage ai for sms

This commit is contained in:
2026-05-30 20:38:00 +03:00
parent 1286012dda
commit 50d56b91d2
25 changed files with 1439 additions and 57 deletions
+32 -1
View File
@@ -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"
}
}
}
}
+72
View File
@@ -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
+41
View File
@@ -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';
}
}
+41
View File
@@ -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
View File
@@ -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"
}
}
}
}
+5
View File
@@ -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(),
+1
View File
@@ -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);
}
@@ -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, шаги 38) для 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,
);
}
@@ -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),
),
],
),
);
}
}
@@ -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)),