@@ -1,8 +1,13 @@
import ' course_pack.dart ' ;
/// A produced core item as the registries see it: the level of the pack that
/// teaches it and its English and Chinese forms.
typedef RegisteredCoreItem = ( { String level , String en , String zh } ) ;
/// A produced core item as the registries see it: the pack that teaches it,
/// its level, and its English and Chinese forms.
typedef RegisteredCoreItem = ( {
String level ,
String unit ,
String en ,
String zh ,
} ) ;
/// Every produced core item across A0– B1, in course order, filled from the
/// packs' `coreItems` by `CourseRepository`. IDs are separate from the
@@ -21,6 +26,192 @@ Map<String, String> get a0CoreItems => {
if ( entry . value . level = = ' A0 ' ) entry . key: entry . value . en ,
} ;
/// A recognition-only word as the registries see it. Learning engine 3.5
/// keeps these apart from produced core items: they are heard and read, never
/// required to be said or spelled, and they do not count toward a level's
/// upgrade denominator.
typedef RegisteredReceptiveWord = ( {
String level ,
String unit ,
String en ,
String zh ,
String ipa ,
} ) ;
final Map < String , RegisteredReceptiveWord > receptiveWordRegistry =
< String , RegisteredReceptiveWord > { } ;
void registerReceptiveWord (
ReceptiveWord word , {
required String level ,
required String unit ,
} ) {
receptiveWordRegistry [ word . id ] = (
level: level ,
unit: unit ,
en: word . en ,
zh: word . zh ,
ipa: ' ' ,
) ;
}
/// Registers a word saved from lookup. It has no unit, so its distractors
/// come from the same level.
void registerSavedWord (
String id , {
required String en ,
required String zh ,
required String level ,
} ) {
receptiveWordRegistry [ id ] = (
level: level ,
unit: ' ' ,
en: en ,
zh: zh ,
ipa: ' ' ,
) ;
}
/// Registers one word of the standalone word bank. Unlike a unit's receptive
/// words, these do not wait for a lesson to be finished.
void registerBankWord (
String id , {
required String en ,
required String zh ,
required String ipa ,
required String level ,
required String unit ,
} ) {
receptiveWordRegistry [ id ] = (
level: level ,
unit: unit ,
en: en ,
zh: zh ,
ipa: ipa ,
) ;
}
/// The IPA of a word, empty when none is known.
String wordIpa ( String id ) = > receptiveWordRegistry [ id ] ? . ipa ? ? ' ' ;
/// Whether [id] is a recognition-only word.
bool isReceptiveWord ( String id ) = > receptiveWordRegistry . containsKey ( id ) ;
/// The English of a word of either layer.
String wordEnglish ( String id ) = >
receptiveWordRegistry [ id ] ? . en ? ? coreItemEnglish ( id ) ;
/// The Chinese of a word of either layer.
String wordMeaning ( String id ) = >
receptiveWordRegistry [ id ] ? . zh ? ? coreItemMeaning ( id ) ;
/// The unit that teaches [id], in either layer; empty when nothing does.
String wordUnit ( String id ) = >
receptiveWordRegistry [ id ] ? . unit ? ? coreItemRegistry [ id ] ? . unit ? ? ' ' ;
/// The receptive words a unit teaches, in pack order.
List < String > receptiveWordsOfUnit ( String unit ) = > [
for ( final entry in receptiveWordRegistry . entries )
if ( entry . value . unit = = unit ) entry . key ,
] ;
/// Wrong answers for a receptive word's recognition question, as word ids,
/// from the same unit first, then the same level (learning engine 3.5:
/// distractors come from the same unit or from near meanings). The caller
/// takes the meaning or the spelling off each, depending on which way round
/// it is asking.
///
/// Words whose meaning overlaps the target's are skipped, and no two options
/// share a meaning, so a question never has two right answers. That matters
/// most when the choice is between spellings: the meaning on screen is then
/// the only thing to go on.
///
/// A word saved from lookup can be the only one of its level, so anything else
/// in the registry is taken rather than leaving the word unaskable.
List < String > receptiveDistractors ( String id , { int count = 2 } ) {
final target = receptiveWordRegistry [ id ] ;
if ( target = = null ) return const [ ] ;
final sameUnit = < String > [ ] ;
final sameLevel = < String > [ ] ;
final rest = < String > [ ] ;
final taken = < String > { } ;
for ( final entry in receptiveWordRegistry . entries ) {
if ( entry . key = = id ) continue ;
final option = entry . value . zh . trim ( ) ;
if ( option . isEmpty | | entry . value . en . trim ( ) . isEmpty ) continue ;
if ( option = = target . zh . trim ( ) ) continue ;
if ( option . contains ( target . zh ) | | target . zh . contains ( option ) ) continue ;
if ( ! taken . add ( option ) ) continue ;
if ( entry . value . unit = = target . unit ) {
sameUnit . add ( entry . key ) ;
} else if ( entry . value . level = = target . level ) {
sameLevel . add ( entry . key ) ;
} else if ( rest . length < count ) {
rest . add ( entry . key ) ;
}
}
return [ . . . sameUnit , . . . sameLevel , . . . rest ] . take ( count ) . toList ( ) ;
}
/// Which way round the word is asked.
///
/// Learning engine 3.5 counts 「听辨或阅读」 as recognition, and a word that
/// can only be recognised in writing is half learned — spoken English arrives
/// as sound. [recall] goes the other way, from the meaning to the word; it
/// stays a choice among spellings rather than something to write, so the word
/// is still only ever recognised and never enters the production ladder.
enum WordAskMode { read , listen , recall }
/// A 5– 10 second recognition question for a receptive word (learning engine
/// 3.5).
///
/// [shown] is the prompt: the English for [WordAskMode.read], the Chinese for
/// [WordAskMode.recall]. A [WordAskMode.listen] question carries the English
/// too — it is what the synthesiser speaks and what the feedback shows — and
/// only hides it while the question is open. The English is always available
/// from the id through [wordEnglish], which is what the page speaks.
typedef WordQuestion = ( {
String id ,
String shown ,
String answer ,
List < String > options ,
WordAskMode mode ,
} ) ;
/// Null when the word has no distractors to offer, which means it cannot be
/// asked as a choice yet.
WordQuestion ? receptiveQuestion (
String id , {
int variant = 0 ,
WordAskMode mode = WordAskMode . read ,
} ) {
final word = receptiveWordRegistry [ id ] ;
if ( word = = null | | word . en . isEmpty | | word . zh . isEmpty ) return null ;
final wrong = receptiveDistractors ( id , count: 3 ) ;
if ( wrong . isEmpty ) return null ;
// A different variant asks the same word against other meanings, which is
// what 「换题、换语境」 means for a word this small.
final rotated = [ . . . wrong . skip ( variant % wrong . length ) , . . . wrong . take ( variant % wrong . length ) ]
. take ( 2 )
. toList ( ) ;
// Asked from the meaning, the options are spellings and the prompt is the
// Chinese; asked either other way, it is the other way round.
final asksSpelling = mode = = WordAskMode . recall ;
String optionOf ( String other ) = > asksSpelling
? receptiveWordRegistry [ other ] ! . en
: receptiveWordRegistry [ other ] ! . zh ;
final answer = asksSpelling ? word . en : word . zh ;
final options = [ answer , . . . rotated . map ( optionOf ) ] ;
final offset = ( id . hashCode . abs ( ) + variant ) % options . length ;
return (
id: id ,
shown: asksSpelling ? word . zh : word . en ,
answer: answer ,
options: [ . . . options . skip ( offset ) , . . . options . take ( offset ) ] ,
mode: mode ,
) ;
}
class CoreReviewTemplate {
const CoreReviewTemplate ( {
required this . prompt ,
@@ -43,9 +234,23 @@ final Map<String, String> coreReviewHints = <String, String>{};
final Map < String , DictationSentence > coreDictationSentences =
< String , DictationSentence > { } ;
/// Registers [item], taught at [level], with its review data.
void registerCoreItem ( CoreItem item , { required String level } ) {
coreItemRegistry [ item . id ] = ( level: level , en: item . en , zh: item . zh ) ;
/// How to use each core item that needs explaining, straight from the pack's
/// `usage`. Items whose meaning is the whole story (`book`, `Monday`) have no
/// entry.
final Map < String , CoreUsage > coreUsages = < String , CoreUsage > { } ;
/// Registers [item], taught in [unit] at [level], with its review data.
void registerCoreItem (
CoreItem item , {
required String level ,
String unit = ' ' ,
} ) {
coreItemRegistry [ item . id ] = (
level: level ,
unit: unit ,
en: item . en ,
zh: item . zh ,
) ;
coreItemMatch [ item . id ] = item . match ;
coreReviewTemplates [ item . id ] = CoreReviewTemplate (
prompt: item . reviewPrompt . isNotEmpty
@@ -55,6 +260,7 @@ void registerCoreItem(CoreItem item, {required String level}) {
skill: item . type = = ' word ' ? ' 词汇回忆 ' : ' 回忆表达 ' ,
) ;
if ( item . reviewHint . isNotEmpty ) coreReviewHints [ item . id ] = item . reviewHint ;
if ( ! item . usage . isEmpty ) coreUsages [ item . id ] = item . usage ;
if ( item . dictationSentence . isNotEmpty ) {
coreDictationSentences [ item . id ] = (
sentence: item . dictationSentence ,
@@ -70,6 +276,8 @@ void clearCoreItems() {
coreReviewTemplates . clear ( ) ;
coreReviewHints . clear ( ) ;
coreDictationSentences . clear ( ) ;
coreUsages . clear ( ) ;
receptiveWordRegistry . clear ( ) ;
}
/// The label a learner sees for a core item, falling back to its id. A0
@@ -85,6 +293,12 @@ String coreItemLabel(String id) {
/// English use this.
String coreItemEnglish ( String id ) = > coreItemRegistry [ id ] ? . en ? ? id ;
/// The item as a learner would say it. A pattern is registered with its slot
/// showing — `I'm [state].` — which is right for a matching rule and wrong to
/// read, so the pack's own example sentence stands in when there is one.
String coreItemSpoken ( String id ) = >
coreDictationSentences [ id ] ? . sentence ? ? coreItemEnglish ( id ) ;
/// Whether [id] is a produced core item the course tracks for mastery.
bool isCoreItem ( String id ) = > coreItemRegistry . containsKey ( id ) ;
@@ -121,6 +335,138 @@ CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
const dictationSkill = ' 听写 ' ;
const spokenRecallSkill = ' 口头回忆 ' ;
/// The skill of the recognition question asked before the first checkpoint
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
const recognitionGateSkill = ' 听辨识别 ' ;
/// The skill of a contrast question: a situation is described and the learner
/// picks which taught sentence it calls for. It shows the learner knows *when*
/// to say something, which is not the same as being able to produce it, so it
/// counts as recognition evidence and never advances a checkpoint on its own.
const contrastSkill = ' 情境辨析 ' ;
/// Whether two core items are taught in the same unit.
bool _sameUnit ( String a , String b ) {
final unit = coreItemRegistry [ a ] ? . unit ? ? ' ' ;
return unit . isNotEmpty & & unit = = coreItemRegistry [ b ] ? . unit ;
}
/// The confusables of [id] worth showing a learner now. A pack may pair an
/// item with one taught several units later — `What's your name?` against
/// `Where are you from?` — and naming a sentence the learner has never seen
/// explains nothing, so a pairing only appears once the other item is in the
/// same unit or has been taught. Passing no [isTaught] keeps them all, which
/// is what content checks want.
List < Confusable > visibleConfusables (
String id , {
bool Function ( String id ) ? isTaught ,
} ) = > [
for ( final other in coreUsages [ id ] ? . confuse ? ? const < Confusable > [ ] )
if ( coreItemRegistry . containsKey ( other . id ) & &
( isTaught = = null | | _sameUnit ( id , other . id ) | | isTaught ( other . id ) ) )
other ,
] ;
/// How to use [id], or null when the pack explains nothing about it.
CoreUsage ? coreUsage ( String id ) = > coreUsages [ id ] ;
/// A contrast question built from an item's `confuse` list: the situation from
/// its own `usage.when`, the taught sentences as options, and the note that
/// explains the difference after an answer.
typedef ContrastQuestion = ( {
String situation ,
String answer ,
List < String > options ,
String note ,
} ) ;
ContrastQuestion ? coreContrastQuestion (
String id , {
bool Function ( String id ) ? isTaught ,
} ) {
final usage = coreUsages [ id ] ;
if ( usage = = null | | usage . when . isEmpty ) return null ;
final answer = coreItemSpoken ( id ) ;
final notes = < String > [ ] ;
final options = < String > { answer } ;
for ( final other in visibleConfusables ( id , isTaught: isTaught ) ) {
// Two wrong options is already a real choice; a longer list turns telling
// two sentences apart into a reading exercise.
if ( options . length > = 3 ) break ;
final english = coreItemSpoken ( other . id ) ;
if ( english . isEmpty | | english = = answer ) continue ;
options . add ( english ) ;
if ( other . note . isNotEmpty ) notes . add ( other . note ) ;
}
if ( options . length < 2 ) return null ;
final ordered = options . toList ( ) ;
final offset = id . hashCode . abs ( ) % ordered . length ;
return (
situation: usage . when ,
answer: answer ,
options: [ . . . ordered . skip ( offset ) , . . . ordered . take ( offset ) ] ,
note: notes . join ( ' \n ' ) ,
) ;
}
/// What the item means, as a learner would read it: the Chinese form when the
/// pack has one, otherwise the reviewed situation the phrase is used in.
String coreItemMeaning ( String id ) {
final zh = coreItemRegistry [ id ] ? . zh . trim ( ) ? ? ' ' ;
if ( zh . isNotEmpty ) return zh ;
final prompt = coreReviewTemplates [ id ] ? . prompt . trim ( ) ? ? ' ' ;
return prompt . isNotEmpty ? prompt : coreItemEnglish ( id ) ;
}
/// A recognition question: what the learner hears or reads, and what the
/// right option says. Learning engine 3.5 asks for recognition inside a
/// sentence or a situation, so the item's dictation sentence is used whenever
/// the pack has one. That also keeps near-synonyms apart: `hello` and `hi`
/// both mean 你好 on their own, but `Hello, how are you?` and
/// `Hi, what's your name?` do not.
typedef RecognitionQuestion = ( { String shown , String answer } ) ;
RecognitionQuestion coreRecognitionQuestion ( String id ) {
final dictation = coreDictationSentences [ id ] ;
if ( dictation ! = null & & dictation . meaning . trim ( ) . isNotEmpty ) {
return ( shown: dictation . sentence , answer: dictation . meaning ) ;
}
return ( shown: coreItemEnglish ( id ) , answer: coreItemMeaning ( id ) ) ;
}
/// Wrong options for a recognition question, taken from other core items in
/// the same unit (learning engine 3.5: distractors come from the same unit or
/// from near meanings, never from obviously unrelated words). Options are
/// asked in the same form as the answer — sentence against sentence — so the
/// right one cannot be spotted by its length alone.
List < String > recognitionDistractors ( String id , { int count = 2 } ) {
final target = coreItemRegistry [ id ] ;
if ( target = = null ) return const [ ] ;
final question = coreRecognitionQuestion ( id ) ;
final sentenceForm = coreDictationSentences [ id ] ! = null ;
final sameUnit = < String > [ ] ;
final sameLevel = < String > [ ] ;
for ( final entry in coreItemRegistry . entries ) {
if ( entry . key = = id ) continue ;
if ( ( coreDictationSentences [ entry . key ] ! = null ) ! = sentenceForm ) continue ;
final option = coreRecognitionQuestion ( entry . key ) . answer ;
if ( option . isEmpty ) continue ;
// A distractor that contains the answer, or is contained by it, would be
// right too — `嗨,你叫什么名字?` against `你叫什么名字?`.
if ( option . contains ( question . answer ) | | question . answer . contains ( option ) ) {
continue ;
}
if ( target . unit . isNotEmpty & & entry . value . unit = = target . unit ) {
sameUnit . add ( option ) ;
} else if ( entry . value . level = = target . level ) {
sameLevel . add ( option ) ;
}
}
final options = < String > { . . . sameUnit , . . . sameLevel } ;
return options . take ( count ) . toList ( ) ;
}
/// A concrete sentence built only from taught language, used to play a core
/// item in context and to check dictation.
typedef DictationSentence = ( { String sentence , String meaning } ) ;