Files
English/kouyu_english/lib/core/review_feedback.dart
T
shenleiandClaude Opus 4.8 febfd30f49 feat: A1 短材料复现本单元理解词,并加入黑夜模式
课程内容
- 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本,
  单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。
- 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。
- 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、
  选项子串等校验全部通过;course_content_test 通过。

黑夜模式
- app_theme 拆分明/暗两套调色板,AppColors 随亮度切换;
  main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度;
  主题偏好持久化到快照;进度页新增“外观主题”切换。

文档
- COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:16:09 +09:00

129 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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' => '用 Im / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:Whats your name?',
'A0-P04' => '问对方怎样拼写:How do you spell …?',
'A0-P08' => '说出至少三个英文数字,也可用 My number is … 开头。',
'A0-P10' => '用 Its / It is 加上一个物品。',
'A0-P12' => '用 Im from … 说出一个地点。',
'A0-P17' => '用 Its … oclock 说整点时间。',
'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<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' => (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,
};
}