diff --git a/assets/skills/search-recipe/SKILL.md b/assets/skills/search-recipe/SKILL.md index 0c0fd64..2b3db61 100644 --- a/assets/skills/search-recipe/SKILL.md +++ b/assets/skills/search-recipe/SKILL.md @@ -14,20 +14,17 @@ NEVER generate a recipe from your own knowledge without searching first. - query: a concise search term matching the user's request. String. - limit: number of results, default 5. Integer. -After receiving search results, base your recipe on the Cookidoo results. -Pick the best matching recipe and adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level). +After receiving search results, pick the best matching recipe and call `get_recipe_detail` to get the full ingredients and steps: -If Cookidoo credentials are configured, call `get_recipe_detail` on the most relevant result to get the full ingredients and steps: +- recipe_id: the Cookidoo recipe ID of the best match. String. -- recipe_id: the Cookidoo recipe ID from search results (e.g. "r145192"). String. - -When you have the full recipe detail, use it as the base for your answer. Adapt the format, language, and portions but keep the ingredients and steps faithful to the original. +When you have the full recipe detail, present it as-is. Do NOT adapt, rewrite, or modify the recipe. ## Guidelines - ALWAYS search before answering a recipe request. No exceptions. -- Base your recipe on the search results. Do not invent recipes. +- ALWAYS call `get_recipe_detail` after searching to get the full recipe. +- Present the recipe exactly as returned. Do NOT modify ingredients, quantities, steps, times, or temperatures. - Do NOT mention Cookidoo to the user unless they explicitly ask about it. -- Adapt the recipe to the user's language, unit system, and preferences. -- If multiple results are relevant, combine the best elements. +- If `get_recipe_detail` returns an error, present the recipe overview from the search results as-is. - If search returns no results, and only then, generate a recipe from your own knowledge. diff --git a/lib/features/chat/domain/chat_model_preference.dart b/lib/features/chat/domain/chat_model_preference.dart index 6451b75..915edfd 100644 --- a/lib/features/chat/domain/chat_model_preference.dart +++ b/lib/features/chat/domain/chat_model_preference.dart @@ -17,6 +17,14 @@ enum ChatModelPreference { modelType: ModelType.gemmaIt, fileType: ModelFileType.task, ), + superGemma4E4BAbliterated( + label: 'SuperGemma4-E4B-abliterated', + fileName: 'supergemma4-e4b-abliterated.litertlm', + url: + 'https://huggingface.co/typomonster/supergemma4-e4b-abliterated-litert-lm/resolve/main/supergemma4-e4b-abliterated.litertlm', + modelType: ModelType.gemmaIt, + fileType: ModelFileType.task, + ), ; const ChatModelPreference({ diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index 2e7dffc..6e5909c 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:cookmate/l10n/app_localizations.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; @@ -41,6 +42,34 @@ class ConversationPage extends ConsumerStatefulWidget { ConsumerState createState() => _ConversationPageState(); } +/// Parse a raw `<|tool_call>call:name{key:<|"|>value<|"|>}` +/// token into a tool name and args map. Returns `null` if not a match. +({String name, Map args})? _parseRawToolCall(String text) { + final raw = text.trim(); + final re = RegExp( + r'^<\|tool_call\>call:(\w+)\{(.+?)\}$', + ); + final match = re.firstMatch(raw); + if (match == null) return null; + + final name = match.group(1)!; + final paramsRaw = match.group(2)!; + final args = {}; + + // Parse key:<|"|>value<|"|> pairs, attempting numeric conversion. + final paramRe = RegExp(r'(\w+):<\|"\|>(.+?)<\|"\|>'); + for (final pm in paramRe.allMatches(paramsRaw)) { + final value = pm.group(2)!; + final asNum = num.tryParse(value); + args[pm.group(1)!] = asNum ?? value; + } + + if (kDebugMode) { + debugPrint('>>> _parseRawToolCall: name="$name" args=$args'); + } + return (name: name, args: args); +} + class _ConversationPageState extends ConsumerState { final InMemoryChatController _chatController = InMemoryChatController(); final StreamStateStore _streamStates = StreamStateStore(); @@ -588,28 +617,91 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { + if (kDebugMode) { + debugPrint('>>> Stream: FunctionCallResponse name="${response.name}" ' + 'args=${response.args}'); + } if (mounted) { final toolReg = ref.read(toolRegistryProvider); final toolResult = await toolReg.handle(response, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; + if (kDebugMode) { + debugPrint('>>> Stream: sending toolResponse for ' + '"${toolResult.name}" to chat'); + } await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); + if (kDebugMode) { + debugPrint('>>> Stream: toolResponse sent successfully'); + } + } else if (kDebugMode) { + debugPrint('>>> Stream: tool returned null or chat changed ' + '(toolResult=${toolResult != null}, sameChat=${_chat == chat})'); } } } else if (response is ParallelFunctionCallResponse) { + if (kDebugMode) { + debugPrint('>>> Stream: ParallelFunctionCallResponse with ' + '${response.calls.length} calls'); + } if (!mounted) continue; final toolReg = ref.read(toolRegistryProvider); for (final call in response.calls) { + if (kDebugMode) { + debugPrint('>>> Stream: parallel call name="${call.name}" ' + 'args=${call.args}'); + } final toolResult = await toolReg.handle(call, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; + if (kDebugMode) { + debugPrint('>>> Stream: sending parallel toolResponse for ' + '"${toolResult.name}"'); + } await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); + if (kDebugMode) { + debugPrint('>>> Stream: parallel toolResponse sent'); + } + } else if (kDebugMode) { + debugPrint('>>> Stream: parallel tool returned null or ' + 'chat changed'); + } + } + } else if (kDebugMode) { + debugPrint('>>> Stream: unknown response type: ' + '${response.runtimeType}'); + } + } + + // Detect raw tool call tokens that the model emits as text + // (happens without thinking mode). + if (!_hadToolCall && mounted && _chat == chat) { + final parsed = _parseRawToolCall(buffer.toString()); + if (parsed != null && mounted) { + if (kDebugMode) { + debugPrint('>>> Stream: detected raw tool call in text buffer'); + } + buffer.clear(); + final toolReg = ref.read(toolRegistryProvider); + final fakeResponse = FunctionCallResponse( + name: parsed.name, + args: parsed.args, + ); + final toolResult = await toolReg.handle(fakeResponse, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + if (kDebugMode) { + debugPrint('>>> Stream: raw tool call handled'); } } } @@ -617,13 +709,25 @@ class _ConversationPageState extends ConsumerState { // After stream ends, if a tool was called, re-generate so the LLM // produces its final answer using the tool results as context. - if (_hadToolCall && mounted && _chat == chat) { - debugPrint('>>> Re-generating after tool call...'); + // Loop up to maxRounds to support chained tool calls (e.g. + // search_recipes → get_recipe_detail → final text). + const maxToolRounds = 10; + for (var round = 0; + _hadToolCall && mounted && _chat == chat && round < maxToolRounds; + round++) { + if (kDebugMode) { + debugPrint('>>> Re-generating after tool call (round ${round + 1})...'); + } int tokenCount = 0; + _hadToolCall = false; // reset for this round + await for (final response in chat.generateChatResponseAsync()) { if (!mounted) break; - debugPrint('>>> Re-gen response: ${response.runtimeType}'); - if (response is TextResponse) { + + if (response is ThinkingResponse) { + // Thinking tokens during re-gen — skip silently. + continue; + } else if (response is TextResponse) { tokenCount++; buffer.write(response.token); final elapsed = DateTime.now().difference(lastUpdate); @@ -634,10 +738,81 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { - debugPrint('>>> Re-gen: LLM called another tool: ${response.name}'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: tool call ' + '"${response.name}" args=${response.args}'); + } + if (mounted) { + final toolReg = ref.read(toolRegistryProvider); + final toolResult = await toolReg.handle(response, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: sending ' + 'toolResponse for "${toolResult.name}"'); + } + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: toolResponse sent'); + } + } + } + } else if (response is ParallelFunctionCallResponse) { + if (!mounted) continue; + final toolReg = ref.read(toolRegistryProvider); + for (final call in response.calls) { + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: parallel tool call ' + '"${call.name}" args=${call.args}'); + } + final toolResult = await toolReg.handle(call, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + } + } } } - debugPrint('>>> Re-gen done: $tokenCount tokens'); + + // Detect raw tool call tokens in text buffer (no-thinking mode). + if (!_hadToolCall && mounted && _chat == chat) { + final parsed = _parseRawToolCall(buffer.toString()); + if (parsed != null && mounted) { + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: ' + 'detected raw tool call in text buffer'); + } + buffer.clear(); + final toolReg = ref.read(toolRegistryProvider); + final fakeResponse = FunctionCallResponse( + name: parsed.name, + args: parsed.args, + ); + final toolResult = await toolReg.handle(fakeResponse, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: ' + 'raw tool call handled'); + } + } + } + } + + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, ' + 'hadToolCall=$_hadToolCall'); + } } } catch (e, stack) { debugPrint('Stream error: $e\n$stack'); diff --git a/lib/features/cookidoo/data/cookidoo_client.dart b/lib/features/cookidoo/data/cookidoo_client.dart index e71f62e..2873e77 100644 --- a/lib/features/cookidoo/data/cookidoo_client.dart +++ b/lib/features/cookidoo/data/cookidoo_client.dart @@ -134,13 +134,29 @@ class CookidooClient { '?query=${Uri.encodeComponent(query)}&context=recipes&limit=$limit', ); + if (kDebugMode) { + debugPrint('>>> CookidooClient.searchRecipes: GET $url'); + } + final http.Response response; try { response = await _http.get(url, headers: {'Accept': 'application/json'}); } on Exception catch (e) { + if (kDebugMode) { + debugPrint('>>> CookidooClient.searchRecipes: request exception — $e'); + } throw CookidooNetworkException('Search request failed: $e'); } + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.searchRecipes: ${response.statusCode} ' + '(${response.body.length} bytes)'); + debugPrint( + '>>> CookidooClient.searchRecipes body: ' + '${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}'); + } + if (response.statusCode != 200) { throw CookidooNetworkException( 'Search failed (${response.statusCode})', @@ -149,6 +165,11 @@ class CookidooClient { final json = jsonDecode(response.body) as Map; final data = json['data'] as List? ?? []; + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.searchRecipes: parsed ${data.length} items ' + 'from json keys=${json.keys.toList()}'); + } return data .map((e) => CookidooRecipeOverview.fromJson(e as Map)) @@ -167,6 +188,10 @@ class CookidooClient { '${_baseUrl(countryCode)}/recipes/recipe/$lang/$recipeId', ); + if (kDebugMode) { + debugPrint('>>> CookidooClient.getRecipeDetail: GET $url'); + } + final http.Response response; try { response = await _http.get(url, headers: { @@ -174,9 +199,22 @@ class CookidooClient { 'Authorization': 'Bearer ${_token!.accessToken}', }); } on Exception catch (e) { + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.getRecipeDetail: request exception — $e'); + } throw CookidooNetworkException('Recipe detail request failed: $e'); } + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.getRecipeDetail: ${response.statusCode} ' + '(${response.body.length} bytes)'); + debugPrint( + '>>> CookidooClient.getRecipeDetail body: ' + '${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}'); + } + if (response.statusCode == 404) { throw CookidooNotFoundException(recipeId); } diff --git a/lib/features/cookidoo/data/cookidoo_repository_impl.dart b/lib/features/cookidoo/data/cookidoo_repository_impl.dart index 6acbbb7..291abc5 100644 --- a/lib/features/cookidoo/data/cookidoo_repository_impl.dart +++ b/lib/features/cookidoo/data/cookidoo_repository_impl.dart @@ -14,9 +14,7 @@ class CookidooRepositoryImpl implements CookidooRepository { final CookidooClient client; final String locale; - final CookidooCredentials? Function() credentialsReader; - - CookidooCredentials? get credentials => credentialsReader(); + final Future Function() credentialsReader; String get _lang => locale; String get _countryCode => CookidooClient.countryCodeFromLocale(locale); @@ -36,7 +34,7 @@ class CookidooRepositoryImpl implements CookidooRepository { @override Future getRecipeDetail(String recipeId) async { - final creds = credentials; + final creds = await credentialsReader(); if (creds == null || creds.isEmpty) { throw const CookidooAuthException( 'Cookidoo credentials not configured', @@ -52,7 +50,7 @@ class CookidooRepositoryImpl implements CookidooRepository { @override Future isAuthenticated() async { - final creds = credentials; + final creds = await credentialsReader(); if (creds == null || creds.isEmpty) return false; try { await client.login(creds, countryCode: _countryCode); diff --git a/lib/features/cookidoo/providers.dart b/lib/features/cookidoo/providers.dart index 99cb45b..0c9b86c 100644 --- a/lib/features/cookidoo/providers.dart +++ b/lib/features/cookidoo/providers.dart @@ -61,9 +61,12 @@ final cookidooRepositoryProvider = Provider((ref) { return CookidooRepositoryImpl( client: client, locale: lang, - // Read credentials lazily to avoid rebuilding the repository (and the - // entire tool registry chain) every time credentials change. - credentialsReader: () => - ref.read(cookidooCredentialsProvider).valueOrNull, + // Read credentials lazily and asynchronously so the provider resolves + // even when accessed before the credentials Future completes. + credentialsReader: () async { + final storage = + await ref.read(cookidooCredentialsStorageProvider.future); + return storage.read(); + }, ); }); diff --git a/lib/features/recipe/domain/system_prompt_builder.dart b/lib/features/recipe/domain/system_prompt_builder.dart index e27888c..7940dad 100644 --- a/lib/features/recipe/domain/system_prompt_builder.dart +++ b/lib/features/recipe/domain/system_prompt_builder.dart @@ -5,12 +5,12 @@ String buildSystemPrompt({ required String language, String skillInstructions = '', }) { - final restrictions = config.dietaryRestrictions.isEmpty - ? 'aucune' - : config.dietaryRestrictions; - + // TODO: re-enable config when the model handles it reliably. + // final restrictions = config.dietaryRestrictions.isEmpty + // ? 'aucune' + // : config.dietaryRestrictions; + // Config: ${config.tmVersion.name.toUpperCase()}, $language, ${config.unitSystem.name}, ${config.portions} servings, level ${config.level.name}, restrictions: $restrictions. return ''' CookMate: Thermomix recipe assistant. Answer any food or recipe related request (text, audio or image). -Config: ${config.tmVersion.name.toUpperCase()}, $language, ${config.unitSystem.name}, ${config.portions} servings, level ${config.level.name}, restrictions: $restrictions. $skillInstructions'''; } diff --git a/lib/features/tools/providers.dart b/lib/features/tools/providers.dart index 35826c1..a17e595 100644 --- a/lib/features/tools/providers.dart +++ b/lib/features/tools/providers.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../cookidoo/providers.dart'; @@ -26,11 +27,23 @@ final toolRegistryProvider = Provider( final enabledToolNames = enabledSkills.expand((s) => s.tools).toSet(); + if (kDebugMode) { + debugPrint('>>> ToolRegistryProvider: enabled skills=' + '${enabledSkills.map((s) => s.name).toList()}, ' + 'enabled tools=$enabledToolNames, ' + 'all handlers=${allHandlers.keys.toList()}'); + } + final activeHandlers = allHandlers.entries .where((e) => enabledToolNames.contains(e.key)) .map((e) => e.value) .toList(); + if (kDebugMode) { + debugPrint('>>> ToolRegistryProvider: active handlers=' + '${activeHandlers.map((h) => h.definition.name).toList()}'); + } + return ToolRegistry(activeHandlers); }, ); diff --git a/lib/features/tools/tool_registry.dart b/lib/features/tools/tool_registry.dart index 8fe7a6a..25c2b92 100644 --- a/lib/features/tools/tool_registry.dart +++ b/lib/features/tools/tool_registry.dart @@ -55,6 +55,17 @@ class ToolRegistry { return null; } final result = await handler.execute(response.args, context); + if (kDebugMode) { + if (result == null) { + debugPrint('>>> ToolRegistry: handler "${response.name}" returned null ' + '(fire-and-forget)'); + } else { + final preview = result.toString(); + debugPrint('>>> ToolRegistry: handler "${response.name}" result ' + '(${preview.length} chars): ' + '${preview.length > 300 ? '${preview.substring(0, 300)}…' : preview}'); + } + } if (result == null) return null; return (name: response.name, result: result); } diff --git a/test/features/cookidoo/data/cookidoo_repository_impl_test.dart b/test/features/cookidoo/data/cookidoo_repository_impl_test.dart index 180ebe3..a0fe662 100644 --- a/test/features/cookidoo/data/cookidoo_repository_impl_test.dart +++ b/test/features/cookidoo/data/cookidoo_repository_impl_test.dart @@ -40,7 +40,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'fr-FR', - credentialsReader: () => null, + credentialsReader: () async => null, ); final results = await repo.searchRecipes('pasta', limit: 3); @@ -55,7 +55,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => null, + credentialsReader: () async => null, ); expect( @@ -68,7 +68,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: ''), ); @@ -82,7 +82,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: 'secret'), ); @@ -98,7 +98,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => null, + credentialsReader: () async => null, ); expect(await repo.isAuthenticated(), isFalse); @@ -108,7 +108,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: ''), ); @@ -121,7 +121,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: 'a@b.com', password: 'pw'), ); @@ -141,7 +141,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: 'a@b.com', password: 'pw'), ); diff --git a/test/features/recipe/domain/system_prompt_builder_test.dart b/test/features/recipe/domain/system_prompt_builder_test.dart index 7c6e958..f88491b 100644 --- a/test/features/recipe/domain/system_prompt_builder_test.dart +++ b/test/features/recipe/domain/system_prompt_builder_test.dart @@ -1,41 +1,18 @@ import 'package:cookmate/features/recipe/domain/recipe_config.dart'; -import 'package:cookmate/features/recipe/domain/recipe_level.dart'; import 'package:cookmate/features/recipe/domain/system_prompt_builder.dart'; -import 'package:cookmate/features/recipe/domain/tm_version.dart'; -import 'package:cookmate/features/recipe/domain/unit_system.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('buildSystemPrompt', () { - test('contains expected config values in output', () { - const config = RecipeConfig( - tmVersion: TmVersion.tm6, - unitSystem: UnitSystem.metric, - portions: 4, - level: RecipeLevel.beginner, - dietaryRestrictions: '', - ); - + test('contains CookMate assistant preamble', () { + const config = RecipeConfig(); final prompt = buildSystemPrompt(config: config, language: 'en'); - - expect(prompt, contains('TM6')); - expect(prompt, contains('metric')); - expect(prompt, contains('4 servings')); - expect(prompt, contains('beginner')); - }); - - test('uses "aucune" when dietary restrictions are empty', () { - const config = RecipeConfig(dietaryRestrictions: ''); - final prompt = buildSystemPrompt(config: config, language: 'fr'); - expect(prompt, contains('aucune')); + expect(prompt, contains('CookMate')); + expect(prompt, contains('Thermomix recipe assistant')); }); - test('includes dietary restrictions when provided', () { - const config = RecipeConfig(dietaryRestrictions: 'gluten-free, vegan'); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('gluten-free, vegan')); - expect(prompt, isNot(contains('aucune'))); - }); + // Config line is temporarily disabled (TODO in source). + // These tests verify the prompt still works without it. test('includes skill instructions when provided', () { const config = RecipeConfig(); @@ -50,32 +27,7 @@ void main() { test('skill instructions default to empty string', () { const config = RecipeConfig(); final prompt = buildSystemPrompt(config: config, language: 'en'); - // Should not throw and should not contain extra instructions. expect(prompt, isNotEmpty); }); - - test('includes the language in output', () { - const config = RecipeConfig(); - final prompt = buildSystemPrompt(config: config, language: 'de'); - expect(prompt, contains('de')); - }); - - test('tm version name is uppercased', () { - const config = RecipeConfig(tmVersion: TmVersion.tm5); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('TM5')); - }); - - test('portions value is reflected in prompt', () { - const config = RecipeConfig(portions: 6); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('6 servings')); - }); - - test('imperial unit system appears in prompt', () { - const config = RecipeConfig(unitSystem: UnitSystem.imperial); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('imperial')); - }); }); }