import 'models.dart'; import 'writing_feedback.dart'; class AssessmentTask { const AssessmentTask({ required this.id, required this.skill, required this.prompt, this.audio, this.choices = const [], this.answerIndex, this.requiredWords = const [], required this.abilityId, }); final String id; final AssessmentSkill skill; final String prompt; final String? audio; final List choices; final int? answerIndex; final List requiredWords; final String abilityId; } class AssessmentPack { const AssessmentPack({required this.id, required this.tasks}); final String id; final List tasks; List forSkill(AssessmentSkill skill) => tasks.where((task) => task.skill == skill).toList(); } final a0AssessmentPacks = [ AssessmentPack( id: 'A0-E1', tasks: _tasks( 'A', const [ 'Hello. I’m Mia.|自我介绍|买东西|问时间|0', 'How do you spell your name?|问姓名拼写|问地点|问时间|0', 'I’m tired today.|很好|累|来自哪里|1', 'seven|6|7|8|1', 'one-three-eight|138|183|318|0', 'It’s a key.|书|钥匙|包|1', 'I’m from Hong Kong.|香港|伦敦|北京|0', 'This is my mother.|妈妈|姐姐|朋友|0', 'It is Monday.|星期一|星期二|星期三|0', 'I like tea.|茶|咖啡|电影|0', ], const [ 'Mia: I’m from Hong Kong.\nShen: I’m good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0', 'Mia: It is Monday.\nShen: It is three o’clock.|几点?|three|one|0', 'Mia: This is my brother.\nShen: Nice to meet you.|谁是家人?|brother|mother|0', 'Mia: I like music.\nShen: I like tea.|Shen 喜欢什么?|tea|music|0', 'Mia: What’s this?\nShen: It’s a pen.|这是什么?|pen|bag|0', ], const [ '用英语介绍自己。|i,m', '用英语说明你来自哪里。|i,m,from', '用英语说一个喜好。|i,like', '用英语说“这是一支笔”。|it,s,pen', '用英语说明星期或整点。|it,s', ], const [ '介绍姓名、地点、状态/喜好并反问。|hello,i,m,from', '完整拼读一个四字母姓名。|a,l,e,x', '说一个三位虚拟号码。|one,three,eight', '介绍一位家人。|this,my', '说明星期。|it,s,monday', '说明整点。|it,s,o,clock', '请求对方重复或放慢。|please', '说出一个物品。|it,s', ], ), ), AssessmentPack( id: 'A0-E2', tasks: _tasks( 'B', const [ 'Hi. My name is Leo.|自我介绍|买东西|问号码|0', 'How do you spell that?|问姓名拼写|问喜好|问家人|0', 'I’m okay.|很好|还可以|累|1', 'four|3|4|5|1', 'two-nine-six|296|269|926|0', 'It’s a bag.|笔|包|水|1', 'I’m from London.|伦敦|香港|北京|0', 'This is my sister.|姐姐|妈妈|朋友|0', 'It is Friday.|星期五|星期一|星期日|0', 'I like coffee.|茶|咖啡|音乐|1', ], const [ 'Mia: I’m from London.\nLeo: I’m okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0', 'Mia: It is Friday.\nLeo: It is six o’clock.|几点?|six|three|0', 'Mia: This is my father.\nLeo: Hi!|谁是家人?|father|brother|0', 'Mia: I like movies.\nLeo: I like coffee.|Leo 喜欢什么?|coffee|movies|0', 'Mia: What’s this?\nLeo: It’s water.|这是什么?|water|book|0', ], const [ '用英语介绍自己。|my,name,is', '用英语说明你来自哪里。|i,m,from', '用英语说一个喜好。|i,like', '用英语说“这是一个包”。|it,s,bag', '用英语说明星期或整点。|it,s', ], const [ '介绍姓名、地点、状态/喜好并反问。|hi,i,m,from', '完整拼读一个四字母姓名。|l,e,o,n', '说一个三位虚拟号码。|two,nine,six', '介绍一位家人。|this,my', '说明星期。|it,s,friday', '说明整点。|it,s,o,clock', '请求对方重复或放慢。|please', '说出一个物品。|it,s', ], ), ), ]; /// Replacement packs use different people, places, numbers, days and likes. /// They keep the same assessed ability but are not immediate replays. final a0ReplacementPacks = [ _replacementPack(a0AssessmentPacks[0], 'A0-E1R', const { 'Mia': 'Nora', 'Shen': 'Kai', 'Hong Kong': 'London', 'Monday': 'Friday', 'one-three-eight': 'two-four-seven', '138': '247', 'three o’clock': 'six o’clock', 'tea': 'coffee', 'key': 'bag', 'mother': 'father', }), _replacementPack(a0AssessmentPacks[1], 'A0-E2R', const { 'Mia': 'Ava', 'Leo': 'Bo', 'London': 'Hong Kong', 'Friday': 'Tuesday', 'two-nine-six': 'five-one-four', '296': '514', 'six o’clock': 'two o’clock', 'coffee': 'music', 'bag': 'book', 'sister': 'brother', }), ]; AssessmentPack? replacementFor(String packId) => switch (packId) { 'A0-E1' => a0ReplacementPacks[0], 'A0-E2' => a0ReplacementPacks[1], _ => null, }; /// Local checks for the frozen A0 exit tasks. They accept variable names and /// places, but require the communicative information named by each task. bool checkOpenAssessmentAnswer(AssessmentTask task, String input) { final text = input .toLowerCase() .replaceAll('’', "'") .replaceAll('‘', "'") .replaceAll(RegExp(r"\s+"), " ") .trim(); final words = RegExp( r"[a-z]+(?:'[a-z]+)?", ).allMatches(text).map((match) => match.group(0)!).toSet(); bool has(String word) => words.contains( word .toLowerCase() .replaceAll('’', "'") .replaceAll('‘', "'") .trim(), ); bool phrase(String value) { final normVal = value .toLowerCase() .replaceAll('’', "'") .replaceAll('‘', "'") .replaceAll(RegExp(r"\s+"), " ") .trim(); return text.contains(normVal); } 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; final position = int.tryParse( RegExp(r'(\d+)$').firstMatch(task.id)?.group(1) ?? '', ); if (position == null) return false; if (task.skill == AssessmentSkill.writing) { const lessonIds = ['a0-01', 'a0-06', 'a0-09', 'a0-05', 'a0-08']; return position >= 1 && position <= lessonIds.length && WritingFeedback.check(lessonIds[position - 1], input).complete; } if (task.skill != AssessmentSkill.speaking) return false; return switch (position) { 1 => hasAny(['hello', 'hi']) && introduction && has('from') && (has('like') || hasAny(['good', 'okay', 'tired'])) && hasAny(['what', 'where', 'how', 'do']), 2 => RegExp(r'[a-z](?:[ -]?[a-z]){3,}').hasMatch(text), 3 => numberWords >= 3, 4 => phrase('this is my') && hasAny(['mother', 'father', 'sister', 'brother']), 5 => itIs && hasAny([ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', ]), 6 => itIs && (has('clock') || has("o'clock") || has('oclock') || phrase("o'clock")), 7 => has('please') && hasAny(['say', 'speak', 'repeat', 'slow', 'slowly']), 8 => itIs && hasAny(['book', 'pen', 'bag', 'key', 'water']), _ => false, }; } AssessmentPack _replacementPack( AssessmentPack source, String id, Map values, ) { String replace(String value) { var result = value; for (final entry in values.entries) { result = result.replaceAll(entry.key, entry.value); } return result; } return AssessmentPack( id: id, tasks: source.tasks .map( (task) => AssessmentTask( id: task.id.replaceFirst( source.id.split('-').last, id.split('-').last, ), skill: task.skill, prompt: replace(task.prompt), audio: task.audio == null ? null : replace(task.audio!), choices: task.choices.map(replace).toList(), answerIndex: task.answerIndex, requiredWords: task.requiredWords.map(replace).toList(), abilityId: task.abilityId, ), ) .toList(), ); } List _tasks( String version, List listening, List reading, List writing, List speaking, ) { AssessmentTask choice(String raw, AssessmentSkill skill, int index) { final fields = raw.split('|'); return AssessmentTask( id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}', skill: skill, audio: skill == AssessmentSkill.listening ? fields[0] : null, prompt: skill == AssessmentSkill.listening ? '选择正确答案' : fields[0], choices: skill == AssessmentSkill.listening ? fields.sublist(1, 4) : fields.sublist(1, 3), answerIndex: int.parse(fields.last), abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}', ); } AssessmentTask open(String raw, AssessmentSkill skill, int index) { final fields = raw.split('|'); return AssessmentTask( id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}', skill: skill, prompt: fields[0], requiredWords: fields[1].split(','), abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}', ); } return [ ...List.generate( listening.length, (i) => choice(listening[i], AssessmentSkill.listening, i), ), ...List.generate( reading.length, (i) => choice(reading[i], AssessmentSkill.reading, i), ), ...List.generate( writing.length, (i) => open(writing[i], AssessmentSkill.writing, i), ), ...List.generate( speaking.length, (i) => open(speaking[i], AssessmentSkill.speaking, i), ), ]; }