feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制

This commit is contained in:
shen
2026-09-23 18:33:22 -07:00
parent 6b42b7abc3
commit d740a9e19c
106 changed files with 2933 additions and 845 deletions
+324 -104
View File
@@ -63,50 +63,180 @@ List<List<String>> parseCsv(String text) {
// ------------------------------------------------------------ 词形还原
const _irregular = {
'am': 'be', 'is': 'be', 'are': 'be', 'was': 'be', 'were': 'be',
'been': 'be', 'being': 'be', 'has': 'have', 'had': 'have',
'having': 'have', 'does': 'do', 'did': 'do', 'done': 'do', 'goes': 'go',
'went': 'go', 'gone': 'go', 'awoke': 'awake', 'born': 'bear',
'beaten': 'beat', 'became': 'become', 'began': 'begin', 'begun': 'begin',
'bent': 'bend', 'bit': 'bite', 'bitten': 'bite', 'bled': 'bleed',
'blew': 'blow', 'blown': 'blow', 'broke': 'break', 'broken': 'break',
'brought': 'bring', 'built': 'build', 'burnt': 'burn', 'bought': 'buy',
'caught': 'catch', 'chose': 'choose', 'chosen': 'choose', 'came': 'come',
'dealt': 'deal', 'dug': 'dig', 'drew': 'draw', 'drawn': 'draw',
'dreamt': 'dream', 'drank': 'drink', 'drunk': 'drink', 'drove': 'drive',
'driven': 'drive', 'ate': 'eat', 'eaten': 'eat', 'fell': 'fall',
'fallen': 'fall', 'fed': 'feed', 'felt': 'feel', 'fought': 'fight',
'found': 'find', 'flew': 'fly', 'flown': 'fly', 'forgot': 'forget',
'forgotten': 'forget', 'forgave': 'forgive', 'forgiven': 'forgive',
'froze': 'freeze', 'frozen': 'freeze', 'got': 'get', 'gotten': 'get',
'gave': 'give', 'given': 'give', 'grew': 'grow', 'grown': 'grow',
'hung': 'hang', 'heard': 'hear', 'hid': 'hide', 'hidden': 'hide',
'held': 'hold', 'kept': 'keep', 'knew': 'know', 'known': 'know',
'laid': 'lay', 'led': 'lead', 'learnt': 'learn', 'left': 'leave',
'lent': 'lend', 'lay': 'lie', 'lain': 'lie', 'lit': 'light',
'lost': 'lose', 'made': 'make', 'meant': 'mean', 'met': 'meet',
'paid': 'pay', 'rode': 'ride', 'ridden': 'ride', 'rang': 'ring',
'rung': 'ring', 'rose': 'rise', 'risen': 'rise', 'ran': 'run',
'said': 'say', 'saw': 'see', 'seen': 'see', 'sought': 'seek',
'sold': 'sell', 'sent': 'send', 'shook': 'shake', 'shaken': 'shake',
'shone': 'shine', 'shot': 'shoot', 'shown': 'show', 'sang': 'sing',
'sung': 'sing', 'sank': 'sink', 'sunk': 'sink', 'sat': 'sit',
'slept': 'sleep', 'slid': 'slide', 'spoke': 'speak', 'spoken': 'speak',
'spent': 'spend', 'spilt': 'spill', 'stood': 'stand', 'stole': 'steal',
'stolen': 'steal', 'stuck': 'stick', 'stung': 'sting', 'struck': 'strike',
'swam': 'swim', 'swum': 'swim', 'swung': 'swing', 'took': 'take',
'taken': 'take', 'taught': 'teach', 'tore': 'tear', 'torn': 'tear',
'told': 'tell', 'thought': 'think', 'threw': 'throw', 'thrown': 'throw',
'understood': 'understand', 'woke': 'wake', 'woken': 'wake',
'wore': 'wear', 'worn': 'wear', 'won': 'win', 'wrote': 'write',
'written': 'write', 'children': 'child', 'men': 'man', 'women': 'woman',
'feet': 'foot', 'teeth': 'tooth', 'mice': 'mouse', 'knives': 'knife',
'wives': 'wife', 'lives': 'life', 'leaves': 'leaf', 'shelves': 'shelf',
'halves': 'half', 'better': 'good', 'best': 'good', 'worse': 'bad',
'worst': 'bad', 'further': 'far', 'furthest': 'far', 'farther': 'far',
'less': 'little', 'least': 'little', 'more': 'much', 'most': 'much',
'an': 'a', 'cannot': 'can', 'thanks': 'thank', 'people': 'person',
'mr': 'mr', 'wifi': 'wi-fi',
'am': 'be',
'is': 'be',
'are': 'be',
'was': 'be',
'were': 'be',
'been': 'be',
'being': 'be',
'has': 'have',
'had': 'have',
'having': 'have',
'does': 'do',
'did': 'do',
'done': 'do',
'goes': 'go',
'went': 'go',
'gone': 'go',
'awoke': 'awake',
'born': 'bear',
'beaten': 'beat',
'became': 'become',
'began': 'begin',
'begun': 'begin',
'bent': 'bend',
'bit': 'bite',
'bitten': 'bite',
'bled': 'bleed',
'blew': 'blow',
'blown': 'blow',
'broke': 'break',
'broken': 'break',
'brought': 'bring',
'built': 'build',
'burnt': 'burn',
'bought': 'buy',
'caught': 'catch',
'chose': 'choose',
'chosen': 'choose',
'came': 'come',
'dealt': 'deal',
'dug': 'dig',
'drew': 'draw',
'drawn': 'draw',
'dreamt': 'dream',
'drank': 'drink',
'drunk': 'drink',
'drove': 'drive',
'driven': 'drive',
'ate': 'eat',
'eaten': 'eat',
'fell': 'fall',
'fallen': 'fall',
'fed': 'feed',
'felt': 'feel',
'fought': 'fight',
'found': 'find',
'flew': 'fly',
'flown': 'fly',
'forgot': 'forget',
'forgotten': 'forget',
'forgave': 'forgive',
'forgiven': 'forgive',
'froze': 'freeze',
'frozen': 'freeze',
'got': 'get',
'gotten': 'get',
'gave': 'give',
'given': 'give',
'grew': 'grow',
'grown': 'grow',
'hung': 'hang',
'heard': 'hear',
'hid': 'hide',
'hidden': 'hide',
'held': 'hold',
'kept': 'keep',
'knew': 'know',
'known': 'know',
'laid': 'lay',
'led': 'lead',
'learnt': 'learn',
'left': 'leave',
'lent': 'lend',
'lay': 'lie',
'lain': 'lie',
'lit': 'light',
'lost': 'lose',
'made': 'make',
'meant': 'mean',
'met': 'meet',
'paid': 'pay',
'rode': 'ride',
'ridden': 'ride',
'rang': 'ring',
'rung': 'ring',
'rose': 'rise',
'risen': 'rise',
'ran': 'run',
'said': 'say',
'saw': 'see',
'seen': 'see',
'sought': 'seek',
'sold': 'sell',
'sent': 'send',
'shook': 'shake',
'shaken': 'shake',
'shone': 'shine',
'shot': 'shoot',
'shown': 'show',
'sang': 'sing',
'sung': 'sing',
'sank': 'sink',
'sunk': 'sink',
'sat': 'sit',
'slept': 'sleep',
'slid': 'slide',
'spoke': 'speak',
'spoken': 'speak',
'spent': 'spend',
'spilt': 'spill',
'stood': 'stand',
'stole': 'steal',
'stolen': 'steal',
'stuck': 'stick',
'stung': 'sting',
'struck': 'strike',
'swam': 'swim',
'swum': 'swim',
'swung': 'swing',
'took': 'take',
'taken': 'take',
'taught': 'teach',
'tore': 'tear',
'torn': 'tear',
'told': 'tell',
'thought': 'think',
'threw': 'throw',
'thrown': 'throw',
'understood': 'understand',
'woke': 'wake',
'woken': 'wake',
'wore': 'wear',
'worn': 'wear',
'won': 'win',
'wrote': 'write',
'written': 'write',
'children': 'child',
'men': 'man',
'women': 'woman',
'feet': 'foot',
'teeth': 'tooth',
'mice': 'mouse',
'knives': 'knife',
'wives': 'wife',
'lives': 'life',
'leaves': 'leaf',
'shelves': 'shelf',
'halves': 'half',
'better': 'good',
'best': 'good',
'worse': 'bad',
'worst': 'bad',
'further': 'far',
'furthest': 'far',
'farther': 'far',
'less': 'little',
'least': 'little',
'more': 'much',
'most': 'much',
'an': 'a',
'cannot': 'can',
'thanks': 'thank',
'people': 'person',
'mr': 'mr',
'wifi': 'wi-fi',
};
/// 一个词可能对应的词头(包括它本身),按可能性排序。
@@ -163,13 +293,14 @@ List<String> lemmaCandidates(String word) {
// ------------------------------------------------------------ 分词
String _normalizeQuotes(String text) => text
.replaceAll(RegExp(r'[‘’`´]'), "'")
.replaceAll(RegExp(r'[“”]'), '"');
String _normalizeQuotes(String text) =>
text.replaceAll(RegExp(r'[‘’`´]'), "'").replaceAll(RegExp(r'[“”]'), '"');
const _negations = {
"can't": ['can', 'not'], "won't": ['will', 'not'],
"shan't": ['shall', 'not'], "ain't": ['be', 'not'],
"can't": ['can', 'not'],
"won't": ['will', 'not'],
"shan't": ['shall', 'not'],
"ain't": ['be', 'not'],
};
/// 把文本拆成词。数字、带数字的词被跳过;缩写展开;连字符词保留整体,
@@ -208,7 +339,11 @@ List<String> tokenize(String text) {
if (apostrophe != null) {
words.add(apostrophe[1]!);
const expansions = {
'm': 'am', 're': 'are', 'll': 'will', 've': 'have', 'd': 'would',
'm': 'am',
're': 'are',
'll': 'will',
've': 'have',
'd': 'would',
};
final expansion = expansions[apostrophe[2]];
if (expansion != null) words.add(expansion);
@@ -234,8 +369,7 @@ String normalizeForMatch(String text) {
.replaceAll("'re", ' are')
.replaceAll("'ll", ' will')
.replaceAll("'ve", ' have')
.replaceAll("'d", ' would')
;
.replaceAll("'d", ' would');
value = value.replaceAllMapped(
RegExp(r"\b(it|that|what|where|here|there|he|she|how|who|when|this)'s\b"),
(match) => '${match[1]} is',
@@ -257,8 +391,15 @@ bool containsPhrase(String normalizedText, String phrase) {
// ------------------------------------------------------------ 词表
const functionPos = {
'pronoun', 'preposition', 'determiner', 'conjunction', 'modal auxiliary',
'be-verb', 'do-verb', 'have-verb', 'infinitive-to',
'pronoun',
'preposition',
'determiner',
'conjunction',
'modal auxiliary',
'be-verb',
'do-verb',
'have-verb',
'infinitive-to',
};
class LexiconEntry {
@@ -297,8 +438,7 @@ class Lexicon {
static List<String> headwordVariants(String headword) => [
for (final part in headword.split('/'))
if (part.trim().isNotEmpty)
part.trim().toLowerCase().replaceAll('.', ''),
if (part.trim().isNotEmpty) part.trim().toLowerCase().replaceAll('.', ''),
];
final Map<String, List<String>> _lemmaCache = {};
@@ -390,15 +530,17 @@ class CourseReport {
// ------------------------------------------------------------ 校验
class CourseValidator {
CourseValidator({
required this.projectRoot,
required Iterable<String> a0Texts,
}) : lexicon = Lexicon.load(
File('${projectRoot.path}/tool/courses/lexicon/cefrj-vocabulary-profile-1.5.csv'),
),
grammarLevels = _loadGrammar(
File('${projectRoot.path}/tool/courses/lexicon/cefrj-grammar-profile-20180315.csv'),
) {
CourseValidator({required this.projectRoot, required Iterable<String> a0Texts})
: lexicon = Lexicon.load(
File(
'${projectRoot.path}/tool/courses/lexicon/cefrj-vocabulary-profile-1.5.csv',
),
),
grammarLevels = _loadGrammar(
File(
'${projectRoot.path}/tool/courses/lexicon/cefrj-grammar-profile-20180315.csv',
),
) {
for (final text in a0Texts) {
for (final word in tokenize(text)) {
a0Words.addAll(lexicon.lemmas(word));
@@ -454,7 +596,8 @@ class CourseValidator {
if (levelId == 'A0') continue;
final functionWords = <String>{...lexicon.functionWordsUpTo(levelId)};
var ready = true;
for (final entry in (level['units'] as List).cast<Map<String, dynamic>>()) {
for (final entry
in (level['units'] as List).cast<Map<String, dynamic>>()) {
final unitId = entry['id'] as String;
final file = File('$coursesDir/${entry['file']}');
if (!file.existsSync()) {
@@ -486,7 +629,9 @@ class CourseValidator {
}
coveredByLevel[levelId] = {...coveredSoFar};
final pool = loadPool(
File('${projectRoot.path}/tool/courses/pools/${levelId.toLowerCase()}-word-pool.csv'),
File(
'${projectRoot.path}/tool/courses/pools/${levelId.toLowerCase()}-word-pool.csv',
),
);
final uncovered = [
for (final word in pool)
@@ -496,11 +641,13 @@ class CourseValidator {
report.coverage[levelId] = coverage;
final minimum = (map['minimumPoolCoverage'] as num?)?.toDouble() ?? 0.85;
if (ready && coverage.ratio < minimum) {
report.errors.add(CourseIssue(
levelId,
'词池覆盖率 ${(coverage.ratio * 100).toStringAsFixed(1)}% 低于 '
'${(minimum * 100).round()}%(${coverage.covered}/${coverage.poolSize})',
));
report.errors.add(
CourseIssue(
levelId,
'词池覆盖率 ${(coverage.ratio * 100).toStringAsFixed(1)}% 低于 '
'${(minimum * 100).round()}%(${coverage.covered}/${coverage.poolSize})',
),
);
}
}
return report;
@@ -607,7 +754,8 @@ class _UnitContext {
bool _isKnown(String word) {
bool single(String value) =>
validator.lexicon.lemmas(value).any(known.contains) || value.length == 1;
validator.lexicon.lemmas(value).any(known.contains) ||
value.length == 1;
if (single(word)) return true;
return word.contains('-') && word.split('-').every(single);
}
@@ -641,7 +789,8 @@ class _UnitContext {
...allowed,
...taughtBefore,
...unitTaught,
for (final name in (unit['names'] as List? ?? const [])) '$name'.toLowerCase(),
for (final name in (unit['names'] as List? ?? const []))
'$name'.toLowerCase(),
};
_itemLevels();
final usage = <String, Set<String>>{
@@ -673,8 +822,11 @@ class _UnitContext {
_addWords(textWords, texts.join('\n'));
for (final MapEntry(:key, :value) in receptive.entries) {
final words = tokenize(value['en'] as String? ?? '');
final present = words.isNotEmpty &&
words.every((word) => validator.lexicon.lemmas(word).any(textWords.contains));
final present =
words.isNotEmpty &&
words.every(
(word) => validator.lexicon.lemmas(word).any(textWords.contains),
);
if (!present) error(key, '理解词 ${value['en']} 没有出现在本单元的听读或场景文本中');
}
}
@@ -742,11 +894,13 @@ class _UnitContext {
error(id, '缺少 review');
} else {
final prompt = _string(review, 'prompt', '$id.review');
if (en.isNotEmpty && normalizeForMatch(prompt).contains(normalizeForMatch(en))) {
if (en.isNotEmpty &&
normalizeForMatch(prompt).contains(normalizeForMatch(en))) {
error(id, 'review.prompt 不能直接给出英文答案');
}
}
if (item['sceneWord'] == true && '${item['sceneReason'] ?? ''}'.trim().isEmpty) {
if (item['sceneWord'] == true &&
'${item['sceneReason'] ?? ''}'.trim().isEmpty) {
error(id, '场景词须写 sceneReason');
}
}
@@ -775,7 +929,8 @@ class _UnitContext {
_string(word, 'zh', id);
_addWords(unitTaught, en);
_addWords(unitCovered, en);
if (word['sceneWord'] == true && '${word['sceneReason'] ?? ''}'.trim().isEmpty) {
if (word['sceneWord'] == true &&
'${word['sceneReason'] ?? ''}'.trim().isEmpty) {
error(id, '场景词须写 sceneReason');
}
}
@@ -786,11 +941,12 @@ class _UnitContext {
if (value is List &&
value.isNotEmpty &&
value.every(
(group) => group is List && group.isNotEmpty && group.every((term) => term is String),
(group) =>
group is List &&
group.isNotEmpty &&
group.every((term) => term is String),
)) {
return [
for (final group in value) (group as List).cast<String>(),
];
return [for (final group in value) (group as List).cast<String>()];
}
error(id, 'match 必须是非空的字符串数组的数组');
return const [];
@@ -806,8 +962,7 @@ class _UnitContext {
validator.lexicon.lemmas(word).any(taughtBefore.contains)) {
continue;
}
final parts = word.contains('-') &&
validator.lexicon.lookup(word) == null
final parts = word.contains('-') && validator.lexicon.lookup(word) == null
? word.split('-')
: [word];
for (final part in parts) {
@@ -824,7 +979,8 @@ class _UnitContext {
for (final MapEntry(:key, :value) in core.entries) {
final computed = _itemLevel('${value['en']}');
levels[key] = computed;
if (levelRank(computed) > levelRank(levelId) && value['sceneWord'] != true) {
if (levelRank(computed) > levelRank(levelId) &&
value['sceneWord'] != true) {
error(key, '${value['en']} 按 CEFR-J 为 $computed,高于 $levelId;须改写或标为场景词');
}
}
@@ -841,13 +997,19 @@ class _UnitContext {
}
final computed = _itemLevel('${value['en']}');
levels[key] = computed;
if (levelRank(computed) > levelRank(limit) && value['sceneWord'] != true) {
if (levelRank(computed) > levelRank(limit) &&
value['sceneWord'] != true) {
error(key, '${value['en']} 按 CEFR-J 为 $computed,高于 $limit;须换词或标为场景词');
}
}
}
void _checkText(String where, String text, {Map<String, dynamic>? glosses, double tolerance = 0}) {
void _checkText(
String where,
String text, {
Map<String, dynamic>? glosses,
double tolerance = 0,
}) {
final words = tokenize(text);
final unknown = <String>[];
for (final word in words) {
@@ -857,7 +1019,9 @@ class _UnitContext {
final glossed = {
for (final key in (glosses ?? const {}).keys) key.toLowerCase(),
};
final missingGloss = unknown.where((word) => !glossed.contains(word)).toSet();
final missingGloss = unknown
.where((word) => !glossed.contains(word))
.toSet();
if (tolerance == 0) {
error(where, '未教的词:${unknown.toSet().join(', ')}');
return;
@@ -882,14 +1046,36 @@ class _UnitContext {
return ids;
}
void _checkOptions(String where, Map<String, dynamic> json, {String? sourceText}) {
void _checkActivityItemIds(
Map<String, dynamic> activity,
String where,
List<String> segmentItemIds,
) {
final refs = _strings(activity, 'itemIds', where);
if (refs.toSet().length != refs.length) error(where, 'itemIds 不能重复');
for (final ref in refs) {
if (!segmentItemIds.contains(ref)) {
error(where, 'itemIds $ref 必须属于本教学段');
}
}
}
void _checkOptions(
String where,
Map<String, dynamic> json, {
String? sourceText,
}) {
final options = _strings(json, 'options', where);
if (options.length != 3) {
error(where, '必须有 3 个选项(第一个为正确答案)');
return;
}
final normalized = [for (final option in options) normalizeForMatch(option)];
final english = options.every((option) => RegExp(r'^[A-Za-z0-9 ,.?!’\x27-]+$').hasMatch(option));
final normalized = [
for (final option in options) normalizeForMatch(option),
];
final english = options.every(
(option) => RegExp(r'^[A-Za-z0-9 ,.?!’\x27-]+$').hasMatch(option),
);
for (var i = 0; i < options.length; i++) {
if (options[i].trim().isEmpty) error(where, '选项不能为空');
for (var j = 0; j < options.length; j++) {
@@ -908,9 +1094,12 @@ class _UnitContext {
}
if (sourceText != null) {
final text = normalizeForMatch(sourceText);
final present = normalized.where((option) => containsPhrase(text, option)).length;
final present = normalized
.where((option) => containsPhrase(text, option))
.length;
if (present < 2) error(where, '英文选项至少两个要在原文里出现');
if (!containsPhrase(text, normalized.first)) error(where, '正确选项没有在原文中出现');
if (!containsPhrase(text, normalized.first))
error(where, '正确选项没有在原文中出现');
}
}
}
@@ -922,7 +1111,8 @@ class _UnitContext {
}
for (final (index, segment) in segments.indexed) {
final id = '${segment['id']}';
if (id != '$unitId-S${index + 1}') error('segments[$index]', 'ID 应为 $unitId-S${index + 1}');
if (id != '$unitId-S${index + 1}')
error('segments[$index]', 'ID 应为 $unitId-S${index + 1}');
_registerId(id, 'segments[$index]');
_string(segment, 'title', id);
final minutes = segment['minutes'];
@@ -942,6 +1132,7 @@ class _UnitContext {
_string(listening, 'question', '$id.listening');
_checkText('$id.listening.text', text);
_checkOptions('$id.listening', listening);
_checkActivityItemIds(listening, '$id.listening', itemIds);
practice.add(text);
} else {
error(id, '缺少 listening');
@@ -951,6 +1142,20 @@ class _UnitContext {
for (final (i, line) in speaking.indexed) {
final text = _string(line, 'text', '$id.speaking[$i]');
_checkText('$id.speaking[$i]', text);
if (i == 0) _checkActivityItemIds(line, '$id.speaking[$i]', itemIds);
if (line['chunks'] case final List chunks) {
final parts = chunks
.whereType<String>()
.map((s) => s.trim())
.toList();
if (parts.length != chunks.length ||
parts.length < 2 ||
parts.any((s) => s.isEmpty) ||
parts.join(' ').replaceAll(RegExp(r'\s+'), ' ') !=
text.trim().replaceAll(RegExp(r'\s+'), ' ')) {
error(id, 'speaking[$i].chunks 拼接后必须等于完整句子');
}
}
practice.add(text);
}
final reading = segment['reading'];
@@ -1000,17 +1205,20 @@ class _UnitContext {
void _scenes(Map<String, Set<String>> usage, List<String> texts) {
final scenes = _objects(unit, 'scenes', 'scenes');
if (scenes.length < 3 || scenes.length > 4) error('scenes', '应为 1 个主场景加 2–3 个变式场景');
if (scenes.length < 3 || scenes.length > 4)
error('scenes', '应为 1 个主场景加 2–3 个变式场景');
if (scenes.where((scene) => scene['kind'] == 'main').length != 1) {
error('scenes', '必须恰好有 1 个 kind=main 的主场景');
}
final sceneIds = <String>{};
for (final (index, scene) in scenes.indexed) {
final id = '${scene['id']}';
if (id != '$unitId-C${index + 1}') error('scenes[$index]', 'ID 应为 $unitId-C${index + 1}');
if (id != '$unitId-C${index + 1}')
error('scenes[$index]', 'ID 应为 $unitId-C${index + 1}');
_registerId(id, 'scenes[$index]');
sceneIds.add(id);
if (!{'main', 'variant'}.contains(scene['kind'])) error(id, 'kind 必须是 main/variant');
if (!{'main', 'variant'}.contains(scene['kind']))
error(id, 'kind 必须是 main/variant');
for (final key in ['title', 'learnerRole', 'aiRole', 'setting']) {
_string(scene, key, id);
}
@@ -1042,7 +1250,8 @@ class _UnitContext {
final tasked = <String>{};
for (final (index, task) in tasks.indexed) {
final id = '${task['id']}';
if (id != '$unitId-T${index + 1}') error('tasks[$index]', 'ID 应为 $unitId-T${index + 1}');
if (id != '$unitId-T${index + 1}')
error('tasks[$index]', 'ID 应为 $unitId-T${index + 1}');
_registerId(id, 'tasks[$index]');
_string(task, 'goal', id);
final sceneId = '${task['sceneId']}';
@@ -1060,19 +1269,28 @@ class _UnitContext {
void _materials(List<String> texts) {
final spec = level['materials'];
final materials = _objects(unit, 'materials', 'materials', required: spec != null);
final materials = _objects(
unit,
'materials',
'materials',
required: spec != null,
);
if (spec is! Map<String, dynamic>) {
if (materials.isNotEmpty) error('materials', '$levelId 不安排独立听读材料');
return;
}
final count = (spec['count'] as List).cast<int>();
if (materials.length < count[0] || materials.length > count[1]) {
error('materials', '听读材料 ${materials.length} 篇,应为 ${count[0]}–${count[1]}');
error(
'materials',
'听读材料 ${materials.length} 篇,应为 ${count[0]}–${count[1]}',
);
}
final modes = <String>{};
for (final (index, material) in materials.indexed) {
final id = '${material['id']}';
if (id != '$unitId-M${index + 1}') error('materials[$index]', 'ID 应为 $unitId-M${index + 1}');
if (id != '$unitId-M${index + 1}')
error('materials[$index]', 'ID 应为 $unitId-M${index + 1}');
_registerId(id, 'materials[$index]');
_string(material, 'title', id);
final mode = material['mode'];
@@ -1099,7 +1317,8 @@ class _UnitContext {
);
texts.add(text);
final questions = _objects(material, 'questions', id);
if (questions.length < 2 || questions.length > 4) error(id, '理解题应为 2–4 道');
if (questions.length < 2 || questions.length > 4)
error(id, '理解题应为 2–4 道');
for (final (i, question) in questions.indexed) {
final where = '$id.questions[$i]';
_string(question, 'question', where);
@@ -1109,7 +1328,8 @@ class _UnitContext {
_checkOptions(where, question);
}
}
if (materials.isNotEmpty && modes.length < 2) error('materials', '听和读的材料都要有');
if (materials.isNotEmpty && modes.length < 2)
error('materials', '听和读的材料都要有');
final rubric = unit['rubric'];
final rubricRequired = level['openTaskRubric'] == true;