class WritingCheckResult { const WritingCheckResult({required this.complete, required this.message}); final bool complete; final String message; } /// A deliberately small offline checker for teaching tasks. It checks only /// the essential A0 information, accepts variable names/places, and never /// claims to grade pronunciation or nuanced grammar. class WritingFeedback { const WritingFeedback._(); static String _normalize(String input) => input .toLowerCase() .replaceAll('’', "'") .replaceAll('‘', "'") .replaceAll(RegExp(r'\s+'), ' ') .trim(); static WritingCheckResult check( String lessonId, String input, { String? segmentId, }) { final text = _normalize(input); final words = RegExp( r"[a-z]+(?:'[a-z]+)?", ).allMatches(text).map((match) => match.group(0)!).toSet(); bool has(String word) => words.contains(_normalize(word)); bool hasPhrase(String phrase) => text.contains(_normalize(phrase)); bool hasAny(Iterable choices) => choices.any(has); final hasIntroduction = hasPhrase("i'm") || hasPhrase('i am') || hasPhrase('my name is'); final hasItIs = hasPhrase("it's") || hasPhrase('it is'); final complete = switch (segmentId) { 'a0-04-a' => hasAll(words, ['zero', 'one', 'two', 'three']), 'a0-04-b' => hasAll(words, ['six', 'seven', 'eight']), 'a0-04-c' => RegExp( r'\b(one|two|three|four|five|six|seven|eight|nine|zero)\b', ).allMatches(text).length >= 3, 'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']), 'a0-08-b' => hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']), 'a0-08-c' => hasItIs && (has('oclock') || hasPhrase("o'clock")), _ => switch (lessonId) { 'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction, 'a0-02' => (hasPhrase('my name') || hasIntroduction) && RegExp(r'[a-z](?:[ -]?[a-z]){2,}').hasMatch(text), 'a0-03' => hasIntroduction && hasAny(['good', 'okay', 'tired']), 'a0-04' => RegExp( r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b', ).allMatches(text).length >= 3, 'a0-05' => hasItIs && hasAny(['book', 'pen', 'bag', 'key']), 'a0-06' => hasIntroduction && has('from') && words.length >= 3, 'a0-07' => hasPhrase('this is my') && hasAny(['mother', 'father', 'sister', 'brother', 'friend']), 'a0-08' => hasItIs && (hasAny([ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', ]) || has('clock')), 'a0-09' => hasIntroduction && has('like') && words.length >= 3, 'a0-10' => hasIntroduction && has('from') && has('like'), _ => words.length >= 2, }, }; if (complete) { return const WritingCheckResult( complete: true, message: '这句已经表达完整。继续在对话里用一次吧。', ); } return WritingCheckResult( complete: false, message: _hintFor(segmentId ?? lessonId), ); } static bool hasAll(Set words, Iterable required) => required.every(words.contains); static String _hintFor(String lessonId) => switch (lessonId) { 'a0-04-a' => '请写出 zero、one、two、three 四个数字。', 'a0-04-b' => '请写出 six、seven、eight 三个数字。', 'a0-04-c' => '用英文一个一个写出三个数字,例如 one-three-nine。', 'a0-08-a' => '用 It is / It’s 加 Monday、Tuesday 或 Wednesday。', 'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。', 'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。', 'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …', 'a0-02' => '写出姓名并拼写一遍,例如 My name is Shen. S-H-E-N.', 'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。', 'a0-04' => '用英文写出三个数字,例如 one-three-nine。', 'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。', 'a0-06' => '用 I’m from … 写成一个完整句子。', 'a0-07' => '用 This is my … 介绍一位家人或朋友。', 'a0-08' => '用 It’s … 写一个星期或整点时间。', 'a0-09' => '用 I like … 写出一种喜好。', 'a0-10' => '分别写姓名、来自哪里和一项喜好三部分。', _ => '再补充一个完整英文句子。', }; }