feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
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<String> choices;
final int? answerIndex;
final List<String> requiredWords;
final String abilityId;
}
class AssessmentPack {
const AssessmentPack({required this.id, required this.tasks});
final String id;
final List<AssessmentTask> tasks;
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
tasks.where((task) => task.skill == skill).toList();
}
final a0AssessmentPacks = [
AssessmentPack(
id: 'A0-E1',
tasks: _tasks(
'A',
const [
'Hello. Im Mia.|自我介绍|买东西|问时间|0',
'How do you spell your name?|问姓名拼写|问地点|问时间|0',
'Im tired today.|很好|累|来自哪里|1',
'seven|6|7|8|1',
'one-three-eight|138|183|318|0',
'Its a key.|书|钥匙|包|1',
'Im from Hong Kong.|香港|伦敦|北京|0',
'This is my mother.|妈妈|姐姐|朋友|0',
'It is Monday.|星期一|星期二|星期三|0',
'I like tea.|茶|咖啡|电影|0',
],
const [
'Mia: Im from Hong Kong.\nShen: Im good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0',
'Mia: It is Monday.\nShen: It is three oclock.|几点?|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: Whats this?\nShen: Its 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',
'Im okay.|很好|还可以|累|1',
'four|3|4|5|1',
'two-nine-six|296|269|926|0',
'Its a bag.|笔|包|水|1',
'Im from London.|伦敦|香港|北京|0',
'This is my sister.|姐姐|妈妈|朋友|0',
'It is Friday.|星期五|星期一|星期日|0',
'I like coffee.|茶|咖啡|音乐|1',
],
const [
'Mia: Im from London.\nLeo: Im okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0',
'Mia: It is Friday.\nLeo: It is six oclock.|几点?|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: Whats this?\nLeo: Its 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 oclock': 'six oclock',
'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 oclock': 'two oclock',
'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('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool phrase(String value) => text.contains(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 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<String, String> 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<AssessmentTask> _tasks(
String version,
List<String> listening,
List<String> reading,
List<String> writing,
List<String> 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),
),
];
}