feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user