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.
This commit is contained in:
Théo Poizat
2026-01-29 16:27:37 +01:00
committed by Dat H. Pham
parent 11fd808080
commit 04696eecd7
9 changed files with 449 additions and 75 deletions
@@ -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<AIMessage> buildPrompt(AIAction action, String? text) {
final prompt = switch (action) {
static Future<List<AIMessage>> 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<List<AIMessage>> 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<List<AIMessage>> buildCustomPrompt(String customPrompt, String? text) async {
return await _promptService.buildPromptByName(CustomPromptAction.promptId, text ?? '', task: customPrompt);
}
}
@@ -0,0 +1,77 @@
import 'package:scribe/scribe/ai/data/model/ai_message.dart';
class PromptData {
final List<Prompt> prompts;
PromptData({
required this.prompts,
});
factory PromptData.fromJson(Map<String, dynamic> json) {
final promptsJson = json['prompts'];
if (promptsJson is! List<dynamic>) {
return PromptData(prompts: []);
}
return PromptData(
prompts: promptsJson
.whereType<Map<String, dynamic>>()
.map((promptJson) => Prompt.fromJson(promptJson))
.toList(),
);
}
}
class Prompt {
final String name;
final List<AIMessage> messages;
Prompt({
required this.name,
required this.messages,
});
factory Prompt.fromJson(Map<String, dynamic> 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<dynamic>) {
return Prompt(name: name, messages: []);
}
return Prompt(
name: name,
messages: messagesJson
.whereType<Map<String, dynamic>>()
.map((messageJson) => AIMessage.fromJson(messageJson))
.toList(),
);
}
List<AIMessage> buildPrompt(String inputText, {String? task}) {
final messages = <AIMessage>[];
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;
}
}
@@ -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<PromptData> loadPrompts() async {
if (_promptData != null) {
return _promptData!;
}
try {
final jsonString = await rootBundle.loadString('packages/scribe/assets/prompts.json');
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
_promptData = PromptData.fromJson(jsonData);
return _promptData!;
} catch (e) {
throw Exception('Failed to load prompts: $e');
}
}
Future<Prompt> getPromptByName(String name) async {
final promptData = await loadPrompts();
return promptData.prompts.firstWhere(
(prompt) => prompt.name == name,
orElse: () => throw Exception('Prompt not found: $name'),
);
}
Future<List<AIMessage>> buildPromptByName(String name, String inputText, {String? task}) async {
final prompt = await getPromptByName(name);
return prompt.buildPrompt(inputText, task: task);
}
}
@@ -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) {
@@ -25,4 +25,6 @@ class CustomPromptAction extends AIAction {
String getLabel(ScribeLocalizations localizations) {
return localizations.customPromptAction;
}
static const String promptId = 'custom-prompt-mail';
}
@@ -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) {
+30
View File
@@ -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}}"
}
]
}
]
}
@@ -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');
});
});
}
@@ -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,
);
});
});
}