perf: improve AI prompt cache reuse
This commit is contained in:
@@ -9,7 +9,7 @@ class AiAnswerEvaluationCapability {
|
|||||||
|
|
||||||
static const descriptor = AiCapabilityDescriptor(
|
static const descriptor = AiCapabilityDescriptor(
|
||||||
id: 'answer-evaluation',
|
id: 'answer-evaluation',
|
||||||
promptVersion: 1,
|
promptVersion: 2,
|
||||||
outputContract: 'WritingAiFeedback',
|
outputContract: 'WritingAiFeedback',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AiDialogueCoachCapability {
|
|||||||
|
|
||||||
static const descriptor = AiCapabilityDescriptor(
|
static const descriptor = AiCapabilityDescriptor(
|
||||||
id: 'dialogue-coach',
|
id: 'dialogue-coach',
|
||||||
promptVersion: 2,
|
promptVersion: 3,
|
||||||
outputContract: 'DialogueAiResponse or DialogueAiIntervention',
|
outputContract: 'DialogueAiResponse or DialogueAiIntervention',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AiLexiconExplanationCapability {
|
|||||||
|
|
||||||
static const descriptor = AiCapabilityDescriptor(
|
static const descriptor = AiCapabilityDescriptor(
|
||||||
id: 'lexicon-explanation',
|
id: 'lexicon-explanation',
|
||||||
promptVersion: 1,
|
promptVersion: 2,
|
||||||
outputContract: 'Temporary definition or SentenceAnalysisResult',
|
outputContract: 'Temporary definition or SentenceAnalysisResult',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class AiReviewGenerationCapability {
|
|||||||
|
|
||||||
static const descriptor = AiCapabilityDescriptor(
|
static const descriptor = AiCapabilityDescriptor(
|
||||||
id: 'review-generation',
|
id: 'review-generation',
|
||||||
promptVersion: 1,
|
promptVersion: 2,
|
||||||
outputContract: 'GeneratedReviewVariant or audited GeneratedLesson',
|
outputContract: 'GeneratedReviewVariant or audited GeneratedLesson',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -323,18 +323,36 @@ class AiService {
|
|||||||
(usage['prompt_tokens_details'] as Map?)?['cached_tokens'];
|
(usage['prompt_tokens_details'] as Map?)?['cached_tokens'];
|
||||||
final miss = usage['prompt_cache_miss_tokens'];
|
final miss = usage['prompt_cache_miss_tokens'];
|
||||||
final prompt = usage['prompt_tokens'] ?? usage['input_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(
|
debugPrint(
|
||||||
'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss '
|
'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss '
|
||||||
|
'cacheRate=$cacheRate '
|
||||||
'completion=${usage['completion_tokens'] ?? usage['output_tokens']}',
|
'completion=${usage['completion_tokens'] ?? usage['output_tokens']}',
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _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<String?> _requestStructured({
|
||||||
required AiProviderType provider,
|
required AiProviderType provider,
|
||||||
required String endpoint,
|
required String endpoint,
|
||||||
required String model,
|
required String model,
|
||||||
required String prompt,
|
required String system,
|
||||||
|
required Map<String, dynamic> input,
|
||||||
double? temperature,
|
double? temperature,
|
||||||
required int maxTokens,
|
required int maxTokens,
|
||||||
Duration timeout = const Duration(seconds: 30),
|
Duration timeout = const Duration(seconds: 30),
|
||||||
@@ -343,8 +361,9 @@ class AiService {
|
|||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
|
system: system,
|
||||||
messages: [
|
messages: [
|
||||||
{'role': 'user', 'content': prompt},
|
{'role': 'user', 'content': jsonEncode(input)},
|
||||||
],
|
],
|
||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
maxTokens: maxTokens,
|
maxTokens: maxTokens,
|
||||||
@@ -482,13 +501,18 @@ class AiService {
|
|||||||
text.trim().isEmpty) {
|
text.trim().isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const instruction =
|
const system =
|
||||||
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
|
'You provide a display-only Chinese gloss for an English word or '
|
||||||
final content = await _requestPrompt(
|
'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,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: '$instruction\nText: $text',
|
system: system,
|
||||||
|
input: {'text': text.trim()},
|
||||||
maxTokens: 200,
|
maxTokens: 200,
|
||||||
);
|
);
|
||||||
if (content == null || content.length > 300) return null;
|
if (content == null || content.length > 300) return null;
|
||||||
@@ -521,9 +545,9 @@ class AiService {
|
|||||||
return _buildMockSentenceAnalysis(cleanText);
|
return _buildMockSentenceAnalysis(cleanText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const instruction =
|
const system =
|
||||||
'You are an expert oral English coach for beginner adult learners (A0-A1). '
|
'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. '
|
'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'
|
'Return ONLY valid JSON matching this schema, with no markdown or other text:\n'
|
||||||
'{\n'
|
'{\n'
|
||||||
@@ -541,11 +565,12 @@ class AiService {
|
|||||||
' ]\n'
|
' ]\n'
|
||||||
'}';
|
'}';
|
||||||
|
|
||||||
final content = await _requestPrompt(
|
final content = await _requestStructured(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: '$instruction\n\nSentence: $cleanText',
|
system: system,
|
||||||
|
input: {'sentence': cleanText},
|
||||||
maxTokens: 800,
|
maxTokens: 800,
|
||||||
);
|
);
|
||||||
if (content == null || content.isEmpty) return null;
|
if (content == null || content.isEmpty) return null;
|
||||||
@@ -799,7 +824,8 @@ class AiService {
|
|||||||
if (provider == AiProviderType.mock) {
|
if (provider == AiProviderType.mock) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final system = dialogueSystemPrompt(
|
final system = dialogueSystemPrompt();
|
||||||
|
final conversationConfig = dialogueConversationConfig(
|
||||||
level: level,
|
level: level,
|
||||||
allowedLanguage: allowedLanguage,
|
allowedLanguage: allowedLanguage,
|
||||||
);
|
);
|
||||||
@@ -811,6 +837,7 @@ class AiService {
|
|||||||
// the model to answer in plain text (or, in JSON mode, with blanks), so
|
// 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.
|
// they are replayed in the JSON shape the system prompt asks for.
|
||||||
final messages = <Map<String, String>>[
|
final messages = <Map<String, String>>[
|
||||||
|
{'role': 'user', 'content': conversationConfig},
|
||||||
for (final message in history)
|
for (final message in history)
|
||||||
message['role'] == 'assistant'
|
message['role'] == 'assistant'
|
||||||
? {
|
? {
|
||||||
@@ -839,41 +866,35 @@ class AiService {
|
|||||||
return _decodeDialogueResponse(content);
|
return _decodeDialogueResponse(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The conversation-wide dialogue instructions. The long word list comes
|
/// The conversation-wide dialogue instructions. This prefix must remain
|
||||||
/// last so the rules before it are shared across units as well.
|
/// byte-for-byte stable; level and taught language belong in the following
|
||||||
|
/// configuration message so they do not break cross-lesson cache reuse.
|
||||||
@visibleForTesting
|
@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 String level,
|
||||||
required List<String> allowedLanguage,
|
required List<String> allowedLanguage,
|
||||||
}) {
|
}) {
|
||||||
final learner = level == 'A0'
|
final vocabularyPolicy = allowedLanguage.isEmpty
|
||||||
? 'a patient A0 English conversation partner for a Chinese beginner'
|
? 'Use only simple, common $level English.'
|
||||||
: 'a patient English conversation partner for a Chinese learner at '
|
|
||||||
'CEFR $level';
|
|
||||||
final vocabularyRule = allowedLanguage.isEmpty
|
|
||||||
? ''
|
|
||||||
: level == 'A0'
|
: level == 'A0'
|
||||||
? 'Build your reply from your goal wording, names, numbers and this '
|
? 'Build the reply from the turn goal, names, numbers, and taughtLanguage. Use at most one other word, only if unavoidable.'
|
||||||
'taught language. At most one word outside it per reply, and '
|
: 'Prefer the turn goal and taughtLanguage; otherwise use only simple, common $level English.';
|
||||||
'only if unavoidable. Taught language: '
|
return '[ConversationConfig]\n${jsonEncode({'level': level, 'vocabularyPolicy': vocabularyPolicy, 'taughtLanguage': allowedLanguage})}';
|
||||||
'${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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates only a bounded variant of an existing review target. A network
|
/// Generates only a bounded variant of an existing review target. A network
|
||||||
@@ -887,25 +908,26 @@ class AiService {
|
|||||||
bool repairAttempt = false,
|
bool repairAttempt = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
// Fixed rules first, the item-specific part last, so repeated requests
|
const system =
|
||||||
// share a cacheable prefix.
|
|
||||||
final instruction =
|
|
||||||
'Generate one A0 English review variant of an existing review item. '
|
'Generate one A0 English review variant of an existing review item. '
|
||||||
'Return JSON only with exactly these five fields and nothing else: '
|
'Return JSON only with exactly these five fields and nothing else: '
|
||||||
'schemaVersion (must be "review-variant-1"), '
|
'schemaVersion (must be "review-variant-1"), '
|
||||||
'variantId (short id such as "ai-<target item id in lower case>-1", maximum 80 characters), '
|
'variantId (short id such as "ai-<target item id in lower case>-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), '
|
'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). '
|
'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'
|
'Stay strictly within A0. Do not introduce new vocabulary or change the target expression. '
|
||||||
'Target item id: $targetItemId\n'
|
'The user message is a JSON object. If repairAttempt is true, repair all schema and constraint errors.';
|
||||||
'Base prompt: $basePrompt'
|
final content = await _requestStructured(
|
||||||
'${repairAttempt ? '\nPrevious response failed schema or constraint validation: repair all errors.' : ''}';
|
|
||||||
final content = await _requestPrompt(
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {
|
||||||
|
'targetItemId': targetItemId,
|
||||||
|
'basePrompt': basePrompt,
|
||||||
|
'repairAttempt': repairAttempt,
|
||||||
|
},
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
);
|
);
|
||||||
@@ -938,19 +960,23 @@ class AiService {
|
|||||||
String level = 'A0',
|
String level = 'A0',
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final instruction =
|
const system =
|
||||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
'''Evaluate a beginner's open-ended English writing response using the input JSON.
|
||||||
schemaVersion must be "writing-feedback-1" and lessonId must be the lesson id given below.
|
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, 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 $level English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.lessonId.
|
||||||
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.
|
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.
|
||||||
Lesson id: $lessonId
|
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.''';
|
||||||
Task: $taskPrompt
|
final content = await _requestStructured(
|
||||||
Learner wrote: $answer''';
|
|
||||||
final content = await _requestPrompt(
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {
|
||||||
|
'level': level,
|
||||||
|
'lessonId': lessonId,
|
||||||
|
'taskPrompt': taskPrompt,
|
||||||
|
'answer': answer,
|
||||||
|
},
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
);
|
);
|
||||||
@@ -972,24 +998,27 @@ Learner wrote: $answer''';
|
|||||||
String level = 'A0',
|
String level = 'A0',
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final instruction =
|
const system =
|
||||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
'''Check the learner's English in the input JSON for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression.
|
||||||
schemaVersion must be "writing-feedback-1" and lessonId must be the answer id given below.
|
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||||
Check the learner's English for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression.
|
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.
|
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.
|
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".
|
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.
|
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.''';
|
||||||
Answer id: $answerId
|
final content = await _requestStructured(
|
||||||
Target expression: $target
|
|
||||||
Task: $taskPrompt
|
|
||||||
Learner wrote: $answer''';
|
|
||||||
final content = await _requestPrompt(
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {
|
||||||
|
'level': level,
|
||||||
|
'answerId': answerId,
|
||||||
|
'target': target,
|
||||||
|
'taskPrompt': taskPrompt,
|
||||||
|
'answer': answer,
|
||||||
|
},
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
);
|
);
|
||||||
@@ -1013,20 +1042,15 @@ Learner wrote: $answer''';
|
|||||||
String level = 'A0',
|
String level = 'A0',
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final instruction =
|
const system =
|
||||||
'You are a supportive oral English coach evaluating an ESL beginner ($level) spoken line in a dialogue.\n'
|
'You are a supportive oral English coach evaluating a beginner spoken line using the input JSON.\n'
|
||||||
'Context:\n'
|
'Evaluation instructions:\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'
|
|
||||||
'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'
|
'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'
|
'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'
|
'3. Return ONLY valid JSON with no markdown:\n'
|
||||||
'{\n'
|
'{\n'
|
||||||
' "schemaVersion": "dialogue-intervention-2",\n'
|
' "schemaVersion": "dialogue-intervention-2",\n'
|
||||||
' "turnId": "${turnId ?? ''}",\n'
|
' "turnId": "exactly copy input.turnId",\n'
|
||||||
' "accepted": true or false,\n'
|
' "accepted": true or false,\n'
|
||||||
' "goalSatisfied": true or false,\n'
|
' "goalSatisfied": true or false,\n'
|
||||||
' "verdict": "accepted, correctable, off_topic, or uncertain",\n'
|
' "verdict": "accepted, correctable, off_topic, or uncertain",\n'
|
||||||
@@ -1037,13 +1061,22 @@ Learner wrote: $answer''';
|
|||||||
'Rules:\n'
|
'Rules:\n'
|
||||||
'- If it is a valid, natural reply (or minor casing/punctuation): set "accepted": true, "suggestion": null, "explanation": "表达自然得体,符合本轮交流目标。".\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 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,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {
|
||||||
|
'level': level,
|
||||||
|
'partnerLine': partnerLine,
|
||||||
|
'taskLabel': taskLabel,
|
||||||
|
'hint': hint,
|
||||||
|
'learnerText': learnerText,
|
||||||
|
'turnId': turnId ?? '',
|
||||||
|
},
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
maxTokens: 250,
|
maxTokens: 250,
|
||||||
);
|
);
|
||||||
@@ -1130,17 +1163,28 @@ Learner wrote: $answer''';
|
|||||||
final lessonId =
|
final lessonId =
|
||||||
'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
|
'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
|
||||||
final stageVersion = '$level-1.0';
|
final stageVersion = '$level-1.0';
|
||||||
final instruction =
|
const system =
|
||||||
'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'
|
'Create a bounded adaptive English mini-lesson from the input JSON. '
|
||||||
'lessonId: $lessonId\n'
|
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. '
|
||||||
'Target item id: $targetItemId\n'
|
'Use schemaVersion lesson-2, copy lessonId and stageVersion from input, revision 1, source aiGenerated, status validated, targetItemIds [input.targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. '
|
||||||
'Target expression: $targetLabel'
|
'Create exactly four tasks: one listening listenChoice, speaking repeat, reading readAnswer, and writing writeAnswer. '
|
||||||
'${repairAttempt ? '\nPrevious response was invalid: repair all constraints.' : ''}';
|
'Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [input.targetItemId]. '
|
||||||
final content = await _requestPrompt(
|
'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,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {
|
||||||
|
'level': level,
|
||||||
|
'lessonId': lessonId,
|
||||||
|
'stageVersion': stageVersion,
|
||||||
|
'targetItemId': targetItemId,
|
||||||
|
'targetLabel': targetLabel,
|
||||||
|
'repairAttempt': repairAttempt,
|
||||||
|
},
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
maxTokens: 850,
|
maxTokens: 850,
|
||||||
timeout: const Duration(seconds: 45),
|
timeout: const Duration(seconds: 45),
|
||||||
@@ -1193,13 +1237,14 @@ Learner wrote: $answer''';
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
});
|
});
|
||||||
final instruction =
|
const system =
|
||||||
'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';
|
'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 _requestPrompt(
|
final content = await _requestStructured(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
prompt: instruction,
|
system: system,
|
||||||
|
input: {'lesson': jsonDecode(lessonJson)},
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
maxTokens: 200,
|
maxTokens: 200,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
test(
|
||||||
|
|||||||
@@ -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 = <List<dynamic>>[];
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic>;
|
||||||
|
captured.add(body['messages'] as List<dynamic>);
|
||||||
|
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 = <List<dynamic>>[];
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic>;
|
||||||
|
captured.add(body['messages'] as List<dynamic>);
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,13 +100,15 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(reply?.reply, 'Where are you from?');
|
expect(reply?.reply, 'Where are you from?');
|
||||||
expect(messages, hasLength(3));
|
expect(messages, hasLength(4));
|
||||||
expect(messages![0]['role'], 'system');
|
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?",
|
'reply': "Hi! What's your name?",
|
||||||
});
|
});
|
||||||
expect(messages![2]['role'], 'user');
|
expect(messages![3]['role'], 'user');
|
||||||
final last = messages![2]['content'] as String;
|
final last = messages![3]['content'] as String;
|
||||||
expect(last, startsWith('My name is Alex.'));
|
expect(last, startsWith('My name is Alex.'));
|
||||||
expect(
|
expect(
|
||||||
last,
|
last,
|
||||||
@@ -114,28 +116,33 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
last,
|
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')));
|
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 words = ['Excuse me.', 'Can you help me?'];
|
||||||
final a = AiService.dialogueSystemPrompt(
|
final a = AiService.dialogueSystemPrompt();
|
||||||
level: 'A1',
|
final b = AiService.dialogueSystemPrompt();
|
||||||
allowedLanguage: words,
|
|
||||||
);
|
|
||||||
final b = AiService.dialogueSystemPrompt(
|
|
||||||
level: 'A1',
|
|
||||||
allowedLanguage: words,
|
|
||||||
);
|
|
||||||
expect(a, b);
|
expect(a, b);
|
||||||
expect(a, contains('CEFR A1'));
|
|
||||||
expect(a, contains('Strictly do not repeat any question'));
|
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(
|
expect(
|
||||||
AiService.dialogueSystemPrompt(level: 'A0', allowedLanguage: const []),
|
AiService.dialogueConversationConfig(
|
||||||
contains('A0 English conversation partner'),
|
level: 'A0',
|
||||||
|
allowedLanguage: const [],
|
||||||
|
),
|
||||||
|
contains('simple, common A0 English'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ void main() {
|
|||||||
() => MockClient((request) async {
|
() => MockClient((request) async {
|
||||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
final messages = body['messages'] as List<dynamic>;
|
final messages = body['messages'] as List<dynamic>;
|
||||||
prompts.add((messages.single as Map<String, dynamic>)['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(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'choices': [
|
'choices': [
|
||||||
@@ -73,7 +77,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(variant, isNotNull);
|
expect(variant, isNotNull);
|
||||||
expect(prompts, hasLength(1));
|
expect(prompts, hasLength(2));
|
||||||
|
final fullPrompt = prompts.join('\n');
|
||||||
for (final field in [
|
for (final field in [
|
||||||
'schemaVersion',
|
'schemaVersion',
|
||||||
'variantId',
|
'variantId',
|
||||||
@@ -81,10 +86,10 @@ void main() {
|
|||||||
'prompt',
|
'prompt',
|
||||||
'expectedAnswer',
|
'expectedAnswer',
|
||||||
]) {
|
]) {
|
||||||
expect(prompts.single, contains(field));
|
expect(fullPrompt, contains(field));
|
||||||
}
|
}
|
||||||
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
|
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
|
||||||
expect(prompts.single, isNot(contains(field)));
|
expect(fullPrompt, isNot(contains(field)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user