494 lines
21 KiB
Dart
494 lines
21 KiB
Dart
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);
|
||
}
|
||
} |