From 1982f199ac5c617e2b73e878e020604d9c22c40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 26 Feb 2026 11:47:00 +0100 Subject: [PATCH] Rework PromptService - Use dependency injection and Get.find instead of manual singleton pattern - Move from domain to data layer - Prevents multiple API calls if the previous request hasn't finished --- .../setup_scribe_prompt_url_extension.dart | 18 ++- .../bindings/network/network_bindings.dart | 2 + scribe/lib/scribe.dart | 2 +- .../ai/data/service/prompt_service.dart | 100 +++++++++++++++++ .../ai/domain/constants/ai_prompts.dart | 5 +- .../scribe/ai/domain/model/prompt_data.dart | 54 ++++----- .../ai/domain/service/prompt_service.dart | 106 ------------------ .../service/prompt_service_test.dart | 50 +++++++-- 8 files changed, 182 insertions(+), 155 deletions(-) create mode 100644 scribe/lib/scribe/ai/data/service/prompt_service.dart delete mode 100644 scribe/lib/scribe/ai/domain/service/prompt_service.dart rename scribe/test/scribe/ai/{domain => data}/service/prompt_service_test.dart (63%) diff --git a/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart b/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart index fe5f9c7d8..53079eb8e 100644 --- a/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart +++ b/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart @@ -1,5 +1,5 @@ import 'package:core/utils/app_logger.dart'; -import 'package:scribe/scribe/ai/domain/service/prompt_service.dart'; +import 'package:scribe/scribe/ai/data/service/prompt_service.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/get_scribe_prompt_url_state.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_scribe_prompt_url_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; @@ -22,11 +22,23 @@ extension SetupScribePromptUrlExtension on MailboxDashBoardController { } void handleGetScribePromptUrlSuccess(GetScribePromptUrlSuccess success) { - PromptService().setPromptUrl(success.promptUrl); + final promptService = getBinding(); + + if (promptService != null) { + promptService.setPromptUrl(success.promptUrl); + } else { + logWarning('SetupScribePromptUrlExtension::handleGetScribePromptUrlFailure: PromptService not found'); + } } void handleGetScribePromptUrlFailure(GetScribePromptUrlFailure failure) { logError('SetupScribePromptUrlExtension::handleGetScribePromptUrlFailure: GetScribePromptUrl failed - ${failure.exception}'); - PromptService().setPromptUrl(null); + final promptService = getBinding(); + + if (promptService != null) { + promptService.setPromptUrl(null); + } else { + logWarning('SetupScribePromptUrlExtension::handleGetScribePromptUrlFailure: PromptService not found'); + } } } diff --git a/lib/main/bindings/network/network_bindings.dart b/lib/main/bindings/network/network_bindings.dart index 44fe28047..d997a201d 100644 --- a/lib/main/bindings/network/network_bindings.dart +++ b/lib/main/bindings/network/network_bindings.dart @@ -8,6 +8,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:scribe/scribe/ai/data/service/prompt_service.dart'; import 'package:tmail_ui_user/features/contact/data/network/contact_api.dart'; import 'package:tmail_ui_user/features/email/data/network/email_api.dart'; import 'package:tmail_ui_user/features/email/data/network/mdn_api.dart'; @@ -146,5 +147,6 @@ class NetworkBindings extends Bindings { void _bindingServices() { Get.put(DnsLookupManager()); + Get.put(PromptService(Get.find())); } } \ No newline at end of file diff --git a/scribe/lib/scribe.dart b/scribe/lib/scribe.dart index 24d81532e..e12789f72 100644 --- a/scribe/lib/scribe.dart +++ b/scribe/lib/scribe.dart @@ -4,7 +4,7 @@ export 'scribe/ai/data/datasource/ai_datasource.dart'; export 'scribe/ai/data/repository/ai_repository_impl.dart'; export 'scribe/ai/domain/model/ai_response.dart'; export 'scribe/ai/domain/repository/ai_scribe_repository.dart'; -export 'scribe/ai/domain/service/prompt_service.dart'; +export 'scribe/ai/data/service/prompt_service.dart'; export 'scribe/ai/domain/state/generate_ai_text_state.dart'; export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; export 'scribe/ai/localizations/scribe_localizations.dart'; diff --git a/scribe/lib/scribe/ai/data/service/prompt_service.dart b/scribe/lib/scribe/ai/data/service/prompt_service.dart new file mode 100644 index 000000000..a767538d5 --- /dev/null +++ b/scribe/lib/scribe/ai/data/service/prompt_service.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:core/data/network/dio_client.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; +import 'package:flutter/services.dart' show rootBundle; + +class PromptService { + static const String _defaultAssetPath = 'packages/scribe/assets/prompts.json'; + + final DioClient _dioClient; + + String? _promptUrl; + PromptData? _promptData; + Future? _loadingFuture; + + PromptService(this._dioClient); + + void setPromptUrl(String? url) { + if (_promptUrl == url) return; + _promptUrl = url; + _promptData = null; + _loadingFuture = null; + } + + String? get promptUrl => _promptUrl; + + // Prevents multiple API calls if the previous request hasn't finished + Future loadPrompts() { + if (_promptData != null) { + return Future.value(_promptData!); + } + + if (_loadingFuture != null) { + return _loadingFuture!; + } + + _loadingFuture = _fetchAndCachePrompts().whenComplete(() { + _loadingFuture = null; + }); + + return _loadingFuture!; + } + + // Prioritize loading from remote or fallback to load local assets + Future _fetchAndCachePrompts() async { + if (_promptUrl != null) { + try { + _promptData = await _fetchPromptsFromUrl(_promptUrl!); + return _promptData!; + } catch (e) { + log('PromptService::loadPrompts: failed to fetch from remote URL: $e'); + } + } + + _promptData = await _loadPromptsFromAssets(); + return _promptData!; + } + + Future _fetchPromptsFromUrl(String url) async { + log('PromptService::_fetchPromptsFromUrl: Fetching from $url'); + + try { + final data = await _dioClient.get(url); + + final promptsMap = data is String + ? jsonDecode(data) as Map + : data as Map; + + return PromptData.fromJson(promptsMap); + } catch (e) { + throw Exception('Failed to fetch prompts: $e'); + } + } + + Future _loadPromptsFromAssets() async { + try { + final jsonString = await rootBundle.loadString(_defaultAssetPath); + final jsonData = jsonDecode(jsonString) as Map; + return PromptData.fromJson(jsonData); + } catch (e) { + throw Exception('Failed to load local prompts: $e'); + } + } + + Future getPromptByName(String name) async { + final promptData = await loadPrompts(); + try { + return promptData.prompts.firstWhere((prompt) => prompt.name == name); + } catch (_) { + throw Exception('Prompt not found: $name'); + } + } + + Future> buildPromptByName(String name, String inputText, {String? task}) async { + final prompt = await getPromptByName(name); + return prompt.buildPrompt(inputText, task: task); + } +} \ No newline at end of file diff --git a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart index b44293e91..721e69e67 100644 --- a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart +++ b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart @@ -1,10 +1,11 @@ import 'package:scribe/scribe/ai/data/model/ai_message.dart'; import 'package:scribe/scribe/ai/presentation/model/ai_action.dart'; import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; -import 'package:scribe/scribe/ai/domain/service/prompt_service.dart'; +import 'package:scribe/scribe/ai/data/service/prompt_service.dart'; +import 'package:get/get.dart'; class AIPrompts { - static final PromptService _promptService = PromptService(); + static final PromptService _promptService = Get.find(); static Future> buildPrompt(AIAction action, String? text) async { return switch (action) { diff --git a/scribe/lib/scribe/ai/domain/model/prompt_data.dart b/scribe/lib/scribe/ai/domain/model/prompt_data.dart index abc062039..8b54a70d8 100644 --- a/scribe/lib/scribe/ai/domain/model/prompt_data.dart +++ b/scribe/lib/scribe/ai/domain/model/prompt_data.dart @@ -7,17 +7,15 @@ class PromptData { required this.prompts, }); - factory PromptData.fromJson(Map json) { - final promptsJson = json['prompts']; - if (promptsJson is! List) { - return PromptData(prompts: []); - } +factory PromptData.fromJson(Map json) { + final promptsJson = json['prompts'] as List?; return PromptData( prompts: promptsJson - .whereType>() - .map((promptJson) => Prompt.fromJson(promptJson)) - .toList(), + ?.whereType>() + .map(Prompt.fromJson) + .toList() ?? + const [], ); } } @@ -37,41 +35,35 @@ class Prompt { throw const FormatException('Prompt name must be a non-null String'); } - final messagesJson = json['messages']; - if (messagesJson is! List) { - return Prompt(name: name, messages: []); - } - + final messagesJson = json['messages'] as List?; + return Prompt( name: name, messages: messagesJson - .whereType>() - .map((messageJson) => AIMessage.fromJson(messageJson)) - .toList(), + ?.whereType>() + .map(AIMessage.fromJson) + .toList() ?? + const [], ); } List buildPrompt(String inputText, {String? task}) { - final messages = []; - for (final message in this.messages) { - if (message.role == AIRole.system) { - messages.add(AIMessage.ofSystem(message.content)); - } else if (message.role == AIRole.user) { - final userContent = _replacePlaceholders(message.content, inputText, task); - messages.add(AIMessage.ofUser(userContent)); - } - } - return messages; + return [ + for (final message in messages) + if (message.role == AIRole.system) + AIMessage.ofSystem(message.content) + else if (message.role == AIRole.user) + AIMessage.ofUser(_replacePlaceholders(message.content, inputText, task)) + ]; } String _replacePlaceholders(String content, String inputText, String? task) { - String result = content; - if (result.contains('{{input}}')) { - result = result.replaceAll('{{input}}', inputText); - } - if (task != null && result.contains('{{task}}')) { + var result = content.replaceAll('{{input}}', inputText); + + if (task != null) { result = result.replaceAll('{{task}}', task); } + return result; } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/domain/service/prompt_service.dart b/scribe/lib/scribe/ai/domain/service/prompt_service.dart deleted file mode 100644 index bb703ef07..000000000 --- a/scribe/lib/scribe/ai/domain/service/prompt_service.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:convert'; -import 'package:core/data/network/dio_client.dart'; -import 'package:core/utils/app_logger.dart'; -import 'package:dio/dio.dart'; -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; -import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; -import 'package:flutter/services.dart' show rootBundle; - -class PromptService { - static PromptService? _instance; - final DioClient _dioClient; - - factory PromptService({DioClient? dioClient}) { - _instance ??= PromptService._internal(dioClient: dioClient); - return _instance!; - } - - PromptService._internal({DioClient? dioClient}) - : _dioClient = dioClient ?? DioClient(Dio(BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - ))); - - PromptData? _promptData; - String? _promptUrl; - - void setPromptUrl(String? url) { - _promptUrl = url; - } - - String? get promptUrl => _promptUrl; - - Future loadPrompts() async { - // App in memory cache - if (_promptData != null) { - return _promptData!; - } - - // First try: remote prompts - if (_promptUrl != null) { - try { - _promptData = await _fetchPromptsFromUrl(_promptUrl!); - return _promptData!; - } catch (e) { - log('PromptService::loadPrompts: failed to fetch from remote URL: $e'); - } - } - - // Fallback: local prompts - return _loadPromptsFromAssets(); - } - - Future _fetchPromptsFromUrl(String url) async { - log('PromptService::_fetchPromptsFromUrl: Fetching from $url'); - - try { - final response = await _dioClient.get(url); - - if (response.statusCode == 200) { - final jsonData = response.data; - Map promptsMap; - - if (jsonData is String) { - promptsMap = jsonDecode(jsonData) as Map; - } else if (jsonData is Map) { - promptsMap = jsonData; - } else { - throw Exception('Failed to fetch prompts: invalid response format'); - } - - log('PromptService::_fetchPromptsFromUrl: Successfully fetched prompts'); - return PromptData.fromJson(promptsMap); - } else { - throw Exception('Failed to fetch prompts: unexpected status code ${response.statusCode}'); - } - } catch (e) { - log('PromptService::_fetchPromptsFromUrl: Exception: $e'); - rethrow; - } - } - - Future _loadPromptsFromAssets() async { - try { - final jsonString = await rootBundle.loadString('packages/scribe/assets/prompts.json'); - final jsonData = jsonDecode(jsonString) as Map; - - _promptData = PromptData.fromJson(jsonData); - return _promptData!; - } catch (e) { - throw Exception('Failed to load prompts: $e'); - } - } - - Future getPromptByName(String name) async { - final promptData = await loadPrompts(); - return promptData.prompts.firstWhere( - (prompt) => prompt.name == name, - orElse: () => throw Exception('Prompt not found: $name'), - ); - } - - Future> buildPromptByName(String name, String inputText, {String? task}) async { - final prompt = await getPromptByName(name); - return prompt.buildPrompt(inputText, task: task); - } -} \ No newline at end of file diff --git a/scribe/test/scribe/ai/domain/service/prompt_service_test.dart b/scribe/test/scribe/ai/data/service/prompt_service_test.dart similarity index 63% rename from scribe/test/scribe/ai/domain/service/prompt_service_test.dart rename to scribe/test/scribe/ai/data/service/prompt_service_test.dart index f308f847a..dd3700c63 100644 --- a/scribe/test/scribe/ai/domain/service/prompt_service_test.dart +++ b/scribe/test/scribe/ai/data/service/prompt_service_test.dart @@ -1,28 +1,54 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:scribe/scribe/ai/domain/service/prompt_service.dart'; +import 'package:core/data/network/dio_client.dart'; +import 'package:dio/dio.dart'; +import 'package:scribe/scribe/ai/data/service/prompt_service.dart'; import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +class TestDioClient implements DioClient { + @override + Future get(String path, {Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onReceiveProgress}) { + throw Exception('Not found'); + } + + @override + Future post(String path, {data, Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, bool useJMAPHeader = true}) { + throw UnsupportedError('post not implemented'); + } + + @override + Future delete(String path, {data, Map? queryParameters, Options? options, CancelToken? cancelToken}) { + throw UnsupportedError('delete not implemented'); + } + + @override + Future put(String path, {data, Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress}) { + throw UnsupportedError('put not implemented'); + } + + @override + Map getHeaders() { + return {}; + } +} + void main() { setUpAll(() { TestWidgetsFlutterBinding.ensureInitialized(); }); group('PromptService', () { - test('PromptService should be singleton', () { - // Act - final service1 = PromptService(); - final service2 = PromptService(); - - // Assert - expect(service1, same(service2)); + late TestDioClient testDioClient; + + setUp(() { + testDioClient = TestDioClient(); }); test('buildPromptByName should build prompt with input text', () async { // Arrange - final service = PromptService(); + final service = PromptService(testDioClient); - // Act - this will use the real prompts.json file + // Act - this will use the real prompts.json file since the test client throws final messages = await service.buildPromptByName('change-tone-casual', 'Hello, how are you?'); // Assert @@ -34,9 +60,9 @@ void main() { test('buildPromptByName should build prompt with input text and task', () async { // Arrange - final service = PromptService(); + final service = PromptService(testDioClient); - // Act - this will use the real prompts.json file + // Act - this will use the real prompts.json file since the test client throws final messages = await service.buildPromptByName('custom-prompt-mail', 'Hello, how are you?', task: 'Make it more casual'); // Assert