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
+72 -58
View File
@@ -1,3 +1,4 @@
import 'a0_core.dart';
import 'models.dart';
class ReviewCheckResult {
@@ -21,70 +22,20 @@ class ReviewFeedback {
.trim();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = _normalize(input);
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(_normalize(token));
bool phrase(String value) => text.contains(_normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final complete = switch (item.id) {
'A0-P01' => introduction && tokens.length >= 2,
'A0-P02' => (phrase("what's your name") || phrase('what is your name')),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") ||
phrase('what is your phone number'),
'A0-P08' =>
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') =>
tokens.contains(_normalize(item.target)) || phrase(item.target),
_ => tokens.length >= 2,
};
final complete = a0CoreItems.containsKey(item.id)
// A word review checks the word it actually shows.
? coreItemUsedIn(item.id, input, word: item.target)
: _tokens(_normalize(input)).length >= 2;
return ReviewCheckResult(
complete: complete,
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
);
}
static Set<String> _tokens(String text) => RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
static String _hint(String id) => switch (id) {
'A0-P01' => '用 I’m / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:What’s your name?',
@@ -97,3 +48,66 @@ class ReviewFeedback {
_ => '再补充本次目标中的关键英文词或句型。',
};
}
/// Whether [input] actually contains A0 core item [id]. Lessons, reviews and
/// dialogues use the same rule, so evidence only goes to the items a learner
/// really produced, never to every target a task happens to be attached to.
bool coreItemUsedIn(String id, String input, {String? word}) {
final text = ReviewFeedback._normalize(input);
final tokens = ReviewFeedback._tokens(text);
bool has(String token) => tokens.contains(ReviewFeedback._normalize(token));
bool phrase(String value) => text.contains(ReviewFeedback._normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
const weekdays = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
return switch (id) {
// "I'm from …" and "I'm good" are other items, not a name.
'A0-P01' =>
phrase('my name is') ||
RegExp(
r"\bi(?:'m| am) (?!from\b|good\b|okay\b|ok\b|fine\b|great\b|tired\b)[a-z]",
).hasMatch(text),
'A0-P02' => phrase("what's your name") || phrase('what is your name'),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") || phrase('what is your phone number'),
'A0-P08' => numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key', 'phone']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' => itIs && hasAny(weekdays),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when a0CoreItems.containsKey(id) && id.startsWith('A0-W') =>
has(word ?? a0CoreItems[id]!) || phrase(word ?? a0CoreItems[id]!),
_ => false,
};
}