整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
+179 -78
View File
@@ -2,6 +2,7 @@ import 'sherpa_stt_service.dart';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
@@ -139,12 +140,17 @@ class AiService {
if (key == null || key.isEmpty || uri == null) return null;
final ext = filePath.split('.').last.toLowerCase();
final format = (ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac') ? ext : 'm4a';
final format =
(ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac')
? ext
: 'm4a';
final base64Data = base64Encode(bytes);
try {
if (provider == AiProviderType.gemini) {
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
final mimeType = format == 'wav'
? 'audio/wav'
: (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
final response = await _postJson(
provider: provider,
key: key,
@@ -155,21 +161,17 @@ class AiService {
{
'parts': [
{
'text': 'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
'text':
'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
},
{
'inline_data': {
'mime_type': mimeType,
'data': base64Data,
}
}
]
}
'inline_data': {'mime_type': mimeType, 'data': base64Data},
},
],
},
],
'generationConfig': {
'thinkingConfig': {
'thinkingBudget': 1024,
},
'thinkingConfig': {'thinkingBudget': 1024},
},
},
);
@@ -189,17 +191,15 @@ class AiService {
'content': [
{
'type': 'text',
'text': 'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
'text':
'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
},
{
'type': 'input_audio',
'input_audio': {
'data': base64Data,
'format': format,
},
}
'input_audio': {'data': base64Data, 'format': format},
},
],
}
},
],
'reasoning_effort': 'low',
'temperature': 0.1,
@@ -219,7 +219,8 @@ class AiService {
}
}
static bool _isHttpUri(Uri uri) => uri.scheme == 'https' || uri.scheme == 'http';
static bool _isHttpUri(Uri uri) =>
uri.scheme == 'https' || uri.scheme == 'http';
static bool _isDeepSeek(Uri uri) {
final host = uri.host.toLowerCase();
@@ -295,12 +296,31 @@ class AiService {
),
);
if (!_isSuccess(response)) return null;
if (kDebugMode) _logUsage(response.body);
return _extractResponseContent(provider, response.body);
} catch (_) {
return null;
}
}
/// Debug-only prompt cache report. DeepSeek reports hits and misses
/// directly; OpenAI-style endpoints report cached tokens in the details.
static void _logUsage(String body) {
try {
final usage = (jsonDecode(body) as Map<String, dynamic>)['usage'];
if (usage is! Map) return;
final hit =
usage['prompt_cache_hit_tokens'] ??
(usage['prompt_tokens_details'] as Map?)?['cached_tokens'];
final miss = usage['prompt_cache_miss_tokens'];
final prompt = usage['prompt_tokens'] ?? usage['input_tokens'];
debugPrint(
'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss '
'completion=${usage['completion_tokens'] ?? usage['output_tokens']}',
);
} catch (_) {}
}
Future<String?> _requestPrompt({
required AiProviderType provider,
required String endpoint,
@@ -436,9 +456,7 @@ class AiService {
'temperature': ?temperature,
'maxOutputTokens': ?maxOutputTokens,
'responseMimeType': ?responseMimeType,
'thinkingConfig': {
'thinkingBudget': thinkingBudget,
},
'thinkingConfig': {'thinkingBudget': thinkingBudget},
};
}
@@ -718,15 +736,9 @@ class AiService {
if (_isSuccess(response)) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(
ok: true,
message: '连接成功,AI 对话服务可用!',
);
return const AiConnectionResult(ok: true, message: '连接成功,AI 对话服务可用!');
}
return const AiConnectionResult(
ok: true,
message: '连接成功,接口响应正常。',
);
return const AiConnectionResult(ok: true, message: '连接成功,接口响应正常。');
}
if (response.statusCode == 401 || response.statusCode == 403) {
return AiConnectionResult(
@@ -761,6 +773,10 @@ class AiService {
/// [aiGoal] is what Mia's own next line has to do; [learnerTask] is what the
/// learner has to say afterwards. They used to be the same string, so the
/// model was told to perform the learner's job.
///
/// The system prompt holds only what stays fixed for the whole conversation
/// and the per-turn goal goes at the very end, so each turn repeats the
/// previous request as a prefix and DeepSeek can serve it from its cache.
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
@@ -768,51 +784,85 @@ class AiService {
required List<Map<String, String>> history,
required String aiGoal,
required String learnerTask,
String level = 'A0',
List<String> allowedLanguage = const [],
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final vocabularyRule = allowedLanguage.isEmpty
? ''
: 'Build your reply from your goal wording, names, numbers and this '
'taught language: ${allowedLanguage.join('; ')}. '
'At most one word outside it per reply, and only if unavoidable. ';
final system =
'You are Mia, a patient A0 English conversation partner for a Chinese beginner. '
'Your own next line must do this: $aiGoal '
'After your line the learner has to: $learnerTask. '
'Do not say the learner sentence for them, and do not ask for anything else. '
'$vocabularyRule'
'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": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
'Only reply is required.';
final system = dialogueSystemPrompt(
level: level,
allowedLanguage: allowedLanguage,
);
final turnNote =
'[Turn] Your next line must do this: $aiGoal '
'After your line the learner has to: $learnerTask.';
// Earlier AI lines are stored as plain English. Sent that way they teach
// 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 = <Map<String, String>>[
for (final message in history)
message['role'] == 'assistant'
? {
'role': 'assistant',
'content': jsonEncode({'reply': message['content']}),
}
: message,
];
if (messages.isNotEmpty && messages.last['role'] == 'user') {
messages.last = {
'role': 'user',
'content': '${messages.last['content']}\n\n$turnNote',
};
} else {
messages.add({'role': 'user', 'content': turnNote});
}
final content = await _requestContent(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
// Earlier AI lines are stored as plain English. Sent that way they teach
// 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.
messages: [
for (final message in history)
message['role'] == 'assistant'
? {
'role': 'assistant',
'content': jsonEncode({'reply': message['content']}),
}
: message,
],
messages: messages,
temperature: 0.3,
maxTokens: 300,
);
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.
@visibleForTesting
static String dialogueSystemPrompt({
required String level,
required List<String> 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
? ''
: 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. '
'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
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
Future<GeneratedReviewVariant?> generateReviewVariant({
@@ -824,16 +874,20 @@ 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 =
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". '
'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-${targetItemId.toLowerCase()}-1", maximum 80 characters), '
'targetItemId (must be "$targetItemId"), '
'variantId (short id such as "ai-<target item id in lower case>-1", maximum 80 characters), '
'targetItemId (must be the target item id below), '
'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.'
'${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
'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(
provider: provider,
endpoint: endpoint,
@@ -868,13 +922,15 @@ class AiService {
required String lessonId,
required String taskPrompt,
required String 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 "$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 A0 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 A0, and do not invent facts the learner did not write.
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(
@@ -886,10 +942,46 @@ Learner wrote: $answer''';
maxTokens: 300,
);
if (content == null) return null;
return decodeWritingAiFeedback(
content,
expectedLessonId: lessonId,
return decodeWritingAiFeedback(content, expectedLessonId: lessonId);
}
/// Checks a review answer or dialogue turn for spelling and grammar. It
/// reuses the writing feedback schema, keyed by [answerId], and stays
/// advisory: the local check still decides whether the answer counts.
Future<WritingAiFeedback?> answerFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String answerId,
required String target,
required String taskPrompt,
required String 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.
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.
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(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
temperature: 0,
maxTokens: 300,
);
if (content == null) return null;
return decodeWritingAiFeedback(content, expectedLessonId: answerId);
}
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
@@ -904,7 +996,11 @@ Learner wrote: $answer''';
if (provider == AiProviderType.mock) return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
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, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], 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 [$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 A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
'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 A0-1.0, 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 A0 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(
provider: provider,
endpoint: endpoint,
@@ -1001,11 +1097,13 @@ Learner wrote: $answer''';
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
} else {
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
final choiceContent =
(choice?['message'] as Map?)?['content'] as String? ??
choice?['text'] as String?;
if (choiceContent != null && choiceContent.isNotEmpty) {
rawContent = choiceContent;
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
} else if (data['output_text'] is String &&
(data['output_text'] as String).isNotEmpty) {
rawContent = data['output_text'] as String;
} else {
final outputList = data['output'] as List?;
@@ -1028,11 +1126,14 @@ Learner wrote: $answer''';
}
}
if (rawContent == null) {
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
if (data['response'] is String &&
(data['response'] as String).isNotEmpty) {
rawContent = data['response'] as String;
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
} else if (data['text'] is String &&
(data['text'] as String).isNotEmpty) {
rawContent = data['text'] as String;
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
} else if (data['content'] is String &&
(data['content'] as String).isNotEmpty) {
rawContent = data['content'] as String;
}
}