Fix parse pipeline
This commit is contained in:
@@ -11,7 +11,6 @@ import '../../../accounts/application/accounts_controller.dart';
|
||||
import '../../../accounts/domain/entities/account.dart';
|
||||
import '../../../categories/application/categories_controller.dart';
|
||||
import '../../../categories/domain/entities/category.dart';
|
||||
import '../../../notification_parsing/application/parsing_worker.dart';
|
||||
import '../../../settings/application/settings_controller.dart';
|
||||
import '../../../transactions/domain/entities/transaction.dart';
|
||||
import '../../../user/application/active_user_controller.dart';
|
||||
@@ -60,8 +59,6 @@ class _HomeContent extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
// Активируем ParsingWorker (keepAlive): слушает raw_messages.pending.
|
||||
ref.watch(parsingWorkerProvider(userId));
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ??
|
||||
const <Category>[];
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../accounts/application/account_providers.dart';
|
||||
import '../../categories/application/category_providers.dart';
|
||||
import '../../categories/domain/entities/category.dart';
|
||||
import '../../transactions/application/transactions_controller.dart';
|
||||
import '../data/openrouter/openrouter_client.dart';
|
||||
import '../data/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';
|
||||
import '../data/parser/rule_lookup.dart';
|
||||
import '../data/parser/rule_suggester.dart';
|
||||
import '../domain/entities/parse_draft.dart';
|
||||
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';
|
||||
|
||||
part 'parsing_pipeline.g.dart';
|
||||
|
||||
/// Точка внедрения pipeline (для воркера, тестов и будущего фонового драйвера).
|
||||
@Riverpod(keepAlive: true)
|
||||
ParsingPipeline parsingPipeline(Ref ref) => ParsingPipeline(ref);
|
||||
|
||||
/// Per-message обработка одного `raw_message` (§5, шаги 3–8): allowlist →
|
||||
/// AI-извлечение → resolver → rule_lookup → suggester → scorer → gate.
|
||||
///
|
||||
/// Чистая логика «что делать», не знает, кто её запустил: `ParsingWorker`
|
||||
/// (foreground), будущий фоновый изолят/WorkManager или тесты. Зависимости
|
||||
/// читаются через [Ref] из текущего контейнера — провайдеры не UI-завязаны,
|
||||
/// поэтому pipeline работает в любом изоляте со своим `ProviderContainer`.
|
||||
///
|
||||
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
|
||||
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
|
||||
/// в Inbox на ручной разбор.
|
||||
class ParsingPipeline {
|
||||
ParsingPipeline(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> process(String userId, RawMessage msg) async {
|
||||
final settings = await _ref.read(parsingSettingsControllerProvider.future);
|
||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||
|
||||
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
|
||||
// ДО AI, чтобы не тратить токены на посторонние пакеты.
|
||||
final enabled =
|
||||
await _ref.read(enabledSourcePackagesProvider(userId).future);
|
||||
if (!enabled.contains(msg.packageName)) {
|
||||
await _ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||
await _extractViaAi(userId, msg, settings);
|
||||
}
|
||||
|
||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||
/// иначе Inbox на ручной разбор.
|
||||
Future<void> _extractViaAi(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings,
|
||||
) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = _ref.read(parsingSettingsControllerProvider.notifier);
|
||||
|
||||
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
|
||||
if (looksLikeNonTransaction(msg.body)) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Есть цифры → кандидат в транзакцию.
|
||||
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
|
||||
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
|
||||
// состояние контроллера могло устареть при смене суток.
|
||||
final aiAllowed =
|
||||
settings.aiConsentGiven && !(await settingsCtrl.isDailyLimitReached());
|
||||
final aiParser =
|
||||
aiAllowed ? await _ref.read(aiParserProvider.future) : null;
|
||||
|
||||
if (aiParser == null) {
|
||||
// AI недоступен → ручной разбор в Inbox.
|
||||
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
|
||||
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) для AI-draft.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParseDraft draft0,
|
||||
ParsingSettings settings, {
|
||||
List<Category>? categories,
|
||||
}) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
|
||||
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
|
||||
final rules =
|
||||
await _ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
||||
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
|
||||
null) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
|
||||
final globalDefaultAccountId = (await _ref
|
||||
.read(accountRepositoryProvider)
|
||||
.watchDefault(userId)
|
||||
.first)
|
||||
?.id;
|
||||
final resolver =
|
||||
AccountResolver(_ref.read(accountBindingsRepositoryProvider));
|
||||
final resolution = await resolver.resolve(
|
||||
userId: userId,
|
||||
packageName: msg.packageName,
|
||||
body: msg.body,
|
||||
cardLast4: draft0.cardLast4,
|
||||
phone: draft0.counterpartyPhone,
|
||||
merchantRaw: draft0.merchantRaw,
|
||||
senderRules: rules,
|
||||
globalDefaultAccountId: globalDefaultAccountId,
|
||||
);
|
||||
var draft = draft0.copyWith(accountId: resolution.accountId);
|
||||
|
||||
final rule =
|
||||
findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw);
|
||||
|
||||
RuleCandidate? candidate;
|
||||
if (draft.merchantRaw != null) {
|
||||
candidate =
|
||||
await _ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
|
||||
userId,
|
||||
ParseRuleKind.merchantToCategory,
|
||||
draft.merchantRaw!,
|
||||
);
|
||||
}
|
||||
|
||||
// Применяем действие правила к draft.
|
||||
if (rule != null) {
|
||||
draft = draft.copyWith(
|
||||
merchantCanonical: rule.merchantCanonical,
|
||||
categoryId: rule.categoryId,
|
||||
accountId: draft.accountId ?? rule.accountId,
|
||||
);
|
||||
}
|
||||
|
||||
// Предложение правила для Inbox (только для незнакомого мерчанта).
|
||||
RuleSuggestion? suggestion;
|
||||
if (rule == null && draft.merchantRaw != 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!);
|
||||
suggestion = suggestion.copyWith(categoryName: cat?.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Confidence.
|
||||
final scores = scoreDraft(
|
||||
draft: draft,
|
||||
message: msg,
|
||||
accountScore: resolution.score,
|
||||
merchantRule: rule,
|
||||
merchantCandidate: candidate,
|
||||
categoryCandidate: candidate,
|
||||
);
|
||||
|
||||
// 8. Gate.
|
||||
final decision = decide(
|
||||
merchantRule: rule,
|
||||
sanityPassed: true,
|
||||
scores: scores,
|
||||
strictness: settings.strictness,
|
||||
amountMinor: draft.amount,
|
||||
accountResolved: draft.accountId != null,
|
||||
accountTrusted: resolution.trusted,
|
||||
);
|
||||
|
||||
if (decision == GateDecision.autoApply) {
|
||||
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
|
||||
} else {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson: encodeDraftBundle(draft, suggestion),
|
||||
confidenceAmount: scores.amount,
|
||||
confidenceAccount: scores.account,
|
||||
confidenceType: scores.type,
|
||||
confidenceMerchant: scores.merchant,
|
||||
confidenceCategory: scores.category,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ParseDraft draft,
|
||||
rule,
|
||||
FieldScores scores,
|
||||
String? bindingId,
|
||||
) async {
|
||||
final repo = _ref.read(rawMessagesRepositoryProvider);
|
||||
final tx = await _ref
|
||||
.read(transactionsControllerProvider.notifier)
|
||||
.createTransaction(
|
||||
userId: userId,
|
||||
accountId: draft.accountId!,
|
||||
categoryId: draft.categoryId,
|
||||
type: draft.type,
|
||||
amount: draft.amount,
|
||||
date: draft.dateTime ?? msg.receivedAt,
|
||||
merchant: draft.merchantCanonical ?? draft.merchantRaw,
|
||||
rawMessageId: msg.id,
|
||||
autoApplied: true,
|
||||
appliedByRuleId: rule.id,
|
||||
);
|
||||
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.applied,
|
||||
draftJson: encodeDraftBundle(draft, null),
|
||||
confidenceAmount: scores.amount,
|
||||
confidenceAccount: scores.account,
|
||||
confidenceType: scores.type,
|
||||
confidenceMerchant: scores.merchant,
|
||||
confidenceCategory: scores.category,
|
||||
);
|
||||
await repo.linkTransaction(msg.id, tx.id);
|
||||
|
||||
await _ref
|
||||
.read(parseRulesRepositoryProvider)
|
||||
.incrementMatchCount(rule.id, DateTime.now());
|
||||
if (bindingId != null) {
|
||||
await _ref
|
||||
.read(accountBindingsRepositoryProvider)
|
||||
.incrementMatchCount(bindingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../accounts/application/account_providers.dart';
|
||||
import '../../categories/application/category_providers.dart';
|
||||
import '../../categories/domain/entities/category.dart';
|
||||
import '../../transactions/application/transactions_controller.dart';
|
||||
import '../data/openrouter/openrouter_client.dart';
|
||||
import '../data/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';
|
||||
import '../data/parser/rule_lookup.dart';
|
||||
import '../data/parser/rule_suggester.dart';
|
||||
import '../domain/entities/parse_draft.dart';
|
||||
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';
|
||||
import 'parsing_pipeline.dart';
|
||||
|
||||
part 'parsing_worker.g.dart';
|
||||
|
||||
@@ -28,12 +13,11 @@ part 'parsing_worker.g.dart';
|
||||
Stream<List<RawMessage>> pendingMessages(Ref ref, String userId) =>
|
||||
ref.watch(rawMessagesRepositoryProvider).watchPending(userId);
|
||||
|
||||
/// ParsingWorker (§5): слушает `raw_messages.pending` и прогоняет pipeline
|
||||
/// AI → resolver → rule_lookup → suggester → scorer → gate.
|
||||
///
|
||||
/// Извлечение делает только AI: встроенные regex-шаблоны убраны (не было
|
||||
/// видимого пользователю слоя). Без согласия/ключа/сети сообщение уходит
|
||||
/// в Inbox на ручной разбор.
|
||||
/// ParsingWorker (§5): foreground-драйвер pipeline. Слушает
|
||||
/// `raw_messages.pending` и прогоняет каждое сообщение через [ParsingPipeline].
|
||||
/// Отвечает только за «когда запускать» (триггер/дедуп/ретраи); сама обработка
|
||||
/// «что делать» вынесена в [ParsingPipeline], чтобы фоновый драйвер мог лечь
|
||||
/// поверх того же кода без дублирования.
|
||||
///
|
||||
/// Идемпотентен: при возврате сообщения в `pending` перепарсивается.
|
||||
/// Провайдер `keepAlive` — активируется чтением из HomeScreen.
|
||||
@@ -84,12 +68,13 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
}
|
||||
|
||||
Future<void> _drain(String userId, List<RawMessage> pending) async {
|
||||
final pipeline = ref.read(parsingPipelineProvider);
|
||||
for (final msg in pending) {
|
||||
if (_inFlight.contains(msg.id)) continue;
|
||||
_inFlight.add(msg.id);
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
try {
|
||||
await _process(userId, msg);
|
||||
await pipeline.process(userId, msg);
|
||||
} catch (e) {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
@@ -101,311 +86,4 @@ class ParsingWorker extends _$ParsingWorker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _process(String userId, RawMessage msg) async {
|
||||
final settings = await ref.read(parsingSettingsControllerProvider.future);
|
||||
if (!settings.enabled) return; // фича выключена — оставляем pending.
|
||||
|
||||
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
|
||||
// ДО AI, чтобы не тратить токены на посторонние пакеты.
|
||||
final enabled =
|
||||
await ref.read(enabledSourcePackagesProvider(userId).future);
|
||||
if (!enabled.contains(msg.packageName)) {
|
||||
await ref
|
||||
.read(rawMessagesRepositoryProvider)
|
||||
.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
|
||||
await _extractViaAi(userId, msg, settings);
|
||||
}
|
||||
|
||||
/// Извлечение через AI: спам без цифр → ignored; зовём AI (если разрешён),
|
||||
/// иначе Inbox на ручной разбор.
|
||||
Future<void> _extractViaAi(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParsingSettings settings,
|
||||
) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
final settingsCtrl = ref.read(parsingSettingsControllerProvider.notifier);
|
||||
|
||||
// Нет цифр → это не операция (спам/реклама) → ignored, AI не тратим.
|
||||
if (looksLikeNonTransaction(msg.body)) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Есть цифры → кандидат в транзакцию.
|
||||
// AI доступен только при согласии, наличии ключа и не исчерпанном лимите.
|
||||
// Лимит читаем «свежим» (счётчик дневной, привязан к дате) — кешированное
|
||||
// состояние контроллера могло устареть при смене суток.
|
||||
final aiAllowed =
|
||||
settings.aiConsentGiven && !(await settingsCtrl.isDailyLimitReached());
|
||||
final aiParser = aiAllowed ? await ref.read(aiParserProvider.future) : null;
|
||||
|
||||
if (aiParser == null) {
|
||||
// AI недоступен → ручной разбор в Inbox.
|
||||
await repo.updateAfterParse(id: msg.id, status: RawMessageStatus.inbox);
|
||||
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) для AI-draft.
|
||||
Future<void> _runPipeline(
|
||||
String userId,
|
||||
RawMessage msg,
|
||||
ParseDraft draft0,
|
||||
ParsingSettings settings, {
|
||||
List<Category>? categories,
|
||||
}) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
|
||||
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
|
||||
final rules =
|
||||
await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
|
||||
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
|
||||
null) {
|
||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
|
||||
final globalDefaultAccountId = (await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.watchDefault(userId)
|
||||
.first)
|
||||
?.id;
|
||||
final resolver =
|
||||
AccountResolver(ref.read(accountBindingsRepositoryProvider));
|
||||
final resolution = await resolver.resolve(
|
||||
userId: userId,
|
||||
packageName: msg.packageName,
|
||||
body: msg.body,
|
||||
cardLast4: draft0.cardLast4,
|
||||
phone: draft0.counterpartyPhone,
|
||||
merchantRaw: draft0.merchantRaw,
|
||||
senderRules: rules,
|
||||
globalDefaultAccountId: globalDefaultAccountId,
|
||||
);
|
||||
var draft = draft0.copyWith(accountId: resolution.accountId);
|
||||
|
||||
final rule =
|
||||
findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw);
|
||||
|
||||
RuleCandidate? candidate;
|
||||
if (draft.merchantRaw != null) {
|
||||
candidate =
|
||||
await ref.read(ruleCandidatesRepositoryProvider).findByRawValue(
|
||||
userId,
|
||||
ParseRuleKind.merchantToCategory,
|
||||
draft.merchantRaw!,
|
||||
);
|
||||
}
|
||||
|
||||
// Применяем действие правила к draft.
|
||||
if (rule != null) {
|
||||
draft = draft.copyWith(
|
||||
merchantCanonical: rule.merchantCanonical,
|
||||
categoryId: rule.categoryId,
|
||||
accountId: draft.accountId ?? rule.accountId,
|
||||
);
|
||||
}
|
||||
|
||||
// Предложение правила для Inbox (только для незнакомого мерчанта).
|
||||
RuleSuggestion? suggestion;
|
||||
if (rule == null && draft.merchantRaw != 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!);
|
||||
suggestion = suggestion.copyWith(categoryName: cat?.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Confidence.
|
||||
final scores = scoreDraft(
|
||||
draft: draft,
|
||||
message: msg,
|
||||
accountScore: resolution.score,
|
||||
merchantRule: rule,
|
||||
merchantCandidate: candidate,
|
||||
categoryCandidate: candidate,
|
||||
);
|
||||
|
||||
// 8. Gate.
|
||||
final decision = decide(
|
||||
merchantRule: rule,
|
||||
sanityPassed: true,
|
||||
scores: scores,
|
||||
strictness: settings.strictness,
|
||||
amountMinor: draft.amount,
|
||||
accountResolved: draft.accountId != null,
|
||||
accountTrusted: resolution.trusted,
|
||||
);
|
||||
|
||||
if (decision == GateDecision.autoApply) {
|
||||
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
|
||||
} else {
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.inbox,
|
||||
draftJson: encodeDraftBundle(draft, suggestion),
|
||||
confidenceAmount: scores.amount,
|
||||
confidenceAccount: scores.account,
|
||||
confidenceType: scores.type,
|
||||
confidenceMerchant: scores.merchant,
|
||||
confidenceCategory: scores.category,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ParseDraft draft,
|
||||
rule,
|
||||
FieldScores scores,
|
||||
String? bindingId,
|
||||
) async {
|
||||
final repo = ref.read(rawMessagesRepositoryProvider);
|
||||
final tx =
|
||||
await ref.read(transactionsControllerProvider.notifier).createTransaction(
|
||||
userId: userId,
|
||||
accountId: draft.accountId!,
|
||||
categoryId: draft.categoryId,
|
||||
type: draft.type,
|
||||
amount: draft.amount,
|
||||
date: draft.dateTime ?? msg.receivedAt,
|
||||
merchant: draft.merchantCanonical ?? draft.merchantRaw,
|
||||
rawMessageId: msg.id,
|
||||
autoApplied: true,
|
||||
appliedByRuleId: rule.id,
|
||||
);
|
||||
|
||||
await repo.updateAfterParse(
|
||||
id: msg.id,
|
||||
status: RawMessageStatus.applied,
|
||||
draftJson: encodeDraftBundle(draft, null),
|
||||
confidenceAmount: scores.amount,
|
||||
confidenceAccount: scores.account,
|
||||
confidenceType: scores.type,
|
||||
confidenceMerchant: scores.merchant,
|
||||
confidenceCategory: scores.category,
|
||||
);
|
||||
await repo.linkTransaction(msg.id, tx.id);
|
||||
|
||||
await ref
|
||||
.read(parseRulesRepositoryProvider)
|
||||
.incrementMatchCount(rule.id, DateTime.now());
|
||||
if (bindingId != null) {
|
||||
await ref
|
||||
.read(accountBindingsRepositoryProvider)
|
||||
.incrementMatchCount(bindingId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/notification_parsing/application/parsing_worker.dart';
|
||||
import '../../features/user/application/active_user_controller.dart';
|
||||
import 'app_bottom_nav.dart';
|
||||
|
||||
class AppScaffold extends StatelessWidget {
|
||||
class AppScaffold extends ConsumerWidget {
|
||||
const AppScaffold({super.key, required this.navigationShell});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Активируем ParsingWorker (keepAlive) на уровне оболочки, чтобы очередь
|
||||
// парсинга драйнилась на любой вкладке, пока приложение открыто, — а не
|
||||
// только при заходе на «Главную». До появления активного пользователя
|
||||
// (/onboarding вне shell) активировать нечего.
|
||||
final userId = ref.watch(activeUserControllerProvider).value?.id;
|
||||
if (userId != null) {
|
||||
ref.watch(parsingWorkerProvider(userId));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
|
||||
Reference in New Issue
Block a user