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
@@ -35,7 +35,7 @@ class DialogueScenePage extends StatelessWidget {
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答,完成明确任务后结束。',
'轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
@@ -60,8 +60,8 @@ class DialogueScenePage extends StatelessWidget {
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
],
),
);
@@ -107,6 +107,7 @@ class DialoguePage extends StatefulWidget {
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
@@ -123,6 +124,22 @@ class _DialoguePageState extends State<DialoguePage> {
bool waitingForReply = false;
String? validationError;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
String? aiNotice;
/// The AI's short Chinese comment on the learner's English. It is kept until
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
String? latestFeedback;
/// The free scene stores its draft under its own id.
static const _sceneDraftId = 'scene-a0-meet';
/// Closing line for the turn after the last scripted prompt. It stays inside
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
static const _closingLine = 'Bye! Nice to meet you.';
static const _closingTranslation = '再见!很高兴认识你。';
final Set<int> _shownTranslations = <int>{};
LessonDialogue get script => widget.isLessonDialogue
@@ -134,14 +151,12 @@ class _DialoguePageState extends State<DialoguePage> {
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
translations: translations,
);
: a0MeetDialogue;
String? _resolveTranslationFor(String text, int currentStage) {
/// Only exact sentence matches are trusted. The old positional fallback
/// attached `script.translations[stage]` to whatever the AI happened to say,
/// which produced Chinese that did not match the English on screen.
String? _resolveTranslationFor(String text) {
final cleanText = text.trim();
// 1. Check current script prompts
for (var i = 0; i < script.prompts.length; i++) {
@@ -151,11 +166,12 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
}
// 2. Check standalone prompts
for (var i = 0; i < prompts.length; i++) {
if (prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
if (i < translations.length) {
return translations[i];
// 2. Check the free scene script
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < a0MeetDialogue.translations.length) {
return a0MeetDialogue.translations[i];
}
}
}
@@ -180,6 +196,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
// 5. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
return "太棒了 — 很高兴认识你!";
@@ -188,40 +205,22 @@ class _DialoguePageState extends State<DialoguePage> {
cleanText.toLowerCase().contains("bye")) {
return "再见!";
}
// 6. If stage index is within script.translations
if (currentStage >= 0 && currentStage < script.translations.length) {
return script.translations[currentStage];
}
return null;
}
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final draft = widget.isLessonDialogue
? widget.state.dialogueDraft
: widget.state.sceneDialogueDraft;
final expectedDraftId = widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft != null &&
draft.lessonId == expectedDraftId &&
draft.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
@@ -230,7 +229,7 @@ class _DialoguePageState extends State<DialoguePage> {
for (var i = 0; i < draft.turns.length; i++) {
final t = draft.turns[i];
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
final trans = _resolveTranslationFor(t.text, i ~/ 2);
final trans = _resolveTranslationFor(t.text);
turns.add(t.copyWith(translation: trans));
} else {
turns.add(t);
@@ -238,8 +237,9 @@ class _DialoguePageState extends State<DialoguePage> {
}
} else {
final initialPrompt = script.prompts.first;
final initialTranslation = script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt, 0);
final initialTranslation =
script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt);
turns.add(
DialogueTurn(
text: initialPrompt,
@@ -251,6 +251,7 @@ class _DialoguePageState extends State<DialoguePage> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
_scrollToBottom();
}
});
}
@@ -260,12 +261,28 @@ class _DialoguePageState extends State<DialoguePage> {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
_scrollController.dispose();
controller.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
Future<void> send() async {
final text = controller.text.trim();
@@ -273,7 +290,10 @@ class _DialoguePageState extends State<DialoguePage> {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
setState(
() => validationError = '这一轮要“${_currentTaskLabel()}”,这句还没做到。'
'可以点“提示”看示范,再补充一次。',
);
return;
}
widget.state.recordDialogueAttempt(
@@ -298,13 +318,20 @@ class _DialoguePageState extends State<DialoguePage> {
validationError = null;
});
_saveDraft();
_scrollToBottom();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
aiGoal: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
: 'Say goodbye warmly and end the conversation.',
learnerTask: nextStage < script.hints.length
? 'answer with something like "${script.hints[nextStage]}"'
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: allTaughtLanguage,
history: turns
.map(
(turn) => <String, String>{
@@ -318,10 +345,10 @@ class _DialoguePageState extends State<DialoguePage> {
final replyText = aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!');
: _closingLine);
var replyTranslation = aiResponse?.translation;
if (replyTranslation == null || replyTranslation.isEmpty) {
replyTranslation = _resolveTranslationFor(replyText, nextStage);
replyTranslation = _resolveTranslationFor(replyText);
}
setState(() {
turns.add(
@@ -332,86 +359,48 @@ class _DialoguePageState extends State<DialoguePage> {
),
);
waitingForReply = false;
final feedback = aiResponse?.feedback;
if (feedback != null && feedback.trim().isNotEmpty) {
latestFeedback = feedback.trim();
}
aiNotice = aiResponse != null
? null
: (widget.state.aiProvider == AiProviderType.mock
? '当前未连接 AI,正在按示范脚本对话。'
: 'AI 暂时无法连接,这一句来自示范脚本。');
});
_saveDraft();
_scrollToBottom();
VoiceService.instance.speak(replyText);
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
final draft = DialogueDraft(
lessonId: widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
);
if (widget.isLessonDialogue) {
widget.state.saveDialogueDraft(draft);
} else {
widget.state.saveSceneDialogueDraft(draft);
}
}
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
bool _matchesCurrentTask(String response) {
if (!widget.isLessonDialogue) {
final text = response.toLowerCase();
return switch (stage) {
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
2 => RegExp(
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
).hasMatch(text),
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
};
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
// sentence, so an off-task answer passed every stage.
if (widget.isLessonDialogue &&
lessonById(widget.state.activeLessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, response);
}
final text = response.toLowerCase();
final lessonId = widget.state.activeLessonId;
if (lessonById(lessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
}
if (stage == 0) {
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
}
if (lessonId == 'a0-02' && stage == 1) {
return RegExp(
r'[a-z](?:[ -]?[a-z]){2,}',
caseSensitive: false,
).hasMatch(text);
}
final expected = switch (lessonId) {
'a0-01' => ['nice', 'hello', 'what'],
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
'a0-04' => [
'yes',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'zero',
'what',
],
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
'a0-06' => ['from', 'where', 'bye'],
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
'a0-08' => [
'it',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'clock',
'what',
],
'a0-09' => ['like', 'yes', 'no', 'do'],
'a0-10' => ['please', 'what', 'like'],
_ => ['nice', 'how', 'what', 'from', 'like'],
};
return expected.any(text.contains);
return matchesDialogueStage(script, stage, response);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
@@ -425,6 +414,7 @@ class _DialoguePageState extends State<DialoguePage> {
widget.onFinished(null);
return;
}
widget.state.clearSceneDialogueDraft();
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
@@ -432,13 +422,25 @@ class _DialoguePageState extends State<DialoguePage> {
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
// Only the turns the learner actually passed are reported.
completedTasks: _completedTaskLabels(),
personalSentence: personalSentence,
usedHelp: usedHelp,
improvement: latestFeedback,
),
);
}
/// A learner turn is only added after it passes [_matchesCurrentTask], so the
/// number of learner turns is the number of tasks actually completed.
List<String> _completedTaskLabels() {
final done = turns.where((turn) => turn.isLearner).length;
return [
for (var i = 0; i < done && i < script.taskLabels.length; i++)
script.taskLabels[i],
];
}
Future<void> _toggleTurnTranslation(int index) async {
if (index < 0 || index >= turns.length) return;
final turn = turns[index];
@@ -453,7 +455,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = turn.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(turn.text, index ~/ 2);
trans = _resolveTranslationFor(turn.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -462,6 +464,7 @@ class _DialoguePageState extends State<DialoguePage> {
_shownTranslations.add(index);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -482,6 +485,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[index] = turn.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _showLatestAiTranslation() async {
@@ -491,7 +495,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = latestAi.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(latestAi.text, stage);
trans = _resolveTranslationFor(latestAi.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -502,6 +506,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -525,6 +530,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _playLatestAi({required bool slow}) async {
@@ -537,7 +543,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
@@ -564,6 +570,7 @@ class _DialoguePageState extends State<DialoguePage> {
transcriptEdited = false;
}
});
_scrollToBottom();
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
@@ -576,56 +583,19 @@ class _DialoguePageState extends State<DialoguePage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening(
(text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!available) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击麦克风,AI 将自动转写英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
return;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() => listening = available);
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
Future<void> _toggleRecording() async {
@@ -679,7 +649,9 @@ class _DialoguePageState extends State<DialoguePage> {
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
final totalStages = script.prompts.length;
return AppPage(
scrollController: _scrollController,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -687,7 +659,7 @@ class _DialoguePageState extends State<DialoguePage> {
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(
@@ -796,6 +768,33 @@ class _DialoguePageState extends State<DialoguePage> {
],
),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => showLexiconLookup(
context,
state: widget.state,
initialText: turn.text,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.psychology_alt_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 4),
Text(
"句型解析",
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
@@ -822,6 +821,7 @@ class _DialoguePageState extends State<DialoguePage> {
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
});
_saveDraft();
_scrollToBottom();
},
),
_AssistChip(
@@ -847,6 +847,29 @@ class _DialoguePageState extends State<DialoguePage> {
style: const TextStyle(color: AppColors.warmInk),
),
),
if (aiNotice != null)
SectionCard(
tint: AppColors.warm,
child: Row(
children: [
const Icon(
Icons.cloud_off_outlined,
size: 18,
color: AppColors.warmInk,
),
const SizedBox(width: 8),
Expanded(
child: Text(
aiNotice!,
style: const TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
),
],
),
),
if (validationError != null)
Text(
validationError!,
@@ -940,9 +963,17 @@ class _DialoguePageState extends State<DialoguePage> {
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
: '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。',
),
),
if (latestFeedback != null)
SectionCard(
tint: AppColors.warm,
child: Text(
'下次可以注意:${latestFeedback!}',
style: const TextStyle(color: AppColors.warmInk),
),
),
PrimaryButton(
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
onPressed: _finish,
@@ -996,7 +1027,11 @@ class DialogueSummaryPage extends StatelessWidget {
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
Text(
summary.completedTasks.isEmpty
? '这次还没有完成完整的交际任务,可以再练一次。'
: '你完成了 ${summary.completedTasks.join('')}',
),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
@@ -1005,6 +1040,16 @@ class DialogueSummaryPage extends StatelessWidget {
tint: AppColors.green,
),
),
if (summary.improvement != null && summary.improvement!.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: _SummaryLine(
icon: Icons.tips_and_updates_outlined,
title: '下一次说得更好',
sentence: summary.improvement!,
tint: AppColors.warmInk,
),
),
Text(
summary.usedHelp
? '本次使用过提示。下次可以先不看提示,再试一次。'