Files
BudgetApp/lib/services/ai_transaction_processing_service.dart
T
2025-08-02 15:25:25 +03:00

494 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart';
import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart';
import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:budget_app/models/transaction_record.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/category.dart';
import 'package:budget_app/services/interfaces/iai_service.dart';
import 'package:logger/logger.dart';
/// Сервис для обработки SMS с помощью ИИ и создания транзакций
class AiTransactionProcessingService {
final IAiService _aiService;
final IPrefilledTransactionRepository _prefilledRepository;
final ICategoryRepository _categoryRepository;
final IAiRuleRepository _aiRuleRepository;
final Logger _logger = Logger();
AiTransactionProcessingService({
required IAiService aiService,
required IPrefilledTransactionRepository prefilledRepository,
required ICategoryRepository categoryRepository,
required IAiRuleRepository aiRuleRepository,
}) : _aiService = aiService,
_prefilledRepository = prefilledRepository,
_categoryRepository = categoryRepository,
_aiRuleRepository = aiRuleRepository;
/// Создает промпт для ИИ анализа SMS сообщения
Future<String> _buildAiPrompt(String smsBody) async {
// Получаем все доступные категории
final categories = await _categoryRepository.getAll();
final expenseCategories = categories.where((cat) => !cat.isIncome).toList();
final incomeCategories = categories.where((cat) => cat.isIncome).toList();
// Формируем список категорий для промпта (реальные названия)
final expenseCategoriesText = expenseCategories
.map((cat) => '"${cat.name}"')
.join(', ');
final incomeCategoriesText = incomeCategories
.map((cat) => '"${cat.name}"')
.join(', ');
return '''
=== ЗАДАЧА ===
Проанализируй SMS сообщение и определи:
1. Это финансовая транзакция или нет?
2. Если транзакция - извлеки сумму, продавца и категорию
3. Если не транзакция - создай regex для исключения похожих сообщений
=== SMS ДЛЯ АНАЛИЗА ===
"$smsBody"
=== ДОСТУПНЫЕ КАТЕГОРИИ ===
Для расходов: $expenseCategoriesText
Для доходов: $incomeCategoriesText
=== ФОРМАТ ОТВЕТА ===
Верни ответ ТОЛЬКО в виде JSON без дополнительных комментариев:
Если это финансовая транзакция:
{
"isTransaction": true,
"amount": сумма (число, положительное для доходов, отрицательное для расходов),
"vendor": "название продавца/получателя",
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": "выбери ТОЧНОЕ название из доступных категорий выше",
"exclusionRegex": null
}
Если это НЕ финансовая транзакция (реклама, уведомления, спам и т.д.):
{
"isTransaction": false,
"amount": null,
"vendor": null,
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": null,
"exclusionRegex": "регулярное выражение для исключения похожих SMS в будущем"
}
=== ПРИМЕРЫ ===
SMS: "СБЕРБАНК: Покупка 1250.00р METRO CASH 15.01.2024 12:34"
Ответ:
{
"isTransaction": true,
"amount": -1250.00,
"vendor": "METRO CASH",
"confidence": 0.95,
"suggestedCategory": "Покупки",
"exclusionRegex": null
}
SMS: "Зарплата поступила на счет 50000.00р"
Ответ:
{
"isTransaction": true,
"amount": 50000.00,
"vendor": "Работодатель",
"confidence": 0.99,
"suggestedCategory": "Зарплата",
"exclusionRegex": null
}
SMS: "Получи кредит за 5 минут! Одобрим всех!"
Ответ:
{
"isTransaction": false,
"amount": null,
"vendor": null,
"confidence": 0.90,
"suggestedCategory": null,
"exclusionRegex": ".*кредит.*одобр.*"
}
=== ВАЖНЫЕ ПРАВИЛА ===
• exclusionRegex - только для НЕ-транзакций (реклама, спам, уведомления)
• suggestedCategory - только из списка выше, точное название
• amount - отрицательное для расходов, положительное для доходов
• vendor - короткое название продавца/источника без лишних деталей
• confidence - от 0 до 1 (насколько уверен в анализе)
''';
}
/// Анализирует SMS с помощью ИИ и создает PrefilledTransaction
Future<PrefilledTransaction> processSmsByAi(String smsBody, String smsMessageId) async {
// Валидация входных данных
if (smsBody.trim().isEmpty) {
final error = 'Пустое SMS сообщение';
_logger.e('$error для ID: $smsMessageId');
throw AiProcessingException(error, smsId: smsMessageId, context: 'AiProcessing');
}
if (smsMessageId.trim().isEmpty) {
final error = 'Пустой ID SMS сообщения';
_logger.e('$error для: ${smsBody.substring(0, 50).replaceAll('\n', ' ')}...');
throw AiProcessingException(error, context: 'AiProcessing');
}
if (!_aiService.isConfigured()) {
final error = 'ИИ сервис не настроен';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiConfigurationException(error, context: 'AiProcessing');
}
try {
_logger.d('Начинаю обработку SMS ID: $smsMessageId, длина: ${smsBody.length} символов');
final prompt = await _buildAiPrompt(smsBody);
final aiResponse = await _aiService.sendMessage(prompt);
if (aiResponse.trim().isEmpty) {
final error = 'Пустой ответ от ИИ';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiProcessingException(error, smsId: smsMessageId, context: 'AiProcessing');
}
_logger.d('Получен ответ от ИИ для SMS ID: $smsMessageId, длина ответа: ${aiResponse.length}');
final aiData = _parseAiResponse(aiResponse);
if (aiData == null) {
final error = 'Не удалось распарсить ответ ИИ';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiResponseParsingException(error,
rawResponse: aiResponse, smsId: smsMessageId, context: 'AiProcessing');
}
_logger.d('Успешно распарсен ответ ИИ для SMS ID: $smsMessageId: $aiData');
// Проверяем тип сообщения
final isTransaction = aiData['isTransaction'] == true;
if (isTransaction) {
// Дополнительная валидация данных для транзакций
if (!_validateTransactionData(aiData)) {
final error = 'Некорректные данные транзакции от ИИ';
_logger.e('$error для SMS ID $smsMessageId: $aiData');
throw AiDataValidationException(error,
invalidData: aiData, smsId: smsMessageId, context: 'AiProcessing');
}
_logger.i('ИИ определил транзакционное SMS ID: $smsMessageId как ${aiData['vendor']} на сумму ${aiData['amount']}');
} else {
// Валидация данных для не-транзакционных SMS
if (!_validateNonTransactionData(aiData)) {
final error = 'Некорректные данные не-транзакции от ИИ';
_logger.e('$error для SMS ID $smsMessageId: $aiData');
throw AiDataValidationException(error,
invalidData: aiData, smsId: smsMessageId, context: 'AiProcessing');
}
_logger.i('ИИ определил не-транзакционное SMS ID: $smsMessageId с exclusion regex: ${aiData['exclusionRegex']}');
}
final result = await _createPrefilledFromAiResponse(aiData, smsMessageId);
_logger.i('Успешно создан PrefilledTransaction для SMS ID: $smsMessageId');
return result;
} on AiException {
// Перебрасываем AI исключения как есть
rethrow;
} catch (e, stackTrace) {
final error = 'Неожиданная ошибка при обработке SMS';
_logger.e('$error ID $smsMessageId: $e');
_logger.d('Stack trace: $stackTrace');
final exception = e is Exception ? e : Exception(e.toString());
throw AiProcessingException(error,
smsId: smsMessageId, context: 'AiProcessing', cause: exception);
}
}
/// Парсит ответ ИИ из JSON
Map<String, dynamic>? _parseAiResponse(String aiResponse) {
try {
// Очищаем ответ от возможных лишних символов
final cleanResponse = aiResponse.trim();
// Попробуем сначала распарсить весь ответ как JSON
try {
return jsonDecode(cleanResponse) as Map<String, dynamic>;
} catch (e) {
// Если не получилось, ищем JSON внутри текста
_logger.w('Попытка парсинга всего ответа как JSON не удалась, ищем JSON внутри текста: $e');
}
// Ищем JSON блок с правильным подсчетом скобок
int jsonStart = cleanResponse.indexOf('{');
if (jsonStart == -1) {
final error = 'JSON не найден в ответе ИИ (нет открывающей скобки)';
_logger.e('$error: $aiResponse');
// Возвращаем null чтобы вызывающий метод выбросил AiResponseParsingException
return null;
}
int braceCount = 0;
int jsonEnd = -1;
for (int i = jsonStart; i < cleanResponse.length; i++) {
if (cleanResponse[i] == '{') {
braceCount++;
} else if (cleanResponse[i] == '}') {
braceCount--;
if (braceCount == 0) {
jsonEnd = i;
break;
}
}
}
if (jsonEnd == -1) {
final error = 'JSON не найден в ответе ИИ (несбалансированные скобки)';
_logger.e('$error: $aiResponse');
// Возвращаем null чтобы вызывающий метод выбросил AiResponseParsingException
return null;
}
final jsonString = cleanResponse.substring(jsonStart, jsonEnd + 1);
_logger.d('Извлеченный JSON: $jsonString');
return jsonDecode(jsonString) as Map<String, dynamic>;
} catch (e) {
final error = 'Критическая ошибка парсинга ответа ИИ';
_logger.e('$error: $e, ответ: $aiResponse');
// Для критических ошибок парсинга выбрасываем исключение сразу
final exception = e is Exception ? e : Exception(e.toString());
throw AiResponseParsingException(error, rawResponse: aiResponse, context: 'AiProcessing', cause: exception);
}
}
/// Создает PrefilledTransaction из ответа ИИ
Future<PrefilledTransaction> _createPrefilledFromAiResponse(
Map<String, dynamic> aiData,
String smsMessageId
) async {
Category? suggestedCategory;
if (aiData['suggestedCategory'] != null) {
final categoryName = (aiData['suggestedCategory'] as String).trim();
final categories = await _categoryRepository.getAll();
// Ищем категорию по точному совпадению имени (регистронезависимо)
final exactMatch = categories.where((cat) =>
cat.name.toLowerCase().trim() == categoryName.toLowerCase()
).toList();
if (exactMatch.isNotEmpty) {
suggestedCategory = exactMatch.first;
_logger.d('Найдена точная категория для "$categoryName": ${suggestedCategory.name}');
} else {
// Если точного совпадения нет, ищем частичное для обратной совместимости
final partialMatch = categories.where((cat) =>
cat.name.toLowerCase().trim().contains(categoryName.toLowerCase()) ||
categoryName.toLowerCase().contains(cat.name.toLowerCase().trim())
).toList();
if (partialMatch.isNotEmpty) {
suggestedCategory = partialMatch.first;
_logger.d('Найдена частичная категория для "$categoryName": ${suggestedCategory.name}');
} else {
_logger.w('Категория не найдена для предложения: "$categoryName"');
}
}
}
final prefilled = PrefilledTransaction(
smsMessageId: smsMessageId,
transactionId: null, // Будет создан позже
amount: (aiData['amount'] as num?)?.toDouble(),
salesPoint: aiData['vendor'] as String?,
calculatedCategory: suggestedCategory,
confidence: ((aiData['confidence'] as num?)?.toDouble() ?? 0.0) * 100, // Конвертируем в проценты
exclusionRegex: aiData['exclusionRegex'] as String?,
);
// Сохраняем в репозиторий
await _prefilledRepository.add(prefilled);
return prefilled;
}
/// Преобразует PrefilledTransaction в TransactionRecord
Future<TransactionRecord> convertToTransactionRecord(
PrefilledTransaction prefilled,
{String? categoryId, String? tagId}
) async {
if (prefilled.amount == null || prefilled.salesPoint == null) {
throw ArgumentError('PrefilledTransaction должен содержать amount и salesPoint');
}
final record = TransactionRecord(
amount: prefilled.amount!,
dateTime: DateTime.now(), // В реальной реализации можно попытаться извлечь дату из SMS
vendor: prefilled.salesPoint!,
currency: 'RUB', // По умолчанию, можно сделать настраиваемым
categoryId: categoryId ?? prefilled.calculatedCategory?.id ?? 'unknown',
tagId: tagId,
);
// Обновляем prefilled с ID созданной транзакции
prefilled.transactionId = record.id;
await _prefilledRepository.update(prefilled);
return record;
}
/// Создает AiRule из PrefilledTransaction для автоматизации в будущем
Future<AiRule?> createAiRuleFromPrefilled(
PrefilledTransaction prefilled,
AiRuleType ruleType,
{String? customName}
) async {
AiRule rule;
switch (ruleType) {
case AiRuleType.pointOfSale:
// Для правил точки продаж нужны salesPoint и calculatedCategory
if (prefilled.salesPoint == null || prefilled.calculatedCategory == null) {
_logger.w('Недостаточно данных для создания правила точки продаж: salesPoint или calculatedCategory отсутствуют');
return null;
}
rule = AiRule(
name: customName ?? 'Автоправило для ${prefilled.salesPoint}',
type: AiRuleType.pointOfSale,
merchantPattern: _createMerchantPattern(prefilled.salesPoint!),
categoryId: prefilled.calculatedCategory!.id,
confidencePercentage: prefilled.confidence.round(),
);
break;
case AiRuleType.skipTemplate:
// Для skip правил нужен только exclusionRegex
if (prefilled.exclusionRegex == null || prefilled.exclusionRegex!.trim().isEmpty) {
_logger.w('Недостаточно данных для создания skip правила: exclusionRegex отсутствует');
return null;
}
// Определяем имя для skip правила
final ruleName = customName ??
(prefilled.salesPoint != null
? 'Пропуск для ${prefilled.salesPoint}'
: 'Автопропуск не-транзакционных SMS');
rule = AiRule(
name: ruleName,
type: AiRuleType.skipTemplate,
skipRegex: prefilled.exclusionRegex,
confidencePercentage: prefilled.confidence.round(),
);
break;
}
await _aiRuleRepository.add(rule);
return rule;
}
/// Валидирует данные транзакции от ИИ
bool _validateTransactionData(Map<String, dynamic> aiData) {
// Проверяем обязательные поля для транзакции
if (aiData['amount'] == null) {
_logger.w('Отсутствует поле amount в ответе ИИ для транзакции');
return false;
}
if (aiData['vendor'] == null || (aiData['vendor'] as String).trim().isEmpty) {
_logger.w('Отсутствует или пустое поле vendor в ответе ИИ для транзакции');
return false;
}
// Проверяем корректность суммы
final amount = aiData['amount'];
if (amount is! num || amount == 0) {
_logger.w('Некорректная сумма в ответе ИИ: $amount');
return false;
}
// Проверяем уверенность
final confidence = aiData['confidence'];
if (confidence != null && (confidence is! num || confidence < 0 || confidence > 1)) {
_logger.w('Некорректное значение confidence в ответе ИИ: $confidence');
return false;
}
// Для транзакций exclusionRegex должен быть null
if (aiData['exclusionRegex'] != null) {
_logger.w('Для транзакций exclusionRegex должен быть null');
return false;
}
return true;
}
/// Валидирует данные не-транзакционного SMS от ИИ
bool _validateNonTransactionData(Map<String, dynamic> aiData) {
// Проверяем уверенность
final confidence = aiData['confidence'];
if (confidence != null && (confidence is! num || confidence < 0 || confidence > 1)) {
_logger.w('Некорректное значение confidence в ответе ИИ: $confidence');
return false;
}
// Проверяем наличие exclusionRegex для не-транзакций
if (aiData['exclusionRegex'] == null || (aiData['exclusionRegex'] as String).trim().isEmpty) {
_logger.w('Отсутствует или пустое поле exclusionRegex для не-транзакции');
return false;
}
// Для не-транзакций amount и vendor должны быть null
if (aiData['amount'] != null) {
_logger.w('Для не-транзакций amount должен быть null');
return false;
}
if (aiData['vendor'] != null) {
_logger.w('Для не-транзакций vendor должен быть null');
return false;
}
return true;
}
/// Создает паттерн для поиска продавца в SMS
String _createMerchantPattern(String salesPoint) {
// Простой паттерн - ищем точное совпадение или частичное
final escaped = RegExp.escape(salesPoint);
return '.*$escaped.*';
}
// === Методы для тестирования (доступ к приватным методам) ===
/// Публичный метод для тестирования _buildAiPrompt
Future<String> buildAiPromptForTesting(String smsBody) async {
return await _buildAiPrompt(smsBody);
}
/// Публичный метод для тестирования _validateTransactionData
bool validateTransactionDataForTesting(Map<String, dynamic> aiData) {
return _validateTransactionData(aiData);
}
/// Публичный метод для тестирования _validateNonTransactionData
bool validateNonTransactionDataForTesting(Map<String, dynamic> aiData) {
return _validateNonTransactionData(aiData);
}
/// Публичный метод для тестирования _parseAiResponse
Map<String, dynamic>? parseAiResponseForTesting(String aiResponse) {
return _parseAiResponse(aiResponse);
}
/// Публичный метод для тестирования _createMerchantPattern
String createMerchantPatternForTesting(String salesPoint) {
return _createMerchantPattern(salesPoint);
}
}