Remake ai
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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('Должна быть хотя бы одна категория доходов или расходов');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user