From 04696eecd7e8ec69e03a8e240ef9bd361af54a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 29 Jan 2026 16:27:37 +0100 Subject: [PATCH] Get and build prompt from local JSON file Here we map our AI actions to the prompt from the JSON file to be able to use them. --- .../ai/domain/constants/ai_prompts.dart | 85 ++------- .../scribe/ai/domain/model/prompt_data.dart | 77 ++++++++ .../ai/domain/service/prompt_service.dart | 43 +++++ .../usecases/generate_ai_text_interactor.dart | 2 +- .../ai/presentation/model/ai_action.dart | 2 + .../model/ai_scribe_menu_action.dart | 17 ++ scribe/test/assets/test_prompts.json | 30 ++++ .../ai/domain/model/prompt_data_test.dart | 167 ++++++++++++++++++ .../domain/service/prompt_service_test.dart | 101 +++++++++++ 9 files changed, 449 insertions(+), 75 deletions(-) create mode 100644 scribe/lib/scribe/ai/domain/model/prompt_data.dart create mode 100644 scribe/lib/scribe/ai/domain/service/prompt_service.dart create mode 100644 scribe/test/assets/test_prompts.json create mode 100644 scribe/test/scribe/ai/domain/model/prompt_data_test.dart create mode 100644 scribe/test/scribe/ai/domain/service/prompt_service_test.dart diff --git a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart index 73b934695..b44293e91 100644 --- a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart +++ b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart @@ -1,91 +1,28 @@ 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'; class AIPrompts { - static const _performTask = "Perform only the following task:"; - static const _preserveLanguagePrompt = - "Do not translate. Strictly keep the original language of the input text. For example, if it's French, keep French. If it's English, keep English."; - static const _doNotAddInfoPrompt = - "Do not add any extra information or interpret anything beyond the explicit task."; + static final PromptService _promptService = PromptService(); - static List buildPrompt(AIAction action, String? text) { - final prompt = switch (action) { + static Future> buildPrompt(AIAction action, String? text) async { + return switch (action) { PredefinedAction(action: final menuAction) => - text?.trim().isNotEmpty == true - ? buildPredefinedPrompt(menuAction, text!) - : throw ArgumentError('Text cannot be empty for predefined actions'), + buildActionPrompt(menuAction, text), CustomPromptAction(prompt: final customPrompt) => buildCustomPrompt(customPrompt, text), }; - - final message = [AIMessage.ofUser(prompt)]; - - return message; } - static String buildPredefinedPrompt(AIScribeMenuAction action, String text) { - switch (action) { - case AIScribeMenuAction.correctGrammar: - return correctGrammar(text); - case AIScribeMenuAction.improveMakeShorter: - return improveMakeShorter(text); - case AIScribeMenuAction.improveExpandContext: - return improveExpandContext(text); - case AIScribeMenuAction.improveEmojify: - return improveEmojify(text); - case AIScribeMenuAction.improveTransformToBullets: - return improveTransformToBullets(text); - case AIScribeMenuAction.changeToneProfessional: - return changeToneTo(text, 'professional'); - case AIScribeMenuAction.changeToneCasual: - return changeToneTo(text, 'casual'); - case AIScribeMenuAction.changeTonePolite: - return changeToneTo(text, 'polite'); - case AIScribeMenuAction.translateFrench: - return translateTo(text, 'French'); - case AIScribeMenuAction.translateEnglish: - return translateTo(text, 'English'); - case AIScribeMenuAction.translateRussian: - return translateTo(text, 'Russian'); - case AIScribeMenuAction.translateVietnamese: - return translateTo(text, 'Vietnamese'); + static Future> buildActionPrompt(AIScribeMenuAction menuAction, String? text) async { + if (text == null || text.trim().isEmpty) { + throw ArgumentError('Text cannot be empty for predefined actions'); } + return await _promptService.buildPromptByName(menuAction.promptId, text); } - static String improveMakeShorter(String text) { - return '$_performTask make the text shorter but preserve the meaning. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String improveExpandContext(String text) { - return '$_performTask expand the context of the text to make it more detailed and comprehensive. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String improveEmojify(String text) { - return '$_performTask add emojis to the important parts of the text. Do not try to rephrase or replace text. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String improveTransformToBullets(String text) { - return '$_performTask transform the text into a bullet list. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String correctGrammar(String text) { - return '$_performTask correct grammar and spelling. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String changeToneTo(String text, String tone) { - return '$_performTask change the tone to be $tone. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String translateTo(String text, String language) { - return '$_performTask translate. Translate the text to the specified language: $language. $_doNotAddInfoPrompt Text:\n\n$text'; - } - - static String buildCustomPrompt(String customPrompt, String? text) { - if (text == null) { - return customPrompt; - } - - return 'You help the user write an email following his instruction: $customPrompt\n\nDo not output a subject or a signature, only the content of the email. Text:\n\n$text'; + static Future> buildCustomPrompt(String customPrompt, String? text) async { + return await _promptService.buildPromptByName(CustomPromptAction.promptId, text ?? '', task: customPrompt); } } diff --git a/scribe/lib/scribe/ai/domain/model/prompt_data.dart b/scribe/lib/scribe/ai/domain/model/prompt_data.dart new file mode 100644 index 000000000..14509de9b --- /dev/null +++ b/scribe/lib/scribe/ai/domain/model/prompt_data.dart @@ -0,0 +1,77 @@ +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; + +class PromptData { + final List prompts; + + PromptData({ + required this.prompts, + }); + + factory PromptData.fromJson(Map json) { + final promptsJson = json['prompts']; + if (promptsJson is! List) { + return PromptData(prompts: []); + } + + return PromptData( + prompts: promptsJson + .whereType>() + .map((promptJson) => Prompt.fromJson(promptJson)) + .toList(), + ); + } +} + +class Prompt { + final String name; + final List messages; + + Prompt({ + required this.name, + required this.messages, + }); + + factory Prompt.fromJson(Map json) { + final name = json['name']; + if (name is! String) { + throw const FormatException('Prompt name must be a non-null String'); + } + + final messagesJson = json['messages']; + if (messagesJson is! List) { + return Prompt(name: name, messages: []); + } + + return Prompt( + name: name, + messages: messagesJson + .whereType>() + .map((messageJson) => AIMessage.fromJson(messageJson)) + .toList(), + ); + } + + List buildPrompt(String inputText, {String? task}) { + final messages = []; + for (final message in this.messages) { + if (message.role == 'system') { + messages.add(AIMessage.ofSystem(message.content)); + } else if (message.role == 'user') { + final userContent = _replacePlaceholders(message.content, inputText, task); + messages.add(AIMessage.ofUser(userContent)); + } + } + return messages; + } + + 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}}')) { + 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 new file mode 100644 index 000000000..7bf1762a9 --- /dev/null +++ b/scribe/lib/scribe/ai/domain/service/prompt_service.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; + +class PromptService { + static final PromptService _instance = PromptService._internal(); + + factory PromptService() => _instance; + + PromptService._internal(); + + PromptData? _promptData; + + Future loadPrompts() async { + if (_promptData != null) { + return _promptData!; + } + + 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/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart b/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart index 496642539..c09917358 100644 --- a/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart +++ b/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart @@ -16,7 +16,7 @@ class GenerateAITextInteractor { String? selectedText, ) async { try { - final prompt = AIPrompts.buildPrompt(action, selectedText); + final prompt = await AIPrompts.buildPrompt(action, selectedText); final response = await _repository.generateMessage(prompt); return Right(GenerateAITextSuccess(response)); } catch (e) { diff --git a/scribe/lib/scribe/ai/presentation/model/ai_action.dart b/scribe/lib/scribe/ai/presentation/model/ai_action.dart index 943bf26f8..60083d582 100644 --- a/scribe/lib/scribe/ai/presentation/model/ai_action.dart +++ b/scribe/lib/scribe/ai/presentation/model/ai_action.dart @@ -25,4 +25,6 @@ class CustomPromptAction extends AIAction { String getLabel(ScribeLocalizations localizations) { return localizations.customPromptAction; } + + static const String promptId = 'custom-prompt-mail'; } diff --git a/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart b/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart index fe67a4331..6dc972aef 100644 --- a/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart +++ b/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart @@ -65,6 +65,23 @@ enum AIScribeMenuAction { } } + String get promptId { + return switch (this) { + AIScribeMenuAction.correctGrammar => 'correct-grammar', + AIScribeMenuAction.improveMakeShorter => 'make-shorter', + AIScribeMenuAction.improveExpandContext => 'expand-context', + AIScribeMenuAction.improveEmojify => 'emojify', + AIScribeMenuAction.improveTransformToBullets => 'transform-to-bullets', + AIScribeMenuAction.changeToneProfessional => 'change-tone-professional', + AIScribeMenuAction.changeToneCasual => 'change-tone-casual', + AIScribeMenuAction.changeTonePolite => 'change-tone-polite', + AIScribeMenuAction.translateFrench => 'translate-french', + AIScribeMenuAction.translateEnglish => 'translate-english', + AIScribeMenuAction.translateRussian => 'translate-russian', + AIScribeMenuAction.translateVietnamese => 'translate-vietnamese', + }; + } + String getFullLabel(ScribeLocalizations localizations) { final categoryLabel = category.getLabel(localizations); if (category.hasSubmenu) { diff --git a/scribe/test/assets/test_prompts.json b/scribe/test/assets/test_prompts.json new file mode 100644 index 000000000..1c2b5e4f3 --- /dev/null +++ b/scribe/test/assets/test_prompts.json @@ -0,0 +1,30 @@ +{ + "prompts": [ + { + "name": "test-prompt", + "messages": [ + { + "role": "system", + "content": "You are a test assistant." + }, + { + "role": "user", + "content": "INSTRUCTION:\nTest instruction.\n\nTEXT:\n{{input}}" + } + ] + }, + { + "name": "test-prompt-with-task", + "messages": [ + { + "role": "system", + "content": "You are a test assistant with task." + }, + { + "role": "user", + "content": "INSTRUCTION:\n{{task}}\n\nTEXT:\n{{input}}" + } + ] + } + ] +} \ No newline at end of file diff --git a/scribe/test/scribe/ai/domain/model/prompt_data_test.dart b/scribe/test/scribe/ai/domain/model/prompt_data_test.dart new file mode 100644 index 000000000..836d0c460 --- /dev/null +++ b/scribe/test/scribe/ai/domain/model/prompt_data_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; + +void main() { + group('PromptData', () { + test('fromJson should parse prompts correctly', () { + // Arrange + final jsonData = { + "prompts": [ + { + "name": "test-prompt-1", + "messages": [ + { + "role": "system", + "content": "System message 1" + }, + { + "role": "user", + "content": "User message 1" + } + ] + }, + { + "name": "test-prompt-2", + "messages": [ + { + "role": "system", + "content": "System message 2" + }, + { + "role": "user", + "content": "User message 2" + } + ] + } + ] + }; + + // Act + final promptData = PromptData.fromJson(jsonData); + + // Assert + expect(promptData.prompts.length, 2); + expect(promptData.prompts.first.name, 'test-prompt-1'); + expect(promptData.prompts.last.name, 'test-prompt-2'); + expect(promptData.prompts.first.messages.length, 2); + expect(promptData.prompts.last.messages.length, 2); + }); + + test('fromJson should handle empty prompts list', () { + // Arrange + final jsonData = { + "prompts": [] + }; + + // Act + final promptData = PromptData.fromJson(jsonData); + + // Assert + expect(promptData.prompts.length, 0); + }); + }); + + group('Prompt', () { + test('fromJson should parse prompt correctly', () { + // Arrange + final jsonData = { + "name": "test-prompt", + "messages": [ + { + "role": "system", + "content": "System message" + }, + { + "role": "user", + "content": "User message with {{input}} placeholder" + } + ] + }; + + // Act + final prompt = Prompt.fromJson(jsonData); + + // Assert + expect(prompt.name, 'test-prompt'); + expect(prompt.messages.length, 2); + expect(prompt.messages.first.role, 'system'); + expect(prompt.messages.first.content, 'System message'); + expect(prompt.messages.last.role, 'user'); + expect(prompt.messages.last.content, 'User message with {{input}} placeholder'); + }); + + test('buildPrompt should replace input placeholder correctly', () { + // Arrange + final messages = [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'User message with {{input}} placeholder') + ]; + final prompt = Prompt(name: 'test-prompt', messages: messages); + + // Act + final result = prompt.buildPrompt('test input value'); + + // Assert + expect(result.length, 2); + expect(result.first.role, 'system'); + expect(result.first.content, 'System message'); + expect(result.last.role, 'user'); + expect(result.last.content, 'User message with test input value placeholder'); + }); + + test('buildPrompt should replace task placeholder when provided', () { + // Arrange + final messages = [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'Task: {{task}}, Input: {{input}}') + ]; + final prompt = Prompt(name: 'test-prompt', messages: messages); + + // Act + final result = prompt.buildPrompt('test input value', task: 'test task value'); + + // Assert + expect(result.length, 2); + expect(result.first.role, 'system'); + expect(result.first.content, 'System message'); + expect(result.last.role, 'user'); + expect(result.last.content, 'Task: test task value, Input: test input value'); + }); + + test('buildPrompt should not replace task placeholder when not provided', () { + // Arrange + final messages = [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'Task: {{task}}, Input: {{input}}') + ]; + final prompt = Prompt(name: 'test-prompt', messages: messages); + + // Act + final result = prompt.buildPrompt('test input value'); + + // Assert + expect(result.length, 2); + expect(result.first.role, 'system'); + expect(result.first.content, 'System message'); + expect(result.last.role, 'user'); + expect(result.last.content, 'Task: {{task}}, Input: test input value'); + }); + + test('buildPrompt should handle messages without placeholders', () { + // Arrange + final messages = [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'User message without placeholders') + ]; + final prompt = Prompt(name: 'test-prompt', messages: messages); + + // Act + final result = prompt.buildPrompt('test input', task: 'test task'); + + // Assert + expect(result.length, 2); + expect(result.last.content, 'User message without placeholders'); + }); + }); +} \ No newline at end of file diff --git a/scribe/test/scribe/ai/domain/service/prompt_service_test.dart b/scribe/test/scribe/ai/domain/service/prompt_service_test.dart new file mode 100644 index 000000000..c40b13d63 --- /dev/null +++ b/scribe/test/scribe/ai/domain/service/prompt_service_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:scribe/scribe/ai/domain/service/prompt_service.dart'; +import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('PromptService', () { + test('PromptService should be singleton', () { + // Act + final service1 = PromptService(); + final service2 = PromptService(); + + // Assert + expect(service1, same(service2)); + }); + + test('buildPromptByName should build prompt with input text', () async { + // Arrange + final service = PromptService(); + + // Act - this will use the real prompts.json file + final messages = await service.buildPromptByName('change-tone-casual', 'Hello, how are you?'); + + // Assert + expect(messages.length, 2); + expect(messages.first.role, 'system'); + expect(messages.last.role, 'user'); + expect(messages.last.content, contains('Hello, how are you?')); + }); + + test('buildPromptByName should build prompt with input text and task', () async { + // Arrange + final service = PromptService(); + + // Act - this will use the real prompts.json file + final messages = await service.buildPromptByName('custom-prompt-mail', 'Hello, how are you?', task: 'Make it more casual'); + + // Assert + expect(messages.length, 2); + expect(messages.first.role, 'system'); + expect(messages.last.role, 'user'); + expect(messages.last.content, contains('Hello, how are you?')); + expect(messages.last.content, contains('Make it more casual')); + }); + }); + + group('PromptService getPromptByName', () { + test('getPromptByName should return correct prompt from data', () async { + // Arrange + final promptData = PromptData( + prompts: [ + Prompt( + name: 'test-prompt', + messages: [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'User message with {{input}}') + ] + ) + ] + ); + + // Act + final prompt = promptData.prompts.firstWhere( + (prompt) => prompt.name == 'test-prompt', + orElse: () => throw Exception('Prompt not found: test-prompt'), + ); + + // Assert + expect(prompt.name, 'test-prompt'); + expect(prompt.messages.length, 2); + }); + + test('getPromptByName should throw exception for non-existent prompt', () async { + // Arrange + final promptData = PromptData( + prompts: [ + Prompt( + name: 'test-prompt', + messages: [ + const AIMessage(role: 'system', content: 'System message'), + const AIMessage(role: 'user', content: 'User message') + ] + ) + ] + ); + + // Act & Assert + expect( + () => promptData.prompts.firstWhere( + (prompt) => prompt.name == 'non-existent-prompt', + orElse: () => throw Exception('Prompt not found: non-existent-prompt'), + ), + throwsException, + ); + }); + }); +} \ No newline at end of file