fix: 修正学习流程中的证据、复习与进度问题

- 独立尝试按课程要求校验内容,不再任意输入即算独立成功
- 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境
- 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助
- 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习
- 复习成功不再降低已有状态
- 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估
- 新增 3 题基础定位,按结果设置起始课程
- 新增按课程开放的对话场景,首页推荐最少练习的场景
- 切换课程时清空上一课的步骤进度
- AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-17 18:52:01 +09:00
co-authored by Claude Opus 5
parent efdff769e4
commit 9ab08c25ef
19 changed files with 1645 additions and 302 deletions
@@ -11,54 +11,93 @@ import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart, this.onBack});
final VoidCallback onStart;
const DialogueScenePage({
super.key,
required this.state,
required this.onStart,
this.onBack,
});
final AppState state;
final ValueChanged<String> onStart;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
Widget build(BuildContext context) {
final recommended = state.recommendedScene;
return AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text(
'选一个场景,开口练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final scene in a0Scenes)
if (state.isSceneUnlocked(scene))
_OpenScene(
scene: scene,
recommended: scene.id == recommended.id,
onStart: () => onStart(scene.id),
)
else
_LockedScene(
title: scene.title,
note:
'A0 · 学完第 ${lessonById(scene.unlockAfterLessonId!).number} 课后开放',
),
const _LockedScene(title: '咖啡店点单', note: 'A1 · 后续版本开放'),
],
),
);
}
}
class _OpenScene extends StatelessWidget {
const _OpenScene({
required this.scene,
required this.recommended,
required this.onStart,
});
final DialogueScene scene;
final bool recommended;
final VoidCallback onStart;
@override
Widget build(BuildContext context) => SectionCard(
tint: recommended ? AppColors.softGreen : null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
scene.title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text(
recommended ? 'A0 · 推荐' : 'A0',
style: const TextStyle(color: AppColors.green),
),
],
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'初次见面',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text('A0', style: TextStyle(color: AppColors.green)),
],
),
Text(
'介绍姓名、地点、状态或喜好,并反问对方',
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(label: '开始对话', onPressed: onStart),
],
),
),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium),
recommended
? PrimaryButton(label: '开始对话', onPressed: onStart)
: SecondaryButton(label: '开始对话', onPressed: onStart),
],
),
);
@@ -94,10 +133,14 @@ class DialoguePage extends StatefulWidget {
required this.state,
required this.onFinished,
this.isLessonDialogue = false,
this.sceneId = 'a0-meet',
});
final AppState state;
final ValueChanged<DialogueSummaryData?> onFinished;
final bool isLessonDialogue;
/// The free scene to run when this is not a lesson dialogue.
final String sceneId;
@override
State<DialoguePage> createState() => _DialoguePageState();
}
@@ -124,8 +167,18 @@ class _DialoguePageState extends State<DialoguePage>
/// 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';
DialogueScene get _scene => sceneById(widget.sceneId);
/// A free scene may use everything the learner has finished so far.
String get _furthestCompletedLessonId => a0SeedLessons
.lastWhere(
(lesson) => widget.state.completedLessonIds.contains(lesson.id),
orElse: () => a0SeedLessons.first,
)
.id;
/// Each free scene stores its draft under its own id.
String get _sceneDraftId => 'scene-${_scene.id}';
/// Closing line for the turn after the last scripted prompt. It stays inside
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
@@ -143,7 +196,7 @@ class _DialoguePageState extends State<DialoguePage>
.id,
widget.state.activeLessonId,
)
: a0MeetDialogue;
: _scene.script;
/// Only exact sentence matches are trusted. The old positional fallback
/// attached `script.translations[stage]` to whatever the AI happened to say,
@@ -286,8 +339,10 @@ class _DialoguePageState extends State<DialoguePage>
return;
}
widget.state.recordDialogueAttempt(
taskId:
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
taskId: widget.isLessonDialogue
? 'dialogue-$_lessonSegmentId-$stage'
: 'dialogue-scene-${_scene.id}-$stage',
sceneId: widget.isLessonDialogue ? null : _scene.id,
rawAnswer: text,
assisted: usedHelp,
spoken: usedVoice && !transcriptEdited,
@@ -320,7 +375,10 @@ class _DialoguePageState extends State<DialoguePage>
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: allTaughtLanguage,
: _scene.unlockAfterLessonId == null
// The always-open scene mixes every A0 topic, as its script does.
? allTaughtLanguage
: taughtLanguageUpTo(_furthestCompletedLessonId),
history: turns
.map(
(turn) => <String, String>{
@@ -409,7 +467,7 @@ class _DialoguePageState extends State<DialoguePage>
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
: learnerTurns.first.text;
widget.state.addDialogueRecap(personalSentence);
widget.state.addDialogueRecap(personalSentence, sceneId: _scene.id);
widget.onFinished(
DialogueSummaryData(
// Only the turns the learner actually passed are reported.
@@ -584,7 +642,7 @@ class _DialoguePageState extends State<DialoguePage>
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
'${widget.isLessonDialogue ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(