feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动

阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 16:38:23 +09:00
co-authored by Claude Opus 5
parent 0c16394b5a
commit 61abc69037
24 changed files with 2896 additions and 665 deletions
+300 -36
View File
@@ -233,16 +233,16 @@ class AiService {
'input': messages,
'reasoning': {'effort': reasoningEffort},
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_output_tokens': maxTokens,
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
};
}
return {
'model': model,
'messages': messages,
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_tokens': maxTokens,
'temperature': ?temperature,
'max_tokens': ?maxTokens,
};
}
@@ -253,9 +253,9 @@ class AiService {
int thinkingBudget = 1024,
}) {
return {
if (temperature != null) 'temperature': temperature,
if (maxOutputTokens != null) 'maxOutputTokens': maxOutputTokens,
if (responseMimeType != null) 'responseMimeType': responseMimeType,
'temperature': ?temperature,
'maxOutputTokens': ?maxOutputTokens,
'responseMimeType': ?responseMimeType,
'thinkingConfig': {
'thinkingBudget': thinkingBudget,
},
@@ -345,6 +345,249 @@ class AiService {
}
}
/// Performs structured multi-dimensional analysis on an English sentence or phrase.
/// Provides translation, sentence pattern, grammar breakdown, pronunciation tips, and extracted phrases.
Future<SentenceAnalysisResult?> analyzeSentence({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
final cleanText = text.trim();
if (cleanText.isEmpty || cleanText.length > 800) {
return null;
}
if (provider == AiProviderType.mock) {
return _buildMockSentenceAnalysis(cleanText);
}
final key = await resolveApiKey();
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
(uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const instruction =
'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. '
'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'
' "translation": "准确通顺的中文整句翻译",\n'
' "sentencePattern": "核心口语句型结构 (如:I would like + 名词/动词原形)",\n'
' "grammarNote": "通俗易懂的语法与时态要点 (1-2句话,面向初学者,不要学术术语)",\n'
' "pronunciationTips": "口语连读/失爆/弱读技巧提示 (如:check in 连读为 /tʃe-kɪn/)",\n'
' "phrases": [\n'
' {\n'
' "phrase": "句子中的重点短语或搭配",\n'
' "ipa": "/音标/",\n'
' "meaning": "在句中的准确释义",\n'
' "usageNote": "简要口语用法说明或常见搭配"\n'
' }\n'
' ]\n'
'}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': '$instruction\n\nSentence: $cleanText'},
],
},
],
'generationConfig': _buildGeminiGenerationConfig(
maxOutputTokens: 800,
responseMimeType: 'application/json',
),
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{
'role': 'user',
'content': '$instruction\n\nSentence: $cleanText',
},
],
maxTokens: 800,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null || content.isEmpty) return null;
return _decodeSentenceAnalysis(
raw: content,
originalText: cleanText,
provider: provider.name,
model: model,
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult? _decodeSentenceAnalysis({
required String raw,
required String originalText,
required String provider,
required String model,
}) {
try {
var sanitized = raw.trim();
if (sanitized.startsWith('```')) {
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
}
final jsonStart = sanitized.indexOf('{');
final jsonEnd = sanitized.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
sanitized = sanitized.substring(jsonStart, jsonEnd + 1);
}
final data = jsonDecode(sanitized);
if (data is! Map<String, dynamic>) return null;
final translation = data['translation'] as String? ?? '';
if (translation.trim().isEmpty) return null;
final phrasesList = <PhraseBreakdownItem>[];
if (data['phrases'] is List) {
for (final item in data['phrases'] as List) {
if (item is Map<String, dynamic>) {
final phrase = item['phrase'] as String? ?? '';
final meaning = item['meaning'] as String? ?? '';
if (phrase.trim().isNotEmpty && meaning.trim().isNotEmpty) {
phrasesList.add(
PhraseBreakdownItem(
phrase: phrase.trim(),
meaning: meaning.trim(),
ipa: item['ipa'] as String?,
usageNote: item['usageNote'] as String?,
),
);
}
}
}
}
return SentenceAnalysisResult(
originalText: originalText,
translation: translation.trim(),
sentencePattern: data['sentencePattern'] as String?,
grammarNote: data['grammarNote'] as String?,
pronunciationTips: data['pronunciationTips'] as String?,
phrases: phrasesList,
provider: provider,
model: model,
createdAt: DateTime.now(),
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult _buildMockSentenceAnalysis(String text) {
final lower = text.toLowerCase();
String translation = '(演示翻译)这是句子的中文参考释义。';
String? pattern = '常见日常交流句型';
String? grammar = '此句为日常口语高频表达,结构清晰,适合在日常与工作场景中直接套用。';
String? pronunciation = '注意单词间的自然停顿,句末语调自然微降。';
final phrases = <PhraseBreakdownItem>[];
if (lower.contains('would like') || lower.contains("i'd like")) {
translation = '我想办理相关事项/我想要这个,麻烦了。';
pattern = 'I would like + 名词/动词原形(礼貌请求句型)';
grammar = 'would like 相当于礼貌委婉的 want,是服务和工作场景中最得体的表达方式。';
pronunciation = 'would like 发音为 /wʊd laɪk/,注意 d 的微弱爆破。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'would like',
ipa: '/wʊd laɪk/',
meaning: '想要(礼貌委婉)',
usageNote: "比 I want 更得体,常缩写为 I'd like",
),
);
} else if (lower.contains('nice to meet you')) {
translation = '初次见面,很高兴认识你。';
pattern = 'It is + 形容词 + to do sth.(社交问候句型)';
grammar = '初次与新朋友或客户见面时的标准礼貌问候,通常省略了句首的 It is。';
pronunciation = 'meet 与 you 发生音变连读为 /miːtʃuː/。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'nice to meet you',
ipa: '/naɪs tuː miːt juː/',
meaning: '初次见面很高兴认识你',
usageNote: '仅用于初次相识;熟悉后再次见面用 Nice to see you again',
),
);
} else if (lower.contains('where is') || lower.contains("where's")) {
translation = '请问……在哪里?';
pattern = 'Where is + 目的地/物品?(询问地点句型)';
grammar = "where 引导的特殊疑问句,口语中常用缩读 Where's。";
pronunciation = 'Where 与 is 发生连读,读作 /weər ɪz/,疑问句末尾用降调。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'where is',
ipa: '/weər ɪz/',
meaning: '……在哪里',
usageNote: '问路与寻物核心句型,句首加上 Excuse me 更礼貌',
),
);
} else {
final words = text
.split(RegExp(r'\s+'))
.map((w) => w.replaceAll(RegExp(r'[^a-zA-Z]'), ''))
.where((w) => w.length > 3)
.take(2);
for (final w in words) {
phrases.add(
PhraseBreakdownItem(
phrase: w,
meaning: '重点词汇',
usageNote: '句子中的核心实词',
),
);
}
}
return SentenceAnalysisResult(
originalText: text,
translation: translation,
sentencePattern: pattern,
grammarNote: grammar,
pronunciationTips: pronunciation,
phrases: phrases,
provider: 'mock',
model: 'local-mock',
createdAt: DateTime.now(),
);
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
@@ -458,12 +701,17 @@ 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.
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
required String aiGoal,
required String learnerTask,
List<String> allowedLanguage = const [],
}) async {
if (provider == AiProviderType.mock) {
return null;
@@ -483,8 +731,23 @@ class AiService {
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), translation (string, simplified Chinese translation of reply), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
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.';
try {
final response = await http
.post(
@@ -500,7 +763,7 @@ class AiService {
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
{'text': system},
],
},
'contents': history
@@ -525,7 +788,7 @@ class AiService {
uri: uri,
model: model,
messages: [
{'role': 'system', 'content': '$system$requiredTask'},
{'role': 'system', 'content': system},
...history,
],
temperature: 0.3,
@@ -984,41 +1247,42 @@ Learner wrote: $answer''';
}
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
if (reply == null || reply.trim().isEmpty || reply.length > 240) {
return null;
}
// Only `reply` is mandatory. A model that omits or malforms an optional
// field used to make the whole turn fall back to the canned script.
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
final rawSlots = data['slots'];
if (rawSlots is Map) {
for (final entry in rawSlots.entries) {
final key = entry.key;
final value = entry.value;
if (key is! String || value is! String) continue;
if (key.length > 40 || value.length > 80) continue;
slots[key] = value;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
final rawEvidence = data['evidence'];
if (rawEvidence is List) {
for (final item in rawEvidence) {
if (item is String && item.length <= 240) evidence.add(item);
}
}
final translation = data['translation'] as String?;
final translation = data['translation'];
final feedback = data['feedback'];
return DialogueAiResponse(
reply: reply.trim(),
translation: translation?.trim(),
translation: translation is String && translation.trim().isNotEmpty
? translation.trim()
: null,
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
suggestsComplete: data['suggestsComplete'] == true,
feedback: feedback is String && feedback.trim().isNotEmpty
? feedback.trim()
: null,
);
} catch (_) {
return null;