import 'a0_core.dart'; import 'models.dart'; import 'speech_compare.dart'; class ReviewCheckResult { const ReviewCheckResult({required this.complete, required this.message}); final bool complete; final String message; } /// Checks only the minimum information a learner needs to produce in an A0 /// review. It intentionally accepts common contractions and variable slot /// values instead of comparing an answer with one fixed sentence. class ReviewFeedback { const ReviewFeedback._(); static String _normalize(String input) => input .toLowerCase() .replaceAll('’', "'") .replaceAll('‘', "'") .replaceAll(RegExp(r'\s+'), ' ') .trim(); static ReviewCheckResult check(ReviewItem item, String input) { final dictation = item.skill == dictationSkill ? a0DictationSentences[item.id] : null; if (dictation != null) { final comparison = compareSpoken(dictation.sentence, input); final missing = comparison.total - comparison.heardCount; final wrong = missing + comparison.extraWords.length; return ReviewCheckResult( complete: comparison.matches, message: comparison.matches ? '听写正确。' : '有 $wrong 处和听到的句子不一致。再听一次,检查拼写和漏掉的词。', ); } 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 _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?', 'A0-P04' => '问对方怎样拼写:How do you spell …?', 'A0-P08' => '说出至少三个英文数字,也可用 My number is … 开头。', 'A0-P10' => '用 It’s / It is 加上一个物品。', 'A0-P12' => '用 I’m from … 说出一个地点。', 'A0-P17' => '用 It’s … o’clock 说整点时间。', 'A0-P18' => '用 I like … 说一项喜好。', _ => '再补充本次目标中的关键英文词或句型。', }; } /// 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 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' => (phrase('i like') || phrase('i love')) && 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, }; }