From 1a10ca88e0000c3e263cbea86a65212ebf541c0c Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 19 Sep 2026 19:21:41 -0700 Subject: [PATCH] perf: improve AI prompt cache reuse --- .../answer_evaluation_capability.dart | 2 +- .../dialogue_coach_capability.dart | 2 +- .../lexicon_explanation_capability.dart | 2 +- .../review_generation_capability.dart | 2 +- kouyu_english/lib/core/ai_service.dart | 241 +++++++++++------- kouyu_english/test/ai_capabilities_test.dart | 10 + kouyu_english/test/ai_prompt_cache_test.dart | 127 +++++++++ .../test/deepseek_thinking_test.dart | 43 ++-- .../test/generated_content_test.dart | 13 +- 9 files changed, 318 insertions(+), 124 deletions(-) create mode 100644 kouyu_english/test/ai_prompt_cache_test.dart diff --git a/kouyu_english/lib/core/ai_capabilities/answer_evaluation_capability.dart b/kouyu_english/lib/core/ai_capabilities/answer_evaluation_capability.dart index 0682f95..e3b7a06 100644 --- a/kouyu_english/lib/core/ai_capabilities/answer_evaluation_capability.dart +++ b/kouyu_english/lib/core/ai_capabilities/answer_evaluation_capability.dart @@ -9,7 +9,7 @@ class AiAnswerEvaluationCapability { static const descriptor = AiCapabilityDescriptor( id: 'answer-evaluation', - promptVersion: 1, + promptVersion: 2, outputContract: 'WritingAiFeedback', ); diff --git a/kouyu_english/lib/core/ai_capabilities/dialogue_coach_capability.dart b/kouyu_english/lib/core/ai_capabilities/dialogue_coach_capability.dart index f78842d..7f66f5e 100644 --- a/kouyu_english/lib/core/ai_capabilities/dialogue_coach_capability.dart +++ b/kouyu_english/lib/core/ai_capabilities/dialogue_coach_capability.dart @@ -9,7 +9,7 @@ class AiDialogueCoachCapability { static const descriptor = AiCapabilityDescriptor( id: 'dialogue-coach', - promptVersion: 2, + promptVersion: 3, outputContract: 'DialogueAiResponse or DialogueAiIntervention', ); diff --git a/kouyu_english/lib/core/ai_capabilities/lexicon_explanation_capability.dart b/kouyu_english/lib/core/ai_capabilities/lexicon_explanation_capability.dart index f36ba19..86d045d 100644 --- a/kouyu_english/lib/core/ai_capabilities/lexicon_explanation_capability.dart +++ b/kouyu_english/lib/core/ai_capabilities/lexicon_explanation_capability.dart @@ -9,7 +9,7 @@ class AiLexiconExplanationCapability { static const descriptor = AiCapabilityDescriptor( id: 'lexicon-explanation', - promptVersion: 1, + promptVersion: 2, outputContract: 'Temporary definition or SentenceAnalysisResult', ); diff --git a/kouyu_english/lib/core/ai_capabilities/review_generation_capability.dart b/kouyu_english/lib/core/ai_capabilities/review_generation_capability.dart index c31af20..50d71ec 100644 --- a/kouyu_english/lib/core/ai_capabilities/review_generation_capability.dart +++ b/kouyu_english/lib/core/ai_capabilities/review_generation_capability.dart @@ -9,7 +9,7 @@ class AiReviewGenerationCapability { static const descriptor = AiCapabilityDescriptor( id: 'review-generation', - promptVersion: 1, + promptVersion: 2, outputContract: 'GeneratedReviewVariant or audited GeneratedLesson', ); diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index f7593c3..0c09911 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -323,18 +323,36 @@ class AiService { (usage['prompt_tokens_details'] as Map?)?['cached_tokens']; final miss = usage['prompt_cache_miss_tokens']; final prompt = usage['prompt_tokens'] ?? usage['input_tokens']; + final hitCount = hit is num ? hit.toDouble() : null; + final missCount = miss is num + ? miss.toDouble() + : prompt is num && hitCount != null + ? (prompt.toDouble() - hitCount).clamp(0, double.infinity) + : null; + final cacheTotal = (hitCount ?? 0) + (missCount ?? 0); + final cacheRate = cacheTotal > 0 + ? '${(100 * (hitCount ?? 0) / cacheTotal).toStringAsFixed(1)}%' + : 'n/a'; debugPrint( 'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss ' + 'cacheRate=$cacheRate ' 'completion=${usage['completion_tokens'] ?? usage['output_tokens']}', ); } catch (_) {} } - Future _requestPrompt({ + /// Sends a cache-friendly structured request. + /// + /// [system] contains only versioned, capability-wide instructions. All + /// request-specific values belong in [input], which is encoded as the final + /// user message. DeepSeek can then reuse the identical leading system + /// tokens across learners, lessons, and turns. + Future _requestStructured({ required AiProviderType provider, required String endpoint, required String model, - required String prompt, + required String system, + required Map input, double? temperature, required int maxTokens, Duration timeout = const Duration(seconds: 30), @@ -343,8 +361,9 @@ class AiService { provider: provider, endpoint: endpoint, model: model, + system: system, messages: [ - {'role': 'user', 'content': prompt}, + {'role': 'user', 'content': jsonEncode(input)}, ], temperature: temperature, maxTokens: maxTokens, @@ -482,13 +501,18 @@ class AiService { text.trim().isEmpty) { return null; } - const instruction = - 'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.'; - final content = await _requestPrompt( + const system = + 'You provide a display-only Chinese gloss for an English word or ' + 'phrase. Return JSON only: ' + '{"definition":"short simplified Chinese meaning"}. Do not include ' + 'markdown, examples, pronunciation, or teaching claims. The input is ' + 'a JSON object with a text field.'; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: '$instruction\nText: $text', + system: system, + input: {'text': text.trim()}, maxTokens: 200, ); if (content == null || content.length > 300) return null; @@ -521,9 +545,9 @@ class AiService { return _buildMockSentenceAnalysis(cleanText); } - const instruction = + const system = 'You are an expert oral English coach for beginner adult learners (A0-A1). ' - 'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. ' + 'Analyze the English sentence in the input JSON into clear, encouraging, beginner-friendly Chinese explanations. ' 'Focus on practical oral usage, sentence structure, and linking/pronunciation hints. ' 'Return ONLY valid JSON matching this schema, with no markdown or other text:\n' '{\n' @@ -541,11 +565,12 @@ class AiService { ' ]\n' '}'; - final content = await _requestPrompt( + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: '$instruction\n\nSentence: $cleanText', + system: system, + input: {'sentence': cleanText}, maxTokens: 800, ); if (content == null || content.isEmpty) return null; @@ -799,7 +824,8 @@ class AiService { if (provider == AiProviderType.mock) { return null; } - final system = dialogueSystemPrompt( + final system = dialogueSystemPrompt(); + final conversationConfig = dialogueConversationConfig( level: level, allowedLanguage: allowedLanguage, ); @@ -811,6 +837,7 @@ class AiService { // the model to answer in plain text (or, in JSON mode, with blanks), so // they are replayed in the JSON shape the system prompt asks for. final messages = >[ + {'role': 'user', 'content': conversationConfig}, for (final message in history) message['role'] == 'assistant' ? { @@ -839,41 +866,35 @@ class AiService { return _decodeDialogueResponse(content); } - /// The conversation-wide dialogue instructions. The long word list comes - /// last so the rules before it are shared across units as well. + /// The conversation-wide dialogue instructions. This prefix must remain + /// byte-for-byte stable; level and taught language belong in the following + /// configuration message so they do not break cross-lesson cache reuse. @visibleForTesting - static String dialogueSystemPrompt({ + static String dialogueSystemPrompt() => + 'You are Mia, a patient English conversation partner for a Chinese learner. ' + 'The first user message is a [ConversationConfig] JSON object. It is configuration, not a learner utterance, and must never be reviewed as one. ' + 'Each later user message ends with a [Turn] note saying what your next line must do and what the learner has to say after it. ' + 'Follow the configured CEFR level and taught-language policy. ' + 'Do not say the learner sentence for them, and do not ask for anything else. ' + 'Strictly do not repeat any question, greeting, or inquiry that has already been asked or answered in earlier turns. ' + 'Check the conversation history carefully: never ask for information the learner has already given, such as name, location, or feelings. ' + 'If the turn goal asks about something already provided in history, acknowledge it naturally and advance the conversation instead of re-asking. ' + 'Reply with one short sentence or question, at most 20 English words, in English only. ' + 'Do not explain grammar. ' + 'Return JSON only: {"reply":"your English line","translation":"reply 的简体中文翻译","feedback":"一句中文点评学习者上一句英文(不含配置和 [Turn] 说明),没有要说的就用 null"}. ' + 'Only reply is required.'; + + @visibleForTesting + static String dialogueConversationConfig({ required String level, required List allowedLanguage, }) { - final learner = level == 'A0' - ? 'a patient A0 English conversation partner for a Chinese beginner' - : 'a patient English conversation partner for a Chinese learner at ' - 'CEFR $level'; - final vocabularyRule = allowedLanguage.isEmpty - ? '' + final vocabularyPolicy = allowedLanguage.isEmpty + ? 'Use only simple, common $level English.' : level == 'A0' - ? 'Build your reply from your goal wording, names, numbers and this ' - 'taught language. At most one word outside it per reply, and ' - 'only if unavoidable. Taught language: ' - '${allowedLanguage.join('; ')}' - : 'Prefer your goal wording and this recently taught language; ' - 'beyond it use only simple, common $level English. ' - 'Taught language: ${allowedLanguage.join('; ')}'; - return 'You are Mia, $learner. ' - 'Each user message ends with a [Turn] note saying what your next line ' - 'must do and what the learner has to say after it. ' - 'Do not say the learner sentence for them, and do not ask for anything else. ' - 'Strictly do not repeat any question, greeting, or inquiry that has already been asked or answered in earlier turns. ' - 'Check the conversation history carefully: never ask for information the learner has already given (e.g. name, location, feelings, etc.). ' - 'If the turn goal asks about something already provided in history, acknowledge it naturally and advance the conversation forward instead of re-asking. ' - 'Reply with one short sentence or question, at most 20 English words, in English only. ' - 'Do not explain grammar. ' - 'Return JSON only: {"reply": "your English line", ' - '"translation": "reply 的简体中文翻译", ' - '"feedback": "一句中文点评学习者上一句英文(不含 [Turn] 说明),没有要说的就用 null"}. ' - 'Only reply is required. ' - '$vocabularyRule'; + ? 'Build the reply from the turn goal, names, numbers, and taughtLanguage. Use at most one other word, only if unavoidable.' + : 'Prefer the turn goal and taughtLanguage; otherwise use only simple, common $level English.'; + return '[ConversationConfig]\n${jsonEncode({'level': level, 'vocabularyPolicy': vocabularyPolicy, 'taughtLanguage': allowedLanguage})}'; } /// Generates only a bounded variant of an existing review target. A network @@ -887,25 +908,26 @@ class AiService { bool repairAttempt = false, }) async { if (provider == AiProviderType.mock) return null; - // Fixed rules first, the item-specific part last, so repeated requests - // share a cacheable prefix. - final instruction = + const system = 'Generate one A0 English review variant of an existing review item. ' 'Return JSON only with exactly these five fields and nothing else: ' 'schemaVersion (must be "review-variant-1"), ' 'variantId (short id such as "ai--1", maximum 80 characters), ' - 'targetItemId (must be the target item id below), ' + 'targetItemId (must match input.targetItemId), ' 'prompt (a new short Chinese situation asking the learner to say the same target expression, maximum 120 Chinese characters), ' 'expectedAnswer (the English reference answer, maximum 12 words; use [place], [name] or [number] for learner-specific details). ' - 'Stay strictly within A0. Do not introduce new vocabulary or change the target expression.\n' - 'Target item id: $targetItemId\n' - 'Base prompt: $basePrompt' - '${repairAttempt ? '\nPrevious response failed schema or constraint validation: repair all errors.' : ''}'; - final content = await _requestPrompt( + 'Stay strictly within A0. Do not introduce new vocabulary or change the target expression. ' + 'The user message is a JSON object. If repairAttempt is true, repair all schema and constraint errors.'; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: { + 'targetItemId': targetItemId, + 'basePrompt': basePrompt, + 'repairAttempt': repairAttempt, + }, temperature: 0.2, maxTokens: 300, ); @@ -938,19 +960,23 @@ class AiService { String level = 'A0', }) async { if (provider == AiProviderType.mock) return null; - final instruction = - '''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId. -schemaVersion must be "writing-feedback-1" and lessonId must be the lesson id given below. -verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple $level English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions. -Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond $level, and do not invent facts the learner did not write. -Lesson id: $lessonId -Task: $taskPrompt -Learner wrote: $answer'''; - final content = await _requestPrompt( + const system = + '''Evaluate a beginner's open-ended English writing response using the input JSON. +Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId. +schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.lessonId. +verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions. +Assess only whether the learner expressed input.taskPrompt. Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.'''; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: { + 'level': level, + 'lessonId': lessonId, + 'taskPrompt': taskPrompt, + 'answer': answer, + }, temperature: 0, maxTokens: 300, ); @@ -972,24 +998,27 @@ Learner wrote: $answer'''; String level = 'A0', }) async { if (provider == AiProviderType.mock) return null; - final instruction = - '''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId. -schemaVersion must be "writing-feedback-1" and lessonId must be the answer id given below. -Check the learner's English for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression. + const system = + '''Check the learner's English in the input JSON for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression. +Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId. +schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.answerId. verdict must be accepted (no spelling or grammar mistakes and the task is answered), rewrite (at least one mistake), or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters) summarising the result. -suggestion is null when verdict is accepted, otherwise the learner's own sentence minimally corrected at $level level (max 18 words); keep their names, places and meaning. +suggestion is null when verdict is accepted, otherwise the learner's own sentence minimally corrected at input.level (max 18 words); keep their names, places and meaning. missing is an array of at most 3 short Chinese notes, one per mistake, each naming the wrong word and its correction, e.g. "Chna 拼写应为 China". -Do not claim pronunciation, do not introduce grammar beyond $level, do not flag capitalisation or final punctuation alone, and do not invent facts the learner did not write. -Answer id: $answerId -Target expression: $target -Task: $taskPrompt -Learner wrote: $answer'''; - final content = await _requestPrompt( +Do not claim pronunciation, do not introduce grammar beyond input.level, do not flag capitalisation or final punctuation alone, and do not invent facts the learner did not write.'''; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: { + 'level': level, + 'answerId': answerId, + 'target': target, + 'taskPrompt': taskPrompt, + 'answer': answer, + }, temperature: 0, maxTokens: 300, ); @@ -1013,20 +1042,15 @@ Learner wrote: $answer'''; String level = 'A0', }) async { if (provider == AiProviderType.mock) return null; - final instruction = - 'You are a supportive oral English coach evaluating an ESL beginner ($level) spoken line in a dialogue.\n' - 'Context:\n' - '- Dialogue partner said: "$partnerLine"\n' - '- Current turn task/goal: "$taskLabel"\n' - '${hint != null && hint.isNotEmpty ? '- Example response: "$hint"\n' : ''}' - '- Learner spoke/wrote: "$learnerText"\n\n' - 'Evaluation Instructions:\n' + const system = + 'You are a supportive oral English coach evaluating a beginner spoken line using the input JSON.\n' + 'Evaluation instructions:\n' '1. Semantic & Communicative Check: Does the learner\'s response make sense and fulfill the conversational goal, even if phrased differently from the example (e.g. "I feel great", "Pretty good", "Not bad at all", "I like tea")?\n' '2. Speech-to-Text (ASR) & Typo Slip Detection: Detect common speech recognition confusions or acoustic slips (e.g. /taɪəd/ transcribed as "third", "tierd", "thx"). If the learner clearly attempted the task with a phonetic or spelling slip, identify their intended English sentence.\n' '3. Return ONLY valid JSON with no markdown:\n' '{\n' ' "schemaVersion": "dialogue-intervention-2",\n' - ' "turnId": "${turnId ?? ''}",\n' + ' "turnId": "exactly copy input.turnId",\n' ' "accepted": true or false,\n' ' "goalSatisfied": true or false,\n' ' "verdict": "accepted, correctable, off_topic, or uncertain",\n' @@ -1037,13 +1061,22 @@ Learner wrote: $answer'''; 'Rules:\n' '- If it is a valid, natural reply (or minor casing/punctuation): set "accepted": true, "suggestion": null, "explanation": "表达自然得体,符合本轮交流目标。".\n' '- If there is an ASR slip, typo, or word error (e.g. "I\'m third today" intended for "I\'m tired today"): set "accepted": false, "suggestion": "I\'m tired today.", "explanation": "识别为 third,你可能是想表达 tired(今天很累)吗?".\n' - '- If off-topic or empty: set "accepted": false, "suggestion": null, "explanation": "简要说明本轮对方在问什么,建议如何回答".'; + '- If off-topic or empty: set "accepted": false, "suggestion": null, "explanation": "简要说明本轮对方在问什么,建议如何回答".\n' + '- Apply the CEFR level in input.level. input.hint may be null.'; - final content = await _requestPrompt( + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: { + 'level': level, + 'partnerLine': partnerLine, + 'taskLabel': taskLabel, + 'hint': hint, + 'learnerText': learnerText, + 'turnId': turnId ?? '', + }, temperature: 0, maxTokens: 250, ); @@ -1130,17 +1163,28 @@ Learner wrote: $answer'''; final lessonId = 'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1'; final stageVersion = '$level-1.0'; - final instruction = - 'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion $stageVersion, source aiGenerated, status validated, targetItemIds [target item id], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [target item id]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple $level English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n' - 'lessonId: $lessonId\n' - 'Target item id: $targetItemId\n' - 'Target expression: $targetLabel' - '${repairAttempt ? '\nPrevious response was invalid: repair all constraints.' : ''}'; - final content = await _requestPrompt( + const system = + 'Create a bounded adaptive English mini-lesson from the input JSON. ' + 'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. ' + 'Use schemaVersion lesson-2, copy lessonId and stageVersion from input, revision 1, source aiGenerated, status validated, targetItemIds [input.targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. ' + 'Create exactly four tasks: one listening listenChoice, speaking repeat, reading readAnswer, and writing writeAnswer. ' + 'Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [input.targetItemId]. ' + 'answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. ' + 'Lesson duration is 8 to 15 minutes. Use only very simple input.level English for input.targetLabel. No new vocabulary, markdown, real phone numbers, or personal data. ' + 'If input.repairAttempt is true, repair every schema and constraint error.'; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: { + 'level': level, + 'lessonId': lessonId, + 'stageVersion': stageVersion, + 'targetItemId': targetItemId, + 'targetLabel': targetLabel, + 'repairAttempt': repairAttempt, + }, temperature: 0.1, maxTokens: 850, timeout: const Duration(seconds: 45), @@ -1193,13 +1237,14 @@ Learner wrote: $answer'''; ) .toList(), }); - final instruction = - 'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson'; - final content = await _requestPrompt( + const system = + 'Audit the A0 English lesson in the input JSON independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string.'; + final content = await _requestStructured( provider: provider, endpoint: endpoint, model: model, - prompt: instruction, + system: system, + input: {'lesson': jsonDecode(lessonJson)}, temperature: 0, maxTokens: 200, ); diff --git a/kouyu_english/test/ai_capabilities_test.dart b/kouyu_english/test/ai_capabilities_test.dart index a897656..5c241a5 100644 --- a/kouyu_english/test/ai_capabilities_test.dart +++ b/kouyu_english/test/ai_capabilities_test.dart @@ -26,6 +26,16 @@ void main() { ), ), ); + expect( + {for (final item in descriptors) item.id: item.promptVersion}, + { + 'speech-transcription': 1, + 'lexicon-explanation': 2, + 'dialogue-coach': 3, + 'answer-evaluation': 2, + 'review-generation': 2, + }, + ); }); test( diff --git a/kouyu_english/test/ai_prompt_cache_test.dart b/kouyu_english/test/ai_prompt_cache_test.dart new file mode 100644 index 0000000..c048537 --- /dev/null +++ b/kouyu_english/test/ai_prompt_cache_test.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:kouyu_english/core/ai_service.dart'; +import 'package:kouyu_english/core/models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'lexicon requests keep a stable system prefix and dynamic JSON last', + () async { + AiService.instance.setFallbackApiKey('test-key'); + final captured = >[]; + + Future request(String text) async { + await http.runWithClient( + () => AiService.instance.temporaryDefinition( + provider: AiProviderType.compatible, + endpoint: 'https://api.deepseek.com', + model: 'deepseek-flash', + text: text, + ), + () => MockClient((request) async { + final body = jsonDecode(request.body) as Map; + captured.add(body['messages'] as List); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': '{"definition":"测试"}'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + } + + await request('apple'); + await request('airport'); + + expect(captured, hasLength(2)); + expect(captured[0], hasLength(2)); + expect(captured[0][0]['role'], 'system'); + expect(captured[0][0]['content'], captured[1][0]['content']); + expect(captured[0][0]['content'], isNot(contains('apple'))); + expect(jsonDecode(captured[0][1]['content'] as String), { + 'text': 'apple', + }); + expect(jsonDecode(captured[1][1]['content'] as String), { + 'text': 'airport', + }); + }, + ); + + test( + 'dialogue evaluation keeps learner data out of the cached prefix', + () async { + AiService.instance.setFallbackApiKey('test-key'); + final captured = >[]; + + Future request({ + required String learnerText, + required String turnId, + }) async { + await http.runWithClient( + () => AiService.instance.checkDialogueIntervention( + provider: AiProviderType.compatible, + endpoint: 'https://api.deepseek.com', + model: 'deepseek-flash', + partnerLine: 'How are you?', + taskLabel: 'Say how you feel.', + learnerText: learnerText, + turnId: turnId, + ), + () => MockClient((request) async { + final body = jsonDecode(request.body) as Map; + captured.add(body['messages'] as List); + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': { + 'content': jsonEncode({ + 'schemaVersion': 'dialogue-intervention-2', + 'turnId': turnId, + 'accepted': true, + 'goalSatisfied': true, + 'verdict': 'accepted', + 'reasonCode': 'goal-met', + 'suggestion': null, + 'explanation': '表达自然。', + }), + }, + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + } + + await request(learnerText: 'I am fine.', turnId: 'turn-1'); + await request(learnerText: 'I feel tired.', turnId: 'turn-2'); + + final firstSystem = captured[0][0]['content'] as String; + final secondSystem = captured[1][0]['content'] as String; + expect(firstSystem, secondSystem); + expect(firstSystem, isNot(contains('I am fine.'))); + expect(firstSystem, isNot(contains('turn-1'))); + + final firstInput = jsonDecode(captured[0][1]['content'] as String); + final secondInput = jsonDecode(captured[1][1]['content'] as String); + expect(firstInput['learnerText'], 'I am fine.'); + expect(firstInput['turnId'], 'turn-1'); + expect(secondInput['learnerText'], 'I feel tired.'); + expect(secondInput['turnId'], 'turn-2'); + }, + ); +} diff --git a/kouyu_english/test/deepseek_thinking_test.dart b/kouyu_english/test/deepseek_thinking_test.dart index 90c0c6d..70a087c 100644 --- a/kouyu_english/test/deepseek_thinking_test.dart +++ b/kouyu_english/test/deepseek_thinking_test.dart @@ -100,13 +100,15 @@ void main() { ); expect(reply?.reply, 'Where are you from?'); - expect(messages, hasLength(3)); + expect(messages, hasLength(4)); expect(messages![0]['role'], 'system'); - expect(jsonDecode(messages![1]['content'] as String), { + expect(messages![1]['role'], 'user'); + expect(messages![1]['content'], startsWith('[ConversationConfig]')); + expect(jsonDecode(messages![2]['content'] as String), { 'reply': "Hi! What's your name?", }); - expect(messages![2]['role'], 'user'); - final last = messages![2]['content'] as String; + expect(messages![3]['role'], 'user'); + final last = messages![3]['content'] as String; expect(last, startsWith('My name is Alex.')); expect( last, @@ -114,28 +116,33 @@ void main() { ); expect( last, - endsWith('Do not repeat any questions or greetings already asked or answered.'), + endsWith( + 'Do not repeat any questions or greetings already asked or answered.', + ), ); expect(messages![0]['content'], isNot(contains('Ask where the learner'))); }); - test('dialogue system prompt stays identical across turns', () { + test('dialogue system prompt stays identical across levels and lessons', () { final words = ['Excuse me.', 'Can you help me?']; - final a = AiService.dialogueSystemPrompt( - level: 'A1', - allowedLanguage: words, - ); - final b = AiService.dialogueSystemPrompt( - level: 'A1', - allowedLanguage: words, - ); + final a = AiService.dialogueSystemPrompt(); + final b = AiService.dialogueSystemPrompt(); expect(a, b); - expect(a, contains('CEFR A1')); expect(a, contains('Strictly do not repeat any question')); - expect(a, endsWith('Taught language: Excuse me.; Can you help me?')); + expect(a, isNot(contains('Excuse me.'))); + + final config = AiService.dialogueConversationConfig( + level: 'A1', + allowedLanguage: words, + ); + expect(config, contains('A1')); + expect(config, contains('Excuse me.')); expect( - AiService.dialogueSystemPrompt(level: 'A0', allowedLanguage: const []), - contains('A0 English conversation partner'), + AiService.dialogueConversationConfig( + level: 'A0', + allowedLanguage: const [], + ), + contains('simple, common A0 English'), ); }); } diff --git a/kouyu_english/test/generated_content_test.dart b/kouyu_english/test/generated_content_test.dart index 5e52d25..4829ea0 100644 --- a/kouyu_english/test/generated_content_test.dart +++ b/kouyu_english/test/generated_content_test.dart @@ -57,7 +57,11 @@ void main() { () => MockClient((request) async { final body = jsonDecode(request.body) as Map; final messages = body['messages'] as List; - prompts.add((messages.single as Map)['content']); + expect(messages, hasLength(2)); + expect(messages.first['role'], 'system'); + expect(messages.last['role'], 'user'); + prompts.add(messages.first['content'] as String); + prompts.add(messages.last['content'] as String); return http.Response( jsonEncode({ 'choices': [ @@ -73,7 +77,8 @@ void main() { ); expect(variant, isNotNull); - expect(prompts, hasLength(1)); + expect(prompts, hasLength(2)); + final fullPrompt = prompts.join('\n'); for (final field in [ 'schemaVersion', 'variantId', @@ -81,10 +86,10 @@ void main() { 'prompt', 'expectedAnswer', ]) { - expect(prompts.single, contains(field)); + expect(fullPrompt, contains(field)); } for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) { - expect(prompts.single, isNot(contains(field))); + expect(fullPrompt, isNot(contains(field))); } }, );