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
+244 -19
View File
@@ -1116,7 +1116,16 @@ bool matchesSegmentIndependent(String segmentId, String response) {
.expand((segments) => segments)
.where((item) => item.id == segmentId)
.firstOrNull;
if (segment == null) return true;
if (segment == null) {
// Single-segment lessons: every group of the lesson's independent task
// has to be present. Unknown segments never pass.
final lessonId = segmentId.replaceFirst(RegExp(r'-[a-z]$'), '');
final groups = a0IndependentRequiredTerms[lessonId];
if (groups == null) return false;
return groups.every(
(group) => group.any((term) => _containsTerm(text, term)),
);
}
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
final count = segment.independentRequiredTerms
.where((term) => text.contains(_normalizeDialogueInput(term)))
@@ -1129,10 +1138,64 @@ bool matchesSegmentIndependent(String segmentId, String response) {
.length >=
3;
}
return segment.independentRequiredTerms
.any((term) => text.contains(_normalizeDialogueInput(term)));
return segment.independentRequiredTerms.any(
(term) => text.contains(_normalizeDialogueInput(term)),
);
}
const _introductionTerms = [
"i'm + #word",
'i am + #word',
'my name is',
"my name's",
];
/// What the independent attempt of each single-segment lesson has to contain.
/// Each inner list is one part of the task; a part passes when any of its
/// terms appears. Terms follow the same syntax as [LessonDialogue.requiredTerms].
const a0IndependentRequiredTerms = <String, List<List<String>>>{
// 介绍你的名字并回应 Nice to meet you
'a0-01': [
_introductionTerms,
['nice to meet you'],
],
// 介绍名字并拼读它
'a0-02': [
_introductionTerms,
['#spelling'],
],
// 问候并说出你的状态
'a0-03': [
['hello', 'hi', 'hey', 'how are you'],
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
],
// 说出一个身边物品
'a0-05': [
["it's a", 'it is a', "it's an", 'it is an'],
],
// 说来自哪里并反问对方
'a0-06': [
["i'm from", 'i am from'],
['where are you from', '#question'],
],
// 介绍一位家人或朋友
'a0-07': [
['this is my'],
],
// 说一个喜好并反问
'a0-09': [
['i like', 'i love'],
['do you like', '#question'],
],
// 完成姓名、地点、喜好和反问
'a0-10': [
_introductionTerms,
["i'm from", 'i am from', 'from + #word'],
['i like', 'i love'],
['#question'],
],
};
class LessonDialogue {
const LessonDialogue({
required this.goal,
@@ -1172,8 +1235,17 @@ bool _containsTerm(String text, String term) {
}
bool _matchesStructure(String text, String token) => switch (token) {
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
// Letters said one by one ("S-H-E-N", "s h e n", "L-I"), or the spelled
// name typed as one word ("Shen", "aaa"). Case and hyphens are ignored.
'#spelling' =>
RegExp(
r'(?:^|[^a-z])[a-z](?:\s*[-\u2010-\u2015]\s*[a-z]|\s[a-z])+(?![a-z])',
).hasMatch(text) ||
RegExp(r'^[a-z]{2,}$').hasMatch(
text
.replaceAll(RegExp(r'[-\u2010-\u2015]'), '')
.replaceAll(RegExp(r'^[^a-z]+|[^a-z]+$'), ''),
),
// Any spoken or written digit.
'#digit' => RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
@@ -1232,21 +1304,150 @@ const a0MeetDialogue = LessonDialogue(
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
["i'm from", 'i am from', 'from + #word'],
[
'good',
'okay',
'ok',
'fine',
'great',
'tired',
'i like',
'i love',
],
['good', 'okay', 'ok', 'fine', 'great', 'tired', 'i like', 'i love'],
['#question'],
],
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
);
/// A free practice scene. It opens once [unlockAfterLessonId] is complete
/// (always open when null) and only uses language taught up to that lesson.
class DialogueScene {
const DialogueScene({
required this.id,
required this.title,
required this.summary,
required this.script,
required this.recapPrompt,
this.unlockAfterLessonId,
});
final String id;
final String title;
final String summary;
final LessonDialogue script;
/// The follow-up review prompt added after the scene is finished.
final String recapPrompt;
final String? unlockAfterLessonId;
}
const a0Scenes = [
DialogueScene(
id: 'a0-meet',
title: '初次见面',
summary: '介绍姓名、地点、状态或喜好,并反问对方',
script: a0MeetDialogue,
recapPrompt: '再用英语介绍一次自己。',
),
DialogueScene(
id: 'a0-number',
title: '留个电话',
summary: '问候、报出号码、再说一遍,并反问对方',
unlockAfterLessonId: 'a0-04',
recapPrompt: '用英语报一次你的虚拟电话号码。',
script: LessonDialogue(
goal: '问候并交换一个虚拟电话号码',
prompts: [
'Hi! How are you today?',
'Good. What\u2019s your phone number? Use a fake number.',
'Sorry, please say it again.',
'Thanks! Now ask me one question.',
],
hints: [
'I\u2019m good, thanks.',
'My number is one-three-eight.',
'One-three-eight.',
'What\u2019s your phone number?',
],
translations: [
'嗨!你今天好吗?',
'好的。你的电话号码是多少?用一个虚拟号码。',
'抱歉,请再说一遍。',
'谢谢!现在问我一个问题。',
],
requiredTerms: [
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['#digit'],
['#digit'],
['#question'],
],
taskLabels: ['说出你今天的状态', '报出一个号码', '再说一次号码', '反问对方'],
),
),
DialogueScene(
id: 'a0-photo',
title: '看照片聊天',
summary: '介绍照片里的人和东西,说喜好,并反问对方',
unlockAfterLessonId: 'a0-07',
recapPrompt: '用英语介绍一位家人或朋友。',
script: LessonDialogue(
goal: '介绍照片里的人和物品',
prompts: [
'Nice photo! Who is this?',
'And what\u2019s this in the photo?',
'Where are you from?',
'Cool! Now ask me one question.',
],
hints: [
'This is my sister.',
'It\u2019s a bag.',
'I\u2019m from Hong Kong.',
'Who is this? / What\u2019s this?',
],
translations: ['照片真好!这是谁?', '照片里这个是什么?', '你来自哪里?', '真酷!现在问我一个问题。'],
requiredTerms: [
['this is my', 'this is'],
["it's a", 'it is a', "it's an", 'it is an'],
["i'm from", 'i am from', 'from + #word'],
['#question'],
],
taskLabels: ['介绍照片里的人', '说出照片里的物品', '说出你来自哪里', '反问对方'],
),
),
DialogueScene(
id: 'a0-plan',
title: '约个时间',
summary: '说星期和整点,回答喜好,并反问对方',
unlockAfterLessonId: 'a0-09',
recapPrompt: '用英语说今天星期几和现在几点。',
script: LessonDialogue(
goal: '说明星期、时间和喜好',
prompts: [
'Hi! What day is it today?',
'What time is it now?',
'Do you like coffee or tea?',
'Me too! Now ask me one question.',
],
hints: [
'It\u2019s Friday.',
'It\u2019s three o\u2019clock.',
'I like tea.',
'Do you like music?',
],
translations: ['嗨!今天星期几?', '现在几点?', '你喜欢咖啡还是茶?', '我也是!现在问我一个问题。'],
requiredTerms: [
[
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
],
["o'clock", 'oclock', "it's + #digit", 'it is + #digit'],
['i like', 'i love', 'yes', 'no', "i don't"],
['#question'],
],
taskLabels: ['说出今天星期几', '说出现在几点', '说出你的喜好', '反问对方'],
),
),
];
DialogueScene sceneById(String id) =>
a0Scenes.where((scene) => scene.id == id).firstOrNull ?? a0Scenes.first;
const a0Dialogues = <String, LessonDialogue>{
'a0-01': LessonDialogue(
goal: '问候、介绍姓名并回应见面问候',
@@ -1341,7 +1542,12 @@ const a0Dialogues = <String, LessonDialogue>{
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
['yes', 'no', "that's right", 'right', 'correct'],
['#digit'],
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
[
"what's your phone number",
'what is your phone number',
'your phone number',
'your number',
],
],
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
),
@@ -1422,7 +1628,12 @@ const a0Dialogues = <String, LessonDialogue>{
['who is this', "who's this"],
['this is my', 'this is'],
],
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
taskLabels: [
'介绍一位家人或朋友',
'完整说出 This is my … 句型',
'反问 Who is this',
'再介绍一个人',
],
),
'a0-08': LessonDialogue(
goal: '说明星期或整点',
@@ -1440,7 +1651,15 @@ const a0Dialogues = <String, LessonDialogue>{
],
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
requiredTerms: [
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
[
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
],
["o'clock", 'oclock', "#digit + o'clock"],
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
['what day is it', 'what day', '#question'],
@@ -1488,7 +1707,13 @@ const a0Dialogues = <String, LessonDialogue>{
requiredTerms: [
['say that again', 'again', 'pardon'],
['slowly', 'slow'],
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
[
"what's your name",
'what is your name',
'where are you from',
'your name',
'#question',
],
['i like', 'i love'],
],
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],