Remake ai

This commit is contained in:
2025-08-02 16:34:22 +03:00
parent 78b80904f5
commit f1739b81de
14 changed files with 1979 additions and 657 deletions
+5
View File
@@ -4,6 +4,7 @@
import 'package:hive_ce/hive.dart';
import 'package:budget_app/hive/hive_adapters.dart';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/ai_settings.dart';
import 'package:budget_app/models/app_settings.dart';
@@ -18,9 +19,11 @@ import 'package:budget_app/models/user.dart';
extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
@@ -40,9 +43,11 @@ extension HiveRegistrar on HiveInterface {
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
+148
View File
@@ -0,0 +1,148 @@
/// Типизированные модели для ответов AI сервиса
library;
import 'package:hive_ce/hive.dart';
part 'ai_response.g.dart';
/// Базовый класс для ответов AI (без Hive аннотаций, так как абстрактный)
abstract class AiResponse {
final bool isTransaction;
final double confidence;
const AiResponse({
required this.isTransaction,
required this.confidence,
});
/// Валидация базовых полей ответа AI
bool isValid() {
return confidence >= 0.0 && confidence <= 1.0;
}
}
/// Ответ AI для транзакционных SMS
@HiveType(typeId: 21)
class AiTransactionResponse extends AiResponse {
@HiveField(0)
final double amount;
@HiveField(1)
final String vendor;
@HiveField(2)
final String? suggestedCategory;
@HiveField(3)
@override
final double confidence;
const AiTransactionResponse({
required this.amount,
required this.vendor,
this.suggestedCategory,
required this.confidence,
}) : super(isTransaction: true, confidence: confidence);
@override
bool isValid() {
return super.isValid() &&
amount != 0.0 &&
vendor.trim().isNotEmpty;
}
factory AiTransactionResponse.fromJson(Map<String, dynamic> json) {
return AiTransactionResponse(
amount: (json['amount'] as num).toDouble(),
vendor: json['vendor'] as String,
suggestedCategory: json['suggestedCategory'] as String?,
confidence: (json['confidence'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'isTransaction': isTransaction,
'amount': amount,
'vendor': vendor,
'confidence': confidence,
'suggestedCategory': suggestedCategory,
'exclusionRegex': null,
};
}
@override
String toString() {
return 'AiTransactionResponse(amount: $amount, vendor: $vendor, '
'category: $suggestedCategory, confidence: $confidence)';
}
}
/// Ответ AI для не-транзакционных SMS (реклама, уведомления и т.д.)
@HiveType(typeId: 22)
class AiNonTransactionResponse extends AiResponse {
@HiveField(0)
final String exclusionRegex;
@HiveField(1)
@override
final double confidence;
const AiNonTransactionResponse({
required this.exclusionRegex,
required this.confidence,
}) : super(isTransaction: false, confidence: confidence);
@override
bool isValid() {
return super.isValid() && exclusionRegex.trim().isNotEmpty;
}
factory AiNonTransactionResponse.fromJson(Map<String, dynamic> json) {
return AiNonTransactionResponse(
exclusionRegex: json['exclusionRegex'] as String,
confidence: (json['confidence'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'isTransaction': isTransaction,
'amount': null,
'vendor': null,
'confidence': confidence,
'suggestedCategory': null,
'exclusionRegex': exclusionRegex,
};
}
@override
String toString() {
return 'AiNonTransactionResponse(exclusionRegex: $exclusionRegex, '
'confidence: $confidence)';
}
}
/// Фабрика для создания типизированных ответов AI
class AiResponseFactory {
/// Создает типизированный ответ AI из JSON Map
static AiResponse fromJson(Map<String, dynamic> json) {
final isTransaction = json['isTransaction'] as bool? ?? false;
if (isTransaction) {
return AiTransactionResponse.fromJson(json);
} else {
return AiNonTransactionResponse.fromJson(json);
}
}
/// Создает Map для сохранения из типизированного ответа
static Map<String, dynamic> toJson(AiResponse response) {
if (response is AiTransactionResponse) {
return response.toJson();
} else if (response is AiNonTransactionResponse) {
return response.toJson();
}
throw ArgumentError('Неизвестный тип ответа AI: ${response.runtimeType}');
}
}
+88
View File
@@ -0,0 +1,88 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ai_response.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AiTransactionResponseAdapter extends TypeAdapter<AiTransactionResponse> {
@override
final typeId = 21;
@override
AiTransactionResponse read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiTransactionResponse(
amount: (fields[0] as num).toDouble(),
vendor: fields[1] as String,
suggestedCategory: fields[2] as String?,
confidence: (fields[3] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, AiTransactionResponse obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.amount)
..writeByte(1)
..write(obj.vendor)
..writeByte(2)
..write(obj.suggestedCategory)
..writeByte(3)
..write(obj.confidence);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiTransactionResponseAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class AiNonTransactionResponseAdapter
extends TypeAdapter<AiNonTransactionResponse> {
@override
final typeId = 22;
@override
AiNonTransactionResponse read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiNonTransactionResponse(
exclusionRegex: fields[0] as String,
confidence: (fields[1] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, AiNonTransactionResponse obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.exclusionRegex)
..writeByte(1)
..write(obj.confidence);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiNonTransactionResponseAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+2 -6
View File
@@ -18,12 +18,8 @@ class AiSettingsAdapter extends TypeAdapter<AiSettings> {
};
return AiSettings(
apiKey: fields[0] as String?,
baseUrl: fields[1] == null
? 'https://openrouter.ai/api/v1'
: fields[1] as String,
defaultModel: fields[2] == null
? 'openai/gpt-3.5-turbo'
: fields[2] as String,
baseUrl: fields[1] as String?,
defaultModel: fields[2] as String?,
timeoutSeconds: fields[3] == null ? 30 : (fields[3] as num).toInt(),
temperature: fields[4] == null ? 0.7 : (fields[4] as num).toDouble(),
maxTokens: fields[5] == null ? 1000 : (fields[5] as num).toInt(),
+324
View File
@@ -0,0 +1,324 @@
/// Класс для валидации данных, полученных от AI сервиса
library;
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:logger/logger.dart';
/// Результат валидации AI данных
class ValidationResult {
final bool isValid;
final List<String> errors;
final List<String> warnings;
const ValidationResult({
required this.isValid,
this.errors = const [],
this.warnings = const [],
});
ValidationResult.valid() : this(isValid: true);
ValidationResult.invalid(List<String> errors, [List<String>? warnings])
: this(isValid: false, errors: errors, warnings: warnings ?? []);
bool get hasErrors => errors.isNotEmpty;
bool get hasWarnings => warnings.isNotEmpty;
@override
String toString() {
if (isValid) return 'ValidationResult.valid()';
return 'ValidationResult.invalid(errors: $errors, warnings: $warnings)';
}
}
/// Валидатор данных AI ответов
class AiDataValidator {
final Logger _logger = Logger();
/// Минимальное значение confidence (по умолчанию 0.0)
final double minConfidence;
/// Максимальное значение confidence (по умолчанию 1.0)
final double maxConfidence;
/// Максимальная длина vendor (по умолчанию 100 символов)
final int maxVendorLength;
/// Максимальная длина exclusionRegex (по умолчанию 500 символов)
final int maxRegexLength;
AiDataValidator({
this.minConfidence = 0.0,
this.maxConfidence = 1.0,
this.maxVendorLength = 100,
this.maxRegexLength = 500,
});
/// Валидирует ответ AI
ValidationResult validateResponse(AiResponse response, {String? smsId}) {
_logger.d('Валидация ответа AI${smsId != null ? ' для SMS ID: $smsId' : ''}');
try {
if (response is AiTransactionResponse) {
return _validateTransactionResponse(response, smsId: smsId);
} else if (response is AiNonTransactionResponse) {
return _validateNonTransactionResponse(response, smsId: smsId);
} else {
final error = 'Неизвестный тип ответа AI: ${response.runtimeType}';
_logger.e(error);
return ValidationResult.invalid([error]);
}
} catch (e, stackTrace) {
final error = 'Критическая ошибка валидации ответа AI: $e';
_logger.e(error);
_logger.d('Stack trace: $stackTrace');
return ValidationResult.invalid([error]);
}
}
/// Валидирует ответ AI и выбрасывает исключение при ошибке
void validateAndThrow(AiResponse response, {String? smsId}) {
final result = validateResponse(response, smsId: smsId);
if (!result.isValid) {
final error = 'Валидация ответа AI не пройдена: ${result.errors.join('; ')}';
_logger.e('$error для SMS ID: $smsId');
final invalidData = response is AiTransactionResponse
? response.toJson()
: (response as AiNonTransactionResponse).toJson();
throw AiDataValidationException(
error,
invalidData: invalidData,
smsId: smsId,
context: 'AiDataValidator',
);
}
// Логируем предупреждения, если есть
if (result.hasWarnings) {
_logger.w('Предупреждения при валидации AI ответа: ${result.warnings.join('; ')}');
}
}
/// Валидирует ответ для транзакций
ValidationResult _validateTransactionResponse(
AiTransactionResponse response,
{String? smsId}
) {
final errors = <String>[];
final warnings = <String>[];
// Проверяем базовую валидность
if (!response.isValid()) {
errors.add('Базовая валидация не пройдена');
}
// Проверяем confidence
final confidenceValidation = _validateConfidence(response.confidence);
if (confidenceValidation.hasErrors) {
errors.addAll(confidenceValidation.errors);
}
warnings.addAll(confidenceValidation.warnings);
// Проверяем amount
final amountValidation = _validateAmount(response.amount);
if (amountValidation.hasErrors) {
errors.addAll(amountValidation.errors);
}
warnings.addAll(amountValidation.warnings);
// Проверяем vendor
final vendorValidation = _validateVendor(response.vendor);
if (vendorValidation.hasErrors) {
errors.addAll(vendorValidation.errors);
}
warnings.addAll(vendorValidation.warnings);
// Проверяем suggestedCategory
if (response.suggestedCategory != null && response.suggestedCategory!.trim().isEmpty) {
warnings.add('Предложенная категория пустая');
}
_logger.d('Валидация транзакционного ответа: errors=${errors.length}, warnings=${warnings.length}');
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует ответ для не-транзакций
ValidationResult _validateNonTransactionResponse(
AiNonTransactionResponse response,
{String? smsId}
) {
final errors = <String>[];
final warnings = <String>[];
// Проверяем базовую валидность
if (!response.isValid()) {
errors.add('Базовая валидация не пройдена');
}
// Проверяем confidence
final confidenceValidation = _validateConfidence(response.confidence);
if (confidenceValidation.hasErrors) {
errors.addAll(confidenceValidation.errors);
}
warnings.addAll(confidenceValidation.warnings);
// Проверяем exclusionRegex
final regexValidation = _validateExclusionRegex(response.exclusionRegex);
if (regexValidation.hasErrors) {
errors.addAll(regexValidation.errors);
}
warnings.addAll(regexValidation.warnings);
_logger.d('Валидация не-транзакционного ответа: errors=${errors.length}, warnings=${warnings.length}');
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует значение confidence
ValidationResult _validateConfidence(double confidence) {
final errors = <String>[];
final warnings = <String>[];
if (confidence < minConfidence || confidence > maxConfidence) {
errors.add('Confidence должен быть от $minConfidence до $maxConfidence, получен: $confidence');
}
if (confidence < 0.5) {
warnings.add('Низкий уровень уверенности AI: $confidence');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует сумму транзакции
ValidationResult _validateAmount(double amount) {
final errors = <String>[];
final warnings = <String>[];
if (amount == 0.0) {
errors.add('Сумма транзакции не может быть равна нулю');
}
if (amount.abs() > 1000000.0) {
warnings.add('Очень большая сумма транзакции: $amount');
}
if (amount.abs() < 1.0) {
warnings.add('Очень маленькая сумма транзакции: $amount');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует название продавца/получателя
ValidationResult _validateVendor(String vendor) {
final errors = <String>[];
final warnings = <String>[];
if (vendor.trim().isEmpty) {
errors.add('Название продавца не может быть пустым');
}
if (vendor.length > maxVendorLength) {
errors.add('Название продавца слишком длинное (>${maxVendorLength} символов): ${vendor.length}');
}
if (vendor.length < 2) {
warnings.add('Очень короткое название продавца: "$vendor"');
}
// Проверяем на подозрительные символы
if (vendor.contains(RegExp(r'[<>{}[\]\\|`~]'))) {
warnings.add('Название продавца содержит подозрительные символы: "$vendor"');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует регулярное выражение для исключения
ValidationResult _validateExclusionRegex(String exclusionRegex) {
final errors = <String>[];
final warnings = <String>[];
if (exclusionRegex.trim().isEmpty) {
errors.add('Регулярное выражение для исключения не может быть пустым');
}
if (exclusionRegex.length > maxRegexLength) {
errors.add('Регулярное выражение слишком длинное (>${maxRegexLength} символов): ${exclusionRegex.length}');
}
// Проверяем синтаксис regex
try {
RegExp(exclusionRegex);
} catch (e) {
errors.add('Некорректный синтаксис регулярного выражения: $e');
}
// Предупреждения для потенциально проблематичных regex
if (exclusionRegex == '.*') {
warnings.add('Regex ".*" исключит все сообщения');
}
if (exclusionRegex.contains('.*.*.*')) {
warnings.add('Избыточные .* в regex могут влиять на производительность');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует массив ответов AI
List<ValidationResult> validateMultipleResponses(
List<AiResponse> responses,
{List<String>? smsIds}
) {
final results = <ValidationResult>[];
for (int i = 0; i < responses.length; i++) {
final response = responses[i];
final smsId = smsIds != null && i < smsIds.length ? smsIds[i] : null;
results.add(validateResponse(response, smsId: smsId));
}
return results;
}
/// Проверяет есть ли критические ошибки в массиве результатов валидации
bool hasAnyCriticalErrors(List<ValidationResult> results) {
return results.any((result) => !result.isValid);
}
/// Собирает все ошибки из массива результатов валидации
List<String> getAllErrors(List<ValidationResult> results) {
return results
.where((result) => result.hasErrors)
.expand((result) => result.errors)
.toList();
}
/// Собирает все предупреждения из массива результатов валидации
List<String> getAllWarnings(List<ValidationResult> results) {
return results
.where((result) => result.hasWarnings)
.expand((result) => result.warnings)
.toList();
}
}
+173
View File
@@ -0,0 +1,173 @@
/// Класс для построения промптов для AI анализа SMS сообщений
library;
import 'package:budget_app/models/category.dart';
/// Билдер промптов для AI анализа SMS
class AiPromptBuilder {
/// Основной шаблон промпта
static const String _basePromptTemplate = '''
=== ЗАДАЧА ===
Проанализируй SMS сообщение и определи:
1. Это финансовая транзакция или нет?
2. Если транзакция - извлеки сумму, продавца и категорию
3. Если не транзакция - создай regex для исключения похожих сообщений
=== SMS ДЛЯ АНАЛИЗА ===
"{smsBody}"
=== ДОСТУПНЫЕ КАТЕГОРИИ ===
{categoriesSection}
=== ФОРМАТ ОТВЕТА ===
Верни ответ ТОЛЬКО в виде JSON без дополнительных комментариев:
Если это финансовая транзакция:
{transactionFormat}
Если это НЕ финансовая транзакция (реклама, уведомления, спам и т.д.):
{nonTransactionFormat}
=== ПРИМЕРЫ ===
{examples}
=== ВАЖНЫЕ ПРАВИЛА ===
{rules}''';
/// Формат ответа для транзакций
static const String _transactionFormat = '''{
"isTransaction": true,
"amount": сумма (число, положительное для доходов, отрицательное для расходов),
"vendor": "название продавца/получателя",
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": "выбери ТОЧНОЕ название из доступных категорий выше",
"exclusionRegex": null
}''';
/// Формат ответа для не-транзакций
static const String _nonTransactionFormat = '''{
"isTransaction": false,
"amount": null,
"vendor": null,
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": null,
"exclusionRegex": "регулярное выражение для исключения похожих SMS в будущем"
}''';
/// Примеры анализа
static const String _examples = '''
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": ".*кредит.*одобр.*"
}''';
/// Важные правила
static const String _rules = '''• exclusionRegex - только для НЕ-транзакций (реклама, спам, уведомления)
• suggestedCategory - только из списка выше, точное название
• amount - отрицательное для расходов, положительное для доходов
• vendor - короткое название продавца/источника без лишних деталей
• confidence - от 0 до 1 (насколько уверен в анализе)''';
/// Строит промпт для анализа SMS с помощью AI
static String buildSmsAnalysisPrompt(
String smsBody,
List<Category> categories,
) {
validatePromptParameters(smsBody: smsBody, categories: categories);
final categoriesSection = _buildCategoriesSection(categories);
return _basePromptTemplate
.replaceAll('{smsBody}', smsBody)
.replaceAll('{categoriesSection}', categoriesSection)
.replaceAll('{transactionFormat}', _transactionFormat)
.replaceAll('{nonTransactionFormat}', _nonTransactionFormat)
.replaceAll('{examples}', _examples)
.replaceAll('{rules}', _rules);
}
/// Создает секцию с доступными категориями
static String _buildCategoriesSection(List<Category> categories) {
final expenseCategories = categories
.where((cat) => !cat.isIncome)
.map((cat) => '"${cat.name}"')
.join(', ');
final incomeCategories = categories
.where((cat) => cat.isIncome)
.map((cat) => '"${cat.name}"')
.join(', ');
return '''Для расходов: $expenseCategories
Для доходов: $incomeCategories''';
}
/// Строит простой промпт для кастомного анализа (для тестирования)
static String buildCustomPrompt(String instruction, String content) {
if (instruction.trim().isEmpty || content.trim().isEmpty) {
throw ArgumentError('Инструкция и контент не могут быть пустыми');
}
return '''=== ЗАДАЧА ===
$instruction
=== КОНТЕНТ ДЛЯ АНАЛИЗА ===
"$content"
=== ФОРМАТ ОТВЕТА ===
Верни ответ в формате JSON.''';
}
/// Валидирует параметры для построения промпта
static void validatePromptParameters({
required String smsBody,
required List<Category> categories,
}) {
if (smsBody.trim().isEmpty) {
throw ArgumentError('SMS сообщение не может быть пустым');
}
if (categories.isEmpty) {
throw ArgumentError('Список категорий не может быть пустым');
}
// Проверяем что есть и расходные и доходные категории
final hasExpenseCategories = categories.any((cat) => !cat.isIncome);
final hasIncomeCategories = categories.any((cat) => cat.isIncome);
if (!hasExpenseCategories && !hasIncomeCategories) {
throw ArgumentError('Должна быть хотя бы одна категория доходов или расходов');
}
}
}
+211
View File
@@ -0,0 +1,211 @@
/// Класс для парсинга ответов AI сервиса
library;
import 'dart:convert';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:logger/logger.dart';
/// Парсер ответов AI в типизированные модели
class AiResponseParser {
final Logger _logger = Logger();
/// Парсит ответ AI в типизированную модель
AiResponse parseResponse(String aiResponse, {String? smsId}) {
if (aiResponse.trim().isEmpty) {
final error = 'Пустой ответ от AI';
_logger.e('$error для SMS ID: $smsId');
throw AiProcessingException(error, smsId: smsId, context: 'AiResponseParser');
}
_logger.d('Начинаю парсинг ответа AI${smsId != null ? ' для SMS ID: $smsId' : ''}');
try {
final jsonData = _extractJsonFromResponse(aiResponse);
_logger.d('Извлеченные JSON данные: $jsonData');
return AiResponseFactory.fromJson(jsonData);
} on AiException {
// Перебрасываем AI исключения как есть
rethrow;
} catch (e, stackTrace) {
final error = 'Критическая ошибка парсинга ответа AI';
_logger.e('$error: $e');
_logger.d('Stack trace: $stackTrace');
final exception = e is Exception ? e : Exception(e.toString());
throw AiResponseParsingException(
error,
rawResponse: aiResponse,
smsId: smsId,
context: 'AiResponseParser',
cause: exception,
);
}
}
/// Извлекает JSON из ответа AI
Map<String, dynamic> _extractJsonFromResponse(String aiResponse) {
final cleanResponse = aiResponse.trim();
// Попробуем сначала распарсить весь ответ как JSON
try {
final decoded = jsonDecode(cleanResponse);
if (decoded is Map<String, dynamic>) {
return decoded;
} else {
throw const FormatException('Ответ не является JSON объектом');
}
} catch (e) {
_logger.w('Попытка парсинга всего ответа как JSON не удалась, '
'ищем JSON внутри текста: $e');
}
// Ищем JSON блок внутри текста
return _extractJsonBlock(cleanResponse);
}
/// Извлекает JSON блок из текста с правильным подсчетом скобок
Map<String, dynamic> _extractJsonBlock(String text) {
final jsonStart = text.indexOf('{');
if (jsonStart == -1) {
final error = 'JSON не найден в ответе AI (нет открывающей скобки)';
_logger.e('$error: $text');
throw AiResponseParsingException(
error,
rawResponse: text,
context: 'AiResponseParser',
);
}
int braceCount = 0;
int jsonEnd = -1;
for (int i = jsonStart; i < text.length; i++) {
if (text[i] == '{') {
braceCount++;
} else if (text[i] == '}') {
braceCount--;
if (braceCount == 0) {
jsonEnd = i;
break;
}
}
}
if (jsonEnd == -1) {
final error = 'JSON не найден в ответе AI (несбалансированные скобки)';
_logger.e('$error: $text');
throw AiResponseParsingException(
error,
rawResponse: text,
context: 'AiResponseParser',
);
}
final jsonString = text.substring(jsonStart, jsonEnd + 1);
_logger.d('Извлеченный JSON: $jsonString');
try {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
return decoded;
} else {
throw const FormatException('Извлеченный JSON не является объектом');
}
} catch (e) {
final error = 'Ошибка парсинга извлеченного JSON';
_logger.e('$error: $e, JSON: $jsonString');
throw AiResponseParsingException(
error,
rawResponse: jsonString,
context: 'AiResponseParser',
cause: e is Exception ? e : Exception(e.toString()),
);
}
}
/// Парсит несколько ответов AI (batch processing)
List<AiResponse> parseMultipleResponses(
List<String> aiResponses,
{List<String>? smsIds}
) {
if (aiResponses.isEmpty) {
return [];
}
final results = <AiResponse>[];
final errors = <String>[];
for (int i = 0; i < aiResponses.length; i++) {
final response = aiResponses[i];
final smsId = smsIds != null && i < smsIds.length ? smsIds[i] : null;
try {
final parsed = parseResponse(response, smsId: smsId);
results.add(parsed);
} catch (e) {
final errorMsg = 'Ошибка парсинга ответа ${i + 1}${smsId != null ? ' (SMS ID: $smsId)' : ''}: $e';
errors.add(errorMsg);
_logger.e(errorMsg);
}
}
if (errors.isNotEmpty && results.isEmpty) {
// Если все ответы с ошибками, выбрасываем исключение
final error = 'Все ответы AI содержат ошибки: ${errors.join('; ')}';
throw AiProcessingException(error, context: 'AiResponseParser');
}
return results;
}
/// Проверяет является ли строка валидным JSON
bool isValidJson(String text) {
try {
jsonDecode(text.trim());
return true;
} catch (e) {
return false;
}
}
/// Извлекает только JSON часть из ответа (для предварительной проверки)
String? extractJsonString(String aiResponse) {
try {
final cleanResponse = aiResponse.trim();
// Если весь ответ - это JSON, возвращаем как есть
if (isValidJson(cleanResponse)) {
return cleanResponse;
}
// Ищем JSON блок
final jsonStart = cleanResponse.indexOf('{');
if (jsonStart == -1) 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) return null;
final jsonString = cleanResponse.substring(jsonStart, jsonEnd + 1);
return isValidJson(jsonString) ? jsonString : null;
} catch (e) {
_logger.w('Ошибка извлечения JSON строки: $e');
return null;
}
}
}
@@ -1,13 +1,16 @@
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/ai_response.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:budget_app/services/ai_prompt_builder.dart';
import 'package:budget_app/services/ai_response_parser.dart';
import 'package:budget_app/services/ai_data_validator.dart';
import 'package:logger/logger.dart';
/// Сервис для обработки SMS с помощью ИИ и создания транзакций
@@ -16,6 +19,8 @@ class AiTransactionProcessingService {
final IPrefilledTransactionRepository _prefilledRepository;
final ICategoryRepository _categoryRepository;
final IAiRuleRepository _aiRuleRepository;
final AiResponseParser _responseParser;
final AiDataValidator _dataValidator;
final Logger _logger = Logger();
AiTransactionProcessingService({
@@ -23,105 +28,19 @@ class AiTransactionProcessingService {
required IPrefilledTransactionRepository prefilledRepository,
required ICategoryRepository categoryRepository,
required IAiRuleRepository aiRuleRepository,
AiResponseParser? responseParser,
AiDataValidator? dataValidator,
}) : _aiService = aiService,
_prefilledRepository = prefilledRepository,
_categoryRepository = categoryRepository,
_aiRuleRepository = aiRuleRepository;
_aiRuleRepository = aiRuleRepository,
_responseParser = responseParser ?? AiResponseParser(),
_dataValidator = dataValidator ?? AiDataValidator();
/// Создает промпт для ИИ анализа 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 (насколько уверен в анализе)
''';
return AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, categories);
}
/// Анализирует SMS с помощью ИИ и создает PrefilledTransaction
@@ -149,50 +68,30 @@ SMS: "Получи кредит за 5 минут! Одобрим всех!"
_logger.d('Начинаю обработку SMS ID: $smsMessageId, длина: ${smsBody.length} символов');
final prompt = await _buildAiPrompt(smsBody);
final aiResponse = await _aiService.sendMessage(prompt);
final rawAiResponse = await _aiService.sendMessage(prompt);
if (aiResponse.trim().isEmpty) {
if (rawAiResponse.trim().isEmpty) {
final error = 'Пустой ответ от ИИ';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiProcessingException(error, smsId: smsMessageId, context: 'AiProcessing');
}
_logger.d('Получен ответ от ИИ для SMS ID: $smsMessageId, длина ответа: ${aiResponse.length}');
_logger.d('Получен ответ от ИИ для SMS ID: $smsMessageId, длина ответа: ${rawAiResponse.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');
}
// Парсим ответ AI в типизированную модель
final parsedResponse = _responseParser.parseResponse(rawAiResponse, smsId: smsMessageId);
_logger.d('Успешно распарсен ответ ИИ для SMS ID: $smsMessageId: $parsedResponse');
_logger.d('Успешно распарсен ответ ИИ для SMS ID: $smsMessageId: $aiData');
// Проверяем тип сообщения
final isTransaction = aiData['isTransaction'] == true;
// Валидируем данные
_dataValidator.validateAndThrow(parsedResponse, smsId: smsMessageId);
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']}');
if (parsedResponse is AiTransactionResponse) {
_logger.i('ИИ определил транзакционное SMS ID: $smsMessageId как ${parsedResponse.vendor} на сумму ${parsedResponse.amount}');
} else if (parsedResponse is AiNonTransactionResponse) {
_logger.i('ИИ определил не-транзакционное SMS ID: $smsMessageId с exclusion regex: ${parsedResponse.exclusionRegex}');
}
final result = await _createPrefilledFromAiResponse(aiData, smsMessageId);
final result = await _createPrefilledFromAiResponse(parsedResponse, smsMessageId);
_logger.i('Успешно создан PrefilledTransaction для SMS ID: $smsMessageId');
return result;
@@ -209,113 +108,76 @@ SMS: "Получи кредит за 5 минут! Одобрим всех!"
}
}
/// Парсит ответ ИИ из 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,
AiResponse aiResponse,
String smsMessageId
) async {
Category? suggestedCategory;
double? amount;
String? salesPoint;
String? exclusionRegex;
if (aiData['suggestedCategory'] != null) {
final categoryName = (aiData['suggestedCategory'] as String).trim();
final categories = await _categoryRepository.getAll();
if (aiResponse is AiTransactionResponse) {
amount = aiResponse.amount;
salesPoint = aiResponse.vendor;
exclusionRegex = null;
// Ищем категорию по точному совпадению имени (регистронезависимо)
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"');
}
// Ищем предложенную категорию
if (aiResponse.suggestedCategory != null) {
suggestedCategory = await _findCategoryByName(aiResponse.suggestedCategory!);
}
} else if (aiResponse is AiNonTransactionResponse) {
amount = null;
salesPoint = null;
exclusionRegex = aiResponse.exclusionRegex;
}
final prefilled = PrefilledTransaction(
smsMessageId: smsMessageId,
transactionId: null, // Будет создан позже
amount: (aiData['amount'] as num?)?.toDouble(),
salesPoint: aiData['vendor'] as String?,
amount: amount,
salesPoint: salesPoint,
calculatedCategory: suggestedCategory,
confidence: ((aiData['confidence'] as num?)?.toDouble() ?? 0.0) * 100, // Конвертируем в проценты
exclusionRegex: aiData['exclusionRegex'] as String?,
confidence: aiResponse.confidence * 100, // Конвертируем в проценты
exclusionRegex: exclusionRegex,
);
// Сохраняем в репозиторий
await _prefilledRepository.add(prefilled);
return prefilled;
}
/// Находит категорию по имени
Future<Category?> _findCategoryByName(String categoryName) async {
final categories = await _categoryRepository.getAll();
final trimmedName = categoryName.trim();
// Ищем категорию по точному совпадению имени (регистронезависимо)
final exactMatch = categories.where((cat) =>
cat.name.toLowerCase().trim() == trimmedName.toLowerCase()
).toList();
if (exactMatch.isNotEmpty) {
_logger.d('Найдена точная категория для "$trimmedName": ${exactMatch.first.name}');
return exactMatch.first;
}
// Если точного совпадения нет, ищем частичное для обратной совместимости
final partialMatch = categories.where((cat) =>
cat.name.toLowerCase().trim().contains(trimmedName.toLowerCase()) ||
trimmedName.toLowerCase().contains(cat.name.toLowerCase().trim())
).toList();
if (partialMatch.isNotEmpty) {
_logger.d('Найдена частичная категория для "$trimmedName": ${partialMatch.first.name}');
return partialMatch.first;
}
_logger.w('Категория не найдена для предложения: "$trimmedName"');
return null;
}
/// Преобразует PrefilledTransaction в TransactionRecord
Future<TransactionRecord> convertToTransactionRecord(
@@ -393,70 +255,6 @@ SMS: "Получи кредит за 5 минут! Одобрим всех!"
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) {
@@ -465,30 +263,4 @@ SMS: "Получи кредит за 5 минут! Одобрим всех!"
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);
}
}
@@ -8,7 +8,6 @@ import 'package:budget_app/models/ai_exceptions.dart';
import '../test_config.dart';
import '../mocks/fake_repositories.dart';
import '../helpers/test_data.dart';
import '../helpers/test_objects.dart';
void main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+43 -4
View File
@@ -25,6 +25,25 @@ class MockAiService implements IAiService {
'suggestedCategory': 'Покупки',
'exclusionRegex': null,
});
// Ответы для тестирования валидации - некорректные данные
_responseMap['некорректные_данные'] = jsonEncode({
'isTransaction': true,
'amount': 0, // Некорректная сумма
'vendor': 'Test Vendor',
'confidence': 0.9,
'suggestedCategory': 'shopping',
'exclusionRegex': null,
});
_responseMap['некорректные_данные2'] = jsonEncode({
'isTransaction': true,
'amount': -150.0,
'vendor': '', // Пустой vendor
'confidence': 0.9,
'suggestedCategory': 'shopping',
'exclusionRegex': null,
});
// Ответ для рекламного SMS
_responseMap['акция'] = jsonEncode({
@@ -49,6 +68,7 @@ class MockAiService implements IAiService {
/// Устанавливает кастомный ответ для определенного ключевого слова в сообщении
void setCustomResponse(String keyword, Map<String, dynamic> response) {
// Перезаписываем ответ, если он уже существует
_responseMap[keyword.toLowerCase()] = jsonEncode(response);
}
@@ -105,11 +125,30 @@ class MockAiService implements IAiService {
// Небольшая задержка для имитации сетевого запроса
await Future.delayed(const Duration(milliseconds: 10));
final messageLower = message.toLowerCase();
// Извлекаем фактический SMS текст из промпта
String smsText = '';
final smsStartMarker = '=== sms для анализа ===';
final smsEndMarker = '=== доступные категории ===';
// Ищем подходящий ответ по ключевым словам
for (final entry in _responseMap.entries) {
if (messageLower.contains(entry.key)) {
final startIndex = message.toLowerCase().indexOf(smsStartMarker.toLowerCase());
final endIndex = message.toLowerCase().indexOf(smsEndMarker.toLowerCase());
if (startIndex != -1 && endIndex != -1) {
final smsSection = message.substring(startIndex + smsStartMarker.length, endIndex).trim();
// Удаляем кавычки в начале и конце
smsText = smsSection.replaceAll(RegExp(r'^"\s*|\s*"$'), '').toLowerCase();
} else {
// Если не можем разобрать промпт, считаем что сообщение - это SMS
smsText = message.toLowerCase();
}
// Ищем подходящий ответ по ключевым словам в SMS тексте
final sortedEntries = _responseMap.entries.toList()
..sort((a, b) => b.key.length.compareTo(a.key.length)); // Сортируем по длине ключа
for (final entry in sortedEntries) {
if (smsText.contains(entry.key)) {
return entry.value;
}
}
+317
View File
@@ -0,0 +1,317 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:budget_app/services/ai_data_validator.dart';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
/// Тесты для AiDataValidator - проверяют валидацию данных от ИИ
void main() {
group('AiDataValidator', () {
late AiDataValidator validator;
setUp(() {
validator = AiDataValidator();
});
group('validateResponse - Transaction Validation', () {
test('должен принимать корректные данные транзакции', () {
// Проверяет валидацию правильно заполненной транзакции
final response = AiTransactionResponse(
amount: -1250.0,
vendor: 'METRO CASH & CARRY',
confidence: 0.95,
suggestedCategory: 'Покупки',
);
final result = validator.validateResponse(response);
expect(result.isValid, isTrue);
expect(result.errors, isEmpty);
});
test('должен отклонять транзакцию с нулевой суммой', () {
// Проверяет что нулевая сумма не допускается для транзакций
final response = AiTransactionResponse(
amount: 0.0,
vendor: 'Test Vendor',
confidence: 0.9,
);
final result = validator.validateResponse(response);
expect(result.isValid, isFalse);
expect(result.errors, contains(contains('нулю')));
});
test('должен отклонять транзакцию с пустым vendor', () {
// Проверяет что название продавца обязательно для транзакций
final response = AiTransactionResponse(
amount: -100.0,
vendor: '',
confidence: 0.9,
);
final result = validator.validateResponse(response);
expect(result.isValid, isFalse);
expect(result.errors, contains(contains('пустым')));
});
test('должен отклонять транзакцию с некорректным confidence', () {
// Проверяет валидацию значения уверенности (должно быть 0-1)
final response1 = AiTransactionResponse(
amount: -100.0,
vendor: 'Test Vendor',
confidence: 1.5, // Слишком большое
);
final response2 = AiTransactionResponse(
amount: -100.0,
vendor: 'Test Vendor',
confidence: -0.1, // Отрицательное
);
final result1 = validator.validateResponse(response1);
final result2 = validator.validateResponse(response2);
expect(result1.isValid, isFalse);
expect(result2.isValid, isFalse);
expect(result1.errors, contains(contains('Confidence')));
expect(result2.errors, contains(contains('Confidence')));
});
test('должен выдавать предупреждения для подозрительных сумм', () {
// Проверяет предупреждения для очень больших или маленьких сумм
final largeAmountResponse = AiTransactionResponse(
amount: -2000000.0, // Очень большая сумма
vendor: 'Test Vendor',
confidence: 0.9,
);
final smallAmountResponse = AiTransactionResponse(
amount: -0.50, // Очень маленькая сумма
vendor: 'Test Vendor',
confidence: 0.9,
);
final result1 = validator.validateResponse(largeAmountResponse);
final result2 = validator.validateResponse(smallAmountResponse);
expect(result1.isValid, isTrue);
expect(result2.isValid, isTrue);
expect(result1.warnings, isNotEmpty);
expect(result2.warnings, isNotEmpty);
});
test('должен выдавать предупреждения для низкого confidence', () {
// Проверяет предупреждения при низкой уверенности ИИ
final response = AiTransactionResponse(
amount: -100.0,
vendor: 'Test Vendor',
confidence: 0.3, // Низкая уверенность
);
final result = validator.validateResponse(response);
expect(result.isValid, isTrue);
expect(result.warnings, contains(contains('Низкий уровень')));
});
});
group('validateResponse - Non-Transaction Validation', () {
test('должен принимать корректные данные не-транзакции', () {
// Проверяет валидацию правильного не-транзакционного SMS
final response = AiNonTransactionResponse(
exclusionRegex: r'.*кредит.*одобр.*',
confidence: 0.85,
);
final result = validator.validateResponse(response);
expect(result.isValid, isTrue);
expect(result.errors, isEmpty);
});
test('должен отклонять не-транзакцию с пустым exclusionRegex', () {
// Проверяет что regex для исключения обязателен
final response = AiNonTransactionResponse(
exclusionRegex: '',
confidence: 0.85,
);
final result = validator.validateResponse(response);
expect(result.isValid, isFalse);
expect(result.errors, contains(contains('пустым')));
});
test('должен отклонять не-транзакцию с некорректным regex', () {
// Проверяет валидацию синтаксиса регулярного выражения
final response = AiNonTransactionResponse(
exclusionRegex: '[invalid regex', // Некорректный regex
confidence: 0.85,
);
final result = validator.validateResponse(response);
expect(result.isValid, isFalse);
expect(result.errors, contains(contains('Некорректный синтаксис')));
});
test('должен выдавать предупреждения для проблематичных regex', () {
// Проверяет предупреждения для потенциально проблематичных regex
final catchAllResponse = AiNonTransactionResponse(
exclusionRegex: '.*', // Исключит все сообщения
confidence: 0.85,
);
final inefficientResponse = AiNonTransactionResponse(
exclusionRegex: '.*.*.*test.*.*.*', // Избыточные .*
confidence: 0.85,
);
final result1 = validator.validateResponse(catchAllResponse);
final result2 = validator.validateResponse(inefficientResponse);
expect(result1.isValid, isTrue);
expect(result2.isValid, isTrue);
expect(result1.warnings, contains(contains('исключит все')));
expect(result2.warnings, contains(contains('производительность')));
});
});
group('validateAndThrow', () {
test('должен проходить без исключений для валидных данных', () {
// Проверяет что валидные данные не вызывают исключений
final response = AiTransactionResponse(
amount: -100.0,
vendor: 'Test Vendor',
confidence: 0.9,
);
expect(
() => validator.validateAndThrow(response),
returnsNormally,
);
});
test('должен выбрасывать AiDataValidationException для невалидных данных', () {
// Проверяет что невалидные данные вызывают соответствующее исключение
final response = AiTransactionResponse(
amount: 0.0, // Невалидная сумма
vendor: 'Test Vendor',
confidence: 0.9,
);
expect(
() => validator.validateAndThrow(response),
throwsA(isA<AiDataValidationException>()),
);
});
test('должен передавать SMS ID в исключение для диагностики', () {
// Проверяет что SMS ID передается в исключениях
const smsId = 'test_sms_456';
final response = AiTransactionResponse(
amount: 0.0,
vendor: 'Test Vendor',
confidence: 0.9,
);
try {
validator.validateAndThrow(response, smsId: smsId);
fail('Должно было выбросить исключение');
} catch (e) {
expect(e, isA<AiDataValidationException>());
final exception = e as AiDataValidationException;
expect(exception.smsId, equals(smsId));
}
});
});
group('validateMultipleResponses', () {
test('должен валидировать массив ответов', () {
// Проверяет batch валидацию множественных ответов
final responses = [
AiTransactionResponse(
amount: -100.0,
vendor: 'Valid Vendor',
confidence: 0.9,
),
AiNonTransactionResponse(
exclusionRegex: r'.*spam.*',
confidence: 0.8,
),
AiTransactionResponse(
amount: 0.0, // Невалидная
vendor: 'Invalid Vendor',
confidence: 0.9,
),
];
final results = validator.validateMultipleResponses(responses);
expect(results, hasLength(3));
expect(results[0].isValid, isTrue);
expect(results[1].isValid, isTrue);
expect(results[2].isValid, isFalse);
});
test('должен корректно определять наличие критических ошибок', () {
// Проверяет утилитарные методы для анализа результатов валидации
final results = [
ValidationResult.valid(),
ValidationResult.invalid(['Ошибка']),
ValidationResult.valid(),
];
expect(validator.hasAnyCriticalErrors(results), isTrue);
final allValidResults = [
ValidationResult.valid(),
ValidationResult.valid(),
];
expect(validator.hasAnyCriticalErrors(allValidResults), isFalse);
});
test('должен собирать все ошибки и предупреждения', () {
// Проверяет агрегацию ошибок и предупреждений из множественных результатов
final results = [
ValidationResult.invalid(['Ошибка 1'], ['Предупреждение 1']),
ValidationResult.valid(),
ValidationResult.invalid(['Ошибка 2'], ['Предупреждение 2']),
];
final allErrors = validator.getAllErrors(results);
final allWarnings = validator.getAllWarnings(results);
expect(allErrors, contains('Ошибка 1'));
expect(allErrors, contains('Ошибка 2'));
expect(allWarnings, contains('Предупреждение 1'));
expect(allWarnings, contains('Предупреждение 2'));
});
});
group('Custom Validator Settings', () {
test('должен использовать кастомные настройки валидации', () {
// Проверяет работу с настраиваемыми параметрами валидатора
final customValidator = AiDataValidator(
minConfidence: 0.7,
maxConfidence: 0.9,
maxVendorLength: 50,
);
final response = AiTransactionResponse(
amount: -100.0,
vendor: 'A' * 60, // Превышает maxVendorLength
confidence: 0.95, // Превышает maxConfidence
);
final result = customValidator.validateResponse(response);
expect(result.isValid, isFalse);
expect(result.errors.length, greaterThan(1));
});
});
});
}
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:budget_app/services/ai_prompt_builder.dart';
import 'package:budget_app/models/category.dart';
/// Тесты для AiPromptBuilder - проверяют построение промптов для ИИ анализа SMS
void main() {
group('AiPromptBuilder', () {
late List<Category> testCategories;
setUp(() {
// Создаем тестовые категории для использования в промптах
testCategories = [
Category(id: '1', name: 'Покупки', isIncome: false, icon: Icons.shopping_cart, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Транспорт', isIncome: false, icon: Icons.directions_bus, color: Color(0xFF4CAF50)),
Category(id: '3', name: 'Зарплата', isIncome: true, icon: Icons.attach_money, color: Color(0xFFFF9800)),
];
});
group('buildSmsAnalysisPrompt', () {
test('должен создавать корректный промпт для анализа SMS с категориями доходов и расходов', () {
// Проверяет что промпт содержит все необходимые секции для анализа SMS
const smsBody = 'Покупка 1250.00р METRO CASH & CARRY';
final prompt = AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, testCategories);
// Проверяем структуру промпта
expect(prompt, contains('=== ЗАДАЧА ==='));
expect(prompt, contains('=== SMS ДЛЯ АНАЛИЗА ==='));
expect(prompt, contains('=== ДОСТУПНЫЕ КАТЕГОРИИ ==='));
expect(prompt, contains('=== ФОРМАТ ОТВЕТА ==='));
expect(prompt, contains('=== ПРИМЕРЫ ==='));
expect(prompt, contains('=== ВАЖНЫЕ ПРАВИЛА ==='));
// Проверяем что SMS включено в промпт
expect(prompt, contains(smsBody));
// Проверяем что категории включены правильно
expect(prompt, contains('Для расходов: "Покупки", "Транспорт"'));
expect(prompt, contains('Для доходов: "Зарплата"'));
});
test('должен корректно обрабатывать SMS с кавычками и специальными символами', () {
// Проверяет что специальные символы в SMS не ломают промпт
const smsBody = 'SMS с "кавычками" и спецсимволами: {}, []';
final prompt = AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, testCategories);
expect(prompt, contains(smsBody));
expect(prompt, contains('=== SMS ДЛЯ АНАЛИЗА ==='));
});
test('должен выбрасывать исключение при пустом SMS', () {
// Проверяет валидацию входных данных - пустое SMS недопустимо
expect(
() => AiPromptBuilder.buildSmsAnalysisPrompt('', testCategories),
throwsA(isA<ArgumentError>()),
);
expect(
() => AiPromptBuilder.buildSmsAnalysisPrompt(' ', testCategories),
throwsA(isA<ArgumentError>()),
);
});
test('должен выбрасывать исключение при пустом списке категорий', () {
// Проверяет валидацию входных данных - нужны категории для анализа
const smsBody = 'Тестовое SMS';
expect(
() => AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, []),
throwsA(isA<ArgumentError>()),
);
});
test('должен корректно обрабатывать только категории расходов', () {
// Проверяет работу с односторонними категориями
final expenseOnlyCategories = [
Category(id: '1', name: 'Покупки', isIncome: false, icon: Icons.shopping_cart, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Еда', isIncome: false, icon: Icons.restaurant, color: Color(0xFF4CAF50)),
];
const smsBody = 'Тестовое SMS';
final prompt = AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, expenseOnlyCategories);
expect(prompt, contains('Для расходов: "Покупки", "Еда"'));
expect(prompt, contains('Для доходов:'));
});
test('должен корректно обрабатывать только категории доходов', () {
// Проверяет работу только с доходными категориями
final incomeOnlyCategories = [
Category(id: '1', name: 'Зарплата', isIncome: true, icon: Icons.attach_money, color: Color(0xFFFF9800)),
Category(id: '2', name: 'Премия', isIncome: true, icon: Icons.card_giftcard, color: Color(0xFF9C27B0)),
];
const smsBody = 'Тестовое SMS';
final prompt = AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, incomeOnlyCategories);
expect(prompt, contains('Для расходов:'));
expect(prompt, contains('Для доходов: "Зарплата", "Премия"'));
});
});
group('buildCustomPrompt', () {
test('должен создавать кастомный промпт для тестирования', () {
// Проверяет создание произвольных промптов для тестовых сценариев
const instruction = 'Проанализируй текст';
const content = 'Тестовый контент';
final prompt = AiPromptBuilder.buildCustomPrompt(instruction, content);
expect(prompt, contains('=== ЗАДАЧА ==='));
expect(prompt, contains(instruction));
expect(prompt, contains('=== КОНТЕНТ ДЛЯ АНАЛИЗА ==='));
expect(prompt, contains(content));
expect(prompt, contains('=== ФОРМАТ ОТВЕТА ==='));
});
test('должен выбрасывать исключение при пустых параметрах', () {
// Проверяет валидацию параметров кастомного промпта
expect(
() => AiPromptBuilder.buildCustomPrompt('', 'content'),
throwsA(isA<ArgumentError>()),
);
expect(
() => AiPromptBuilder.buildCustomPrompt('instruction', ''),
throwsA(isA<ArgumentError>()),
);
});
});
group('validatePromptParameters', () {
test('должен проходить валидацию для корректных параметров', () {
// Проверяет что валидация не выбрасывает исключения для правильных данных
const smsBody = 'Тестовое SMS';
expect(
() => AiPromptBuilder.validatePromptParameters(
smsBody: smsBody,
categories: testCategories,
),
returnsNormally,
);
});
test('должен выбрасывать исключение для некорректных параметров', () {
// Проверяет различные сценарии невалидных параметров
expect(
() => AiPromptBuilder.validatePromptParameters(
smsBody: '',
categories: testCategories,
),
throwsA(isA<ArgumentError>()),
);
expect(
() => AiPromptBuilder.validatePromptParameters(
smsBody: 'SMS',
categories: [],
),
throwsA(isA<ArgumentError>()),
);
});
});
});
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:budget_app/services/ai_response_parser.dart';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import '../helpers/ai_responses.dart';
/// Тесты для AiResponseParser - проверяют парсинг ответов ИИ в типизированные модели
void main() {
group('AiResponseParser', () {
late AiResponseParser parser;
setUp(() {
parser = AiResponseParser();
});
group('parseResponse - Transaction SMS', () {
test('должен корректно парсить ответ ИИ для банковской транзакции', () {
// Проверяет парсинг валидного JSON ответа для транзакционного SMS
final response = parser.parseResponse(TestAiResponses.bankTransactionResponse);
expect(response, isA<AiTransactionResponse>());
final transactionResponse = response as AiTransactionResponse;
expect(transactionResponse.isTransaction, isTrue);
expect(transactionResponse.amount, equals(-1250.0));
expect(transactionResponse.vendor, equals('METRO CASH & CARRY'));
expect(transactionResponse.confidence, equals(0.95));
expect(transactionResponse.suggestedCategory, equals('Покупки'));
});
test('должен корректно парсить ответ ИИ для снятия наличных', () {
// Проверяет парсинг различных типов транзакций
final response = parser.parseResponse(TestAiResponses.atmWithdrawalResponse);
expect(response, isA<AiTransactionResponse>());
final transactionResponse = response as AiTransactionResponse;
expect(transactionResponse.amount, equals(-5000.0));
expect(transactionResponse.vendor, equals('Банкомат СБЕРБАНК'));
expect(transactionResponse.confidence, equals(0.9));
expect(transactionResponse.suggestedCategory, equals('Наличные'));
});
test('должен корректно парсить ответ ИИ для доходной транзакции', () {
// Проверяет парсинг положительных сумм (доходы)
final response = parser.parseResponse(TestAiResponses.incomeTransferResponse);
expect(response, isA<AiTransactionResponse>());
final transactionResponse = response as AiTransactionResponse;
expect(transactionResponse.amount, equals(50000.0));
expect(transactionResponse.vendor, equals('РАБОТОДАТЕЛЬ ООО'));
expect(transactionResponse.confidence, equals(0.99));
expect(transactionResponse.suggestedCategory, equals('Зарплата'));
});
});
group('parseResponse - Non-Transaction SMS', () {
test('должен корректно парсить ответ ИИ для рекламного SMS', () {
// Проверяет парсинг не-транзакционных SMS с regex для исключения
final response = parser.parseResponse(TestAiResponses.bankAdvertisingResponse);
expect(response, isA<AiNonTransactionResponse>());
final nonTransactionResponse = response as AiNonTransactionResponse;
expect(nonTransactionResponse.isTransaction, isFalse);
expect(nonTransactionResponse.confidence, equals(0.85));
expect(nonTransactionResponse.exclusionRegex, isNotEmpty);
expect(nonTransactionResponse.exclusionRegex, contains('Специальное предложение'));
});
test('должен корректно парсить ответ ИИ для кода подтверждения', () {
// Проверяет парсинг различных типов не-транзакционных SMS
final response = parser.parseResponse(TestAiResponses.securityCodeResponse);
expect(response, isA<AiNonTransactionResponse>());
final nonTransactionResponse = response as AiNonTransactionResponse;
expect(nonTransactionResponse.confidence, equals(0.95));
expect(nonTransactionResponse.exclusionRegex, contains('Код подтверждения'));
});
});
group('parseResponse - Error Handling', () {
test('должен выбрасывать исключение для пустого ответа', () {
// Проверяет обработку пустых ответов от ИИ
expect(
() => parser.parseResponse(''),
throwsA(isA<AiProcessingException>()),
);
expect(
() => parser.parseResponse(' '),
throwsA(isA<AiProcessingException>()),
);
});
test('должен выбрасывать исключение для некорректного JSON', () {
// Проверяет обработку невалидного JSON
expect(
() => parser.parseResponse(TestAiResponses.invalidJsonResponse),
throwsA(isA<AiResponseParsingException>()),
);
});
test('должен выбрасывать исключение для не-JSON ответа', () {
// Проверяет обработку ответов, не содержащих JSON
expect(
() => parser.parseResponse(TestAiResponses.nonJsonResponse),
throwsA(isA<AiResponseParsingException>()),
);
});
test('должен корректно парсить JSON с дополнительным текстом', () {
// Проверяет извлечение JSON из ответа с дополнительным текстом
const responseWithText = '''
Анализирую SMS сообщение...
{
"isTransaction": true,
"amount": -100.0,
"vendor": "Тест Магазин",
"confidence": 0.8,
"suggestedCategory": "Покупки",
"exclusionRegex": null
}
Анализ завершен.
''';
final response = parser.parseResponse(responseWithText);
expect(response, isA<AiTransactionResponse>());
final transactionResponse = response as AiTransactionResponse;
expect(transactionResponse.amount, equals(-100.0));
expect(transactionResponse.vendor, equals('Тест Магазин'));
});
test('должен передавать SMS ID в исключениях для диагностики', () {
// Проверяет что SMS ID передается в исключениях для отладки
const smsId = 'test_sms_123';
try {
parser.parseResponse('invalid json', smsId: smsId);
fail('Должно было выбросить исключение');
} catch (e) {
expect(e, isA<AiResponseParsingException>());
final exception = e as AiResponseParsingException;
expect(exception.smsId, equals(smsId));
}
});
});
group('parseMultipleResponses', () {
test('должен корректно парсить несколько ответов ИИ', () {
// Проверяет batch обработку множественных ответов
final responses = [
TestAiResponses.bankTransactionResponse,
TestAiResponses.bankAdvertisingResponse,
];
final results = parser.parseMultipleResponses(responses);
expect(results, hasLength(2));
expect(results[0], isA<AiTransactionResponse>());
expect(results[1], isA<AiNonTransactionResponse>());
});
test('должен обрабатывать ошибки в части ответов и возвращать валидные', () {
// Проверяет устойчивость к частичным ошибкам в batch обработке
final responses = [
TestAiResponses.bankTransactionResponse,
TestAiResponses.invalidJsonResponse, // Ошибка
TestAiResponses.bankAdvertisingResponse,
];
final results = parser.parseMultipleResponses(responses);
// Должен вернуть 2 валидных результата, проигнорировав ошибочный
expect(results, hasLength(2));
expect(results[0], isA<AiTransactionResponse>());
expect(results[1], isA<AiNonTransactionResponse>());
});
test('должен выбрасывать исключение если все ответы содержат ошибки', () {
// Проверяет обработку случая когда все ответы невалидны
final responses = [
TestAiResponses.invalidJsonResponse,
TestAiResponses.nonJsonResponse,
'',
];
expect(
() => parser.parseMultipleResponses(responses),
throwsA(isA<AiProcessingException>()),
);
});
test('должен возвращать пустой список для пустого входа', () {
// Проверяет обработку пустого списка ответов
final results = parser.parseMultipleResponses([]);
expect(results, isEmpty);
});
});
group('Utility Methods', () {
test('isValidJson должен корректно определять валидный JSON', () {
// Проверяет утилитарный метод проверки JSON
expect(parser.isValidJson('{"test": true}'), isTrue);
expect(parser.isValidJson('invalid json'), isFalse);
expect(parser.isValidJson(''), isFalse);
expect(parser.isValidJson(' '), isFalse);
});
test('extractJsonString должен извлекать JSON из текста', () {
// Проверяет извлечение JSON части из ответа с дополнительным текстом
const textWithJson = '''
Некоторый текст до JSON
{"isTransaction": true, "amount": -100}
Текст после JSON
''';
final jsonString = parser.extractJsonString(textWithJson);
expect(jsonString, isNotNull);
expect(jsonString, equals('{"isTransaction": true, "amount": -100}'));
});
test('extractJsonString должен возвращать null для текста без JSON', () {
// Проверяет обработку текста без валидного JSON
const textWithoutJson = 'Просто текст без JSON структур';
final jsonString = parser.extractJsonString(textWithoutJson);
expect(jsonString, isNull);
});
});
});
}
@@ -3,9 +3,10 @@ import 'package:mockito/mockito.dart';
import 'package:budget_app/services/ai_transaction_processing_service.dart';
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import '../mocks/mocks.mocks.dart';
import '../mocks/fake_ai_service.dart';
// import '../mocks/fake_ai_service.dart'; // Не используется в новой архитектуре
import '../mocks/mock_ai_service.dart';
import '../mocks/fake_repositories.dart';
import '../helpers/test_data.dart';
@@ -36,24 +37,34 @@ void main() {
fakeRepositories.clearAll();
});
group('_buildAiPrompt', () {
test('should build correct prompt for SMS analysis', () async {
final prompt = await service.buildAiPromptForTesting(TestSmsData.bankTransactionSms);
expect(prompt, contains('=== ЗАДАЧА ==='));
expect(prompt, contains('Проанализируй SMS сообщение'));
expect(prompt, contains(TestSmsData.bankTransactionSms));
expect(prompt, contains('=== ДОСТУПНЫЕ КАТЕГОРИИ ==='));
expect(prompt, contains('Для расходов:'));
expect(prompt, contains('Для доходов:'));
expect(prompt, contains('Покупки'));
expect(prompt, contains('Зарплата'));
expect(prompt, contains('=== ФОРМАТ ОТВЕТА ==='));
expect(prompt, contains('"isTransaction": true'));
expect(prompt, contains('"isTransaction": false'));
expect(prompt, contains('=== ПРИМЕРЫ ==='));
expect(prompt, contains('=== ВАЖНЫЕ ПРАВИЛА ==='));
expect(prompt, contains('точное название'));
group('processSmsByAi - Основные сценарии', () {
/// Проверяет что сервис корректно обрабатывает SMS с банковской транзакцией
/// и создает правильный PrefilledTransaction с категорией и суммой
test('должен корректно обрабатывать SMS с банковской транзакцией', () async {
// Проверяет end-to-end обработку транзакционного SMS
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.bankTransactionResponse);
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_001',
);
// Проверяем результат обработки
expect(result, isNotNull, reason: 'Должен создать PrefilledTransaction для банковской транзакции');
expect(result.smsMessageId, equals('test_sms_001'));
expect(result.amount, equals(-1250.0));
expect(result.salesPoint, equals('METRO CASH & CARRY'));
expect(result.exclusionRegex, isNull);
expect(result.confidence, equals(95.0));
expect(result.calculatedCategory, isNotNull);
expect(result.calculatedCategory!.name, equals('Покупки'));
// Проверяем что результат сохранен в репозиторий
final saved = fakeRepositories.prefilledTransactionRepository
.findBySmsId('test_sms_001');
expect(saved, isNotNull);
});
});
@@ -86,241 +97,123 @@ void main() {
expect(saved, isNotNull);
});
test('should handle AI service not configured', () async {
// Arrange
/// Проверяет что сервис корректно обрабатывает ситуацию когда ИИ не настроен
/// и выбрасывает соответствующее исключение без вызова API
test('должен выбрасывать исключение когда ИИ сервис не настроен', () async {
// Настраиваем мок для имитации неконфигурированного ИИ
when(mockAiService.isConfigured()).thenReturn(false);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_002',
// Проверяем что выбрасывается правильное исключение
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_002',
),
throwsA(isA<AiConfigurationException>()),
);
// Assert
expect(result, isNull);
verify(mockAiService.isConfigured());
verifyNever(mockAiService.sendMessage(any));
});
test('should handle invalid transaction data from AI', () async {
// Arrange
/// Проверяет что сервис корректно обрабатывает некорректные данные от ИИ
/// и выбрасывает исключение валидации вместо создания неправильной транзакции
test('должен отклонять некорректные данные транзакции от ИИ', () async {
// Настраиваем мок для возврата неполных данных транзакции
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.incompleteTransactionResponse);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_003',
// Проверяем что выбрасывается исключение валидации
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_003',
),
throwsA(isA<AiDataValidationException>()),
);
// Assert
expect(result, isNull);
});
test('should handle AI service exception', () async {
// Arrange
/// Проверяет что ошибки ИИ сервиса корректно обрабатываются и оборачиваются
/// в соответствующие исключения для лучшей диагностики проблем
test('должен обрабатывать ошибки ИИ сервиса', () async {
// Настраиваем мок для выброса ошибки ИИ сервиса
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenThrow(Exception('AI service error'));
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_004',
// Проверяем что ошибка оборачивается в AiProcessingException
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'test_sms_004',
),
throwsA(isA<AiProcessingException>()),
);
// Assert
expect(result, isNull);
});
});
group('processSmsByAi - Non-Transaction SMS', () {
test('should process non-transaction SMS successfully', () async {
// Arrange
group('processSmsByAi - Не-транзакционные SMS', () {
/// Проверяет что сервис корректно обрабатывает рекламные/спам SMS сообщения
/// и создает PrefilledTransaction с regex для исключения похожих сообщений
test('должен корректно обрабатывать не-транзакционные SMS (реклама)', () async {
// Настраиваем мок для обработки рекламного SMS
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.bankAdvertisingResponse);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankAdvertisingSms,
'test_spam_001',
);
// Assert
// Проверяем характеристики не-транзакционного SMS
expect(result, isNotNull);
expect(result.smsMessageId, equals('test_spam_001'));
expect(result.amount, isNull);
expect(result.salesPoint, isNull);
expect(result.calculatedCategory, isNull);
expect(result.exclusionRegex, isNotNull);
expect(result.amount, isNull, reason: 'У рекламных SMS не должно быть суммы');
expect(result.salesPoint, isNull, reason: 'У рекламных SMS не должно быть точки продаж');
expect(result.calculatedCategory, isNull, reason: 'У рекламных SMS не должно быть категории');
expect(result.exclusionRegex, isNotNull, reason: 'Должен быть создан regex для исключения');
expect(result.exclusionRegex, contains('Специальное предложение'));
expect(result.confidence, equals(85.0));
// Verify it was saved to repository
// Проверяем что результат сохранен в репозиторий
final saved = fakeRepositories.prefilledTransactionRepository
.findBySmsId('test_spam_001');
expect(saved, isNotNull);
});
test('should handle invalid non-transaction data from AI', () async {
// Arrange
/// Проверяет что неполные данные не-транзакции отклоняются с соответствующим исключением
test('должен отклонять некорректные данные не-транзакции от ИИ', () async {
// Настраиваем мок для возврата неполных данных не-транзакции
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.incompleteNonTransactionResponse);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankAdvertisingSms,
'test_spam_002',
// Проверяем что выбрасывается исключение валидации
expect(
() => service.processSmsByAi(
TestSmsData.bankAdvertisingSms,
'test_spam_002',
),
throwsA(isA<AiDataValidationException>()),
);
// Assert
expect(result, isNull);
});
});
group('_validateTransactionData', () {
test('should validate correct transaction data', () {
final data = {
'isTransaction': true,
'amount': -1250.0,
'vendor': 'METRO CASH & CARRY',
'confidence': 0.95,
'suggestedCategory': 'shopping',
'exclusionRegex': null,
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isTrue);
});
test('should reject transaction data with missing amount', () {
final data = {
'isTransaction': true,
'vendor': 'METRO CASH & CARRY',
'confidence': 0.95,
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject transaction data with missing vendor', () {
final data = {
'isTransaction': true,
'amount': -1250.0,
'confidence': 0.95,
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject transaction data with exclusionRegex', () {
final data = {
'isTransaction': true,
'amount': -1250.0,
'vendor': 'METRO CASH & CARRY',
'confidence': 0.95,
'exclusionRegex': 'some regex',
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject transaction data with zero amount', () {
final data = {
'isTransaction': true,
'amount': 0.0,
'vendor': 'METRO CASH & CARRY',
'confidence': 0.95,
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject transaction data with invalid confidence', () {
final data = {
'isTransaction': true,
'amount': -1250.0,
'vendor': 'METRO CASH & CARRY',
'confidence': 1.5, // Invalid: > 1
};
final result = service.validateTransactionDataForTesting(data);
expect(result, isFalse);
});
});
group('_validateNonTransactionData', () {
test('should validate correct non-transaction data', () {
final data = {
'isTransaction': false,
'amount': null,
'vendor': null,
'confidence': 0.85,
'exclusionRegex': '.*spam.*',
};
final result = service.validateNonTransactionDataForTesting(data);
expect(result, isTrue);
});
test('should reject non-transaction data with missing exclusionRegex', () {
final data = {
'isTransaction': false,
'amount': null,
'vendor': null,
'confidence': 0.85,
};
final result = service.validateNonTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject non-transaction data with amount', () {
final data = {
'isTransaction': false,
'amount': -100.0,
'vendor': null,
'confidence': 0.85,
'exclusionRegex': '.*spam.*',
};
final result = service.validateNonTransactionDataForTesting(data);
expect(result, isFalse);
});
test('should reject non-transaction data with vendor', () {
final data = {
'isTransaction': false,
'amount': null,
'vendor': 'Some Vendor',
'confidence': 0.85,
'exclusionRegex': '.*spam.*',
};
final result = service.validateNonTransactionDataForTesting(data);
expect(result, isFalse);
});
});
group('createAiRuleFromPrefilled', () {
test('should create pointOfSale rule from transaction prefilled', () async {
// Arrange
group('createAiRuleFromPrefilled - Создание правил автоматизации', () {
/// Проверяет создание правила для точки продаж на основе обработанной транзакции
/// Это позволяет автоматически категоризировать будущие SMS от того же продавца
test('должен создавать правило точки продаж из транзакционного prefilled', () async {
final prefilled = TestObjects.metroTransaction;
// Act
final result = await service.createAiRuleFromPrefilled(
prefilled,
AiRuleType.pointOfSale,
);
// Assert
// Проверяем созданное правило автокатегоризации
expect(result, isNotNull);
expect(result!.type, equals(AiRuleType.pointOfSale));
expect(result.name, contains('METRO CASH & CARRY'));
@@ -328,244 +221,201 @@ void main() {
expect(result.categoryId, equals(TestObjects.shoppingCategory.id));
expect(result.confidencePercentage, equals(95));
// Verify it was saved to repository
// Проверяем что правило сохранено в репозиторий
final saved = fakeRepositories.aiRuleRepository.findByName('METRO');
expect(saved.isNotEmpty, isTrue);
});
test('should create skipTemplate rule from non-transaction prefilled', () async {
// Arrange
/// Проверяет создание правила пропуска для не-транзакционных SMS
/// Это позволяет автоматически исключать похожие рекламные сообщения
test('должен создавать правило пропуска из не-транзакционного prefilled', () async {
final prefilled = TestObjects.bankAdvertising;
// Act
final result = await service.createAiRuleFromPrefilled(
prefilled,
AiRuleType.skipTemplate,
);
// Assert
// Проверяем созданное правило автопропуска
expect(result, isNotNull);
expect(result!.type, equals(AiRuleType.skipTemplate));
expect(result.name, contains('Автопропуск'));
expect(result.skipRegex, equals(prefilled.exclusionRegex));
expect(result.confidencePercentage, equals(85));
// Verify it was saved to repository
// Проверяем что правило сохранено в репозиторий
final saved = fakeRepositories.aiRuleRepository.getSkipRules();
expect(saved.length, greaterThan(2)); // Существующие + новое правило
});
test('should fail to create pointOfSale rule without required data', () async {
// Arrange
/// Проверяет что невозможно создать правило точки продаж без необходимых данных
test('должен возвращать null при создании правила точки продаж без salesPoint', () async {
final prefilled = TestObjects.bankAdvertising; // Не имеет salesPoint
// Act
final result = await service.createAiRuleFromPrefilled(
prefilled,
AiRuleType.pointOfSale,
);
// Assert
expect(result, isNull);
expect(result, isNull, reason: 'Нельзя создать правило без точки продаж');
});
test('should fail to create skipTemplate rule without exclusionRegex', () async {
// Arrange
/// Проверяет что невозможно создать правило пропуска без regex
test('должен возвращать null при создании правила пропуска без exclusionRegex', () async {
final prefilled = TestObjects.createTransactionPrefilled(
exclusionRegex: null, // Нет exclusionRegex
);
// Act
final result = await service.createAiRuleFromPrefilled(
prefilled,
AiRuleType.skipTemplate,
);
// Assert
expect(result, isNull);
expect(result, isNull, reason: 'Нельзя создать правило пропуска без regex');
});
test('should create skipTemplate rule with custom name', () async {
// Arrange
/// Проверяет возможность создания правила с кастомным именем
test('должен создавать правило пропуска с кастомным именем', () async {
final prefilled = TestObjects.bankAdvertising;
const customName = 'Мое кастомное правило пропуска';
// Act
final result = await service.createAiRuleFromPrefilled(
prefilled,
AiRuleType.skipTemplate,
customName: customName,
);
// Assert
expect(result, isNotNull);
expect(result!.name, equals(customName));
});
});
group('convertToTransactionRecord', () {
test('should convert prefilled transaction to transaction record', () async {
// Arrange
group('convertToTransactionRecord - Конвертация в финальную транзакцию', () {
/// Проверяет преобразование обработанного ИИ SMS в финальную транзакцию
/// После этого транзакция попадает в основную базу данных пользователя
test('должен конвертировать prefilled транзакцию в запись транзакции', () async {
final prefilled = TestObjects.metroTransaction;
// Act
final result = await service.convertToTransactionRecord(prefilled);
// Assert
// Проверяем корректность конвертации
expect(result.amount, equals(prefilled.amount));
expect(result.vendor, equals(prefilled.salesPoint));
expect(result.categoryId, equals(prefilled.calculatedCategory!.id));
expect(result.currency, equals('RUB'));
// Verify prefilled was updated with transaction ID
// Проверяем что prefilled обновлен с ID транзакции для связи
expect(prefilled.transactionId, equals(result.id));
});
test('should fail to convert prefilled without required fields', () async {
// Arrange
final prefilled = TestObjects.createTransactionPrefilled(
amount: null, // Missing amount
/// Проверяет что конвертация невозможна без обязательных полей
test('должен отклонять конвертацию prefilled без обязательных полей', () async {
final prefilled = PrefilledTransaction(
smsMessageId: 'test_invalid',
transactionId: null,
amount: null, // Отсутствует сумма - должно вызвать ошибку
salesPoint: 'Test Vendor',
calculatedCategory: TestObjects.shoppingCategory,
confidence: 90.0,
exclusionRegex: null,
);
// Act & Assert
expect(
() => service.convertToTransactionRecord(prefilled),
throwsA(isA<ArgumentError>()),
reason: 'Нельзя создать транзакцию без суммы',
);
});
test('should use provided categoryId over calculated category', () async {
// Arrange
/// Проверяет что пользователь может переопределить категорию при конвертации
test('должен использовать предоставленную категорию вместо рассчитанной', () async {
final prefilled = TestObjects.metroTransaction;
const providedCategoryId = 'custom_category_id';
// Act
final result = await service.convertToTransactionRecord(
prefilled,
categoryId: providedCategoryId,
);
// Assert
expect(result.categoryId, equals(providedCategoryId));
expect(result.categoryId, equals(providedCategoryId),
reason: 'Должна использоваться категория, выбранная пользователем');
});
});
group('Edge Cases and Error Handling', () {
test('should handle empty SMS message', () async {
// Arrange
group('Граничные случаи и обработка ошибок', () {
/// Проверяет что пустые SMS сообщения отклоняются на этапе валидации
/// без вызова ИИ API для экономии ресурсов
test('должен отклонять пустые SMS сообщения', () async {
when(mockAiService.isConfigured()).thenReturn(true);
// Act
final result = await service.processSmsByAi('', 'empty_sms');
// Assert
expect(result, isNull);
verifyNever(mockAiService.sendMessage(any));
});
test('should handle empty SMS message ID', () async {
// Arrange
when(mockAiService.isConfigured()).thenReturn(true);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'',
// Проверяем различные варианты пустых сообщений
expect(
() => service.processSmsByAi('', 'empty_sms'),
throwsA(isA<AiProcessingException>()),
);
// Assert
expect(result, isNull);
verifyNever(mockAiService.sendMessage(any));
});
test('should handle invalid JSON response from AI', () async {
// Arrange
/// Проверяет что пустые ID SMS сообщений отклоняются для корректного трекинга
test('должен отклонять пустые ID SMS сообщений', () async {
when(mockAiService.isConfigured()).thenReturn(true);
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'',
),
throwsA(isA<AiProcessingException>()),
);
verifyNever(mockAiService.sendMessage(any));
});
/// Проверяет что некорректный JSON от ИИ обрабатывается как ошибка парсинга
test('должен обрабатывать некорректный JSON ответ от ИИ', () async {
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.invalidJsonResponse);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'invalid_json_test',
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'invalid_json_test',
),
throwsA(isA<AiResponseParsingException>()),
);
// Assert
expect(result, isNull);
});
test('should handle empty response from AI', () async {
// Arrange
/// Проверяет что пустые ответы от ИИ обрабатываются как ошибка обработки
test('должен обрабатывать пустые ответы от ИИ', () async {
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => '');
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'empty_response_test',
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'empty_response_test',
),
throwsA(isA<AiProcessingException>()),
);
// Assert
expect(result, isNull);
});
test('should handle non-JSON response from AI', () async {
// Arrange
/// Проверяет что не-JSON ответы от ИИ обрабатываются как ошибка парсинга
test('должен обрабатывать не-JSON ответы от ИИ', () async {
when(mockAiService.isConfigured()).thenReturn(true);
when(mockAiService.sendMessage(any))
.thenAnswer((_) async => TestAiResponses.nonJsonResponse);
// Act
final result = await service.processSmsByAi(
TestSmsData.bankTransactionSms,
'non_json_test',
expect(
() => service.processSmsByAi(
TestSmsData.bankTransactionSms,
'non_json_test',
),
throwsA(isA<AiResponseParsingException>()),
);
// Assert
expect(result, isNull);
});
});
group('Additional validation and parsing tests', () {
test('should parse valid AI response correctly', () {
final response = TestAiResponses.bankTransactionResponse;
final result = service.parseAiResponseForTesting(response);
expect(result, isNotNull);
expect(result!['isTransaction'], isTrue);
expect(result['amount'], equals(-1250.0));
expect(result['vendor'], equals('METRO CASH & CARRY'));
});
test('should handle malformed JSON in AI response', () {
final response = TestAiResponses.invalidJsonResponse;
final result = service.parseAiResponseForTesting(response);
expect(result, isNull);
});
test('should create correct merchant pattern', () {
const salesPoint = 'METRO CASH & CARRY';
final pattern = service.createMerchantPatternForTesting(salesPoint);
expect(pattern, contains('METRO CASH & CARRY'));
expect(pattern, startsWith('.*'));
expect(pattern, endsWith('.*'));
});
test('should escape special regex characters in merchant pattern', () {
const salesPoint = 'Test (Store) [Bracket] +Plus';
final pattern = service.createMerchantPatternForTesting(salesPoint);
// Проверяем что специальные символы экранированы
expect(pattern, contains('\\('));
expect(pattern, contains('\\)'));
expect(pattern, contains('\\['));
expect(pattern, contains('\\]'));
expect(pattern, contains('\\+'));
});
});
group('Mock AI Service Tests', () {
late MockAiService mockAiService;
@@ -647,11 +497,11 @@ void main() {
// Act & Assert: Должен выбросить AiProcessingException
expect(
() async => await mockService.processSmsByAi(
() => mockService.processSmsByAi(
'Тест пустой',
'format_test_002',
),
throwsA(isA<Exception>()),
throwsA(isA<AiProcessingException>()),
reason: 'Пустой ответ от ИИ должен вызывать исключение',
);
});
@@ -660,14 +510,14 @@ void main() {
// Arrange: Настраиваем мок на выброс ошибки
mockAiService.setShouldThrowError(true, 'Test connection error');
// Act
final result = await mockService.processSmsByAi(
'Тест ошибка',
'error_test_001',
// Act & Assert: Должен выбросить AiProcessingException
expect(
() => mockService.processSmsByAi(
'Тест ошибка',
'error_test_001',
),
throwsA(isA<AiProcessingException>()),
);
// Assert: Должен вернуть null без падения
expect(result, isNull);
});
test('should validate transaction data from AI - reject invalid amount', () async {
@@ -681,19 +531,19 @@ void main() {
'exclusionRegex': null,
});
// Act
final result = await mockService.processSmsByAi(
'Некорректные_данные тест',
'validation_test_001',
// Act & Assert: Должен выбросить AiDataValidationException
expect(
() => mockService.processSmsByAi(
'Некорректные_данные тест',
'validation_test_001',
),
throwsA(isA<AiDataValidationException>()),
);
// Assert: Должен отклонить некорректные данные
expect(result, isNull);
});
test('should validate transaction data from AI - reject empty vendor', () async {
// Arrange: Настраиваем некорректный ответ с пустым vendor
mockAiService.setCustomResponse('некорректные_данные', {
mockAiService.setCustomResponse('некорректные_данные2', {
'isTransaction': true,
'amount': -150.0,
'vendor': '', // Пустой vendor - должен быть отклонен
@@ -702,14 +552,14 @@ void main() {
'exclusionRegex': null,
});
// Act
final result = await mockService.processSmsByAi(
'Некорректные_данные тест',
'validation_test_002',
// Act & Assert: Должен выбросить AiDataValidationException
expect(
() => mockService.processSmsByAi(
'Некорректные_данные2 тест',
'validation_test_002',
),
throwsA(isA<AiDataValidationException>()),
);
// Assert: Должен отклонить некорректные данные
expect(result, isNull);
});
});
});