feat: 独立词库、三向认词与当日快闪复习
## 独立词库 理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词, 不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json (2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1, 完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。 数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、 公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。 词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用; 若要分发或上架,须替换音标那一列。 ## 背单词机制 - 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。 原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。 - 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。 阶梯第一级是次日,今天引入的新词就是明天的工作量。 - 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。 - 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。 想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。 - 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。 ## 用法对照 课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、 对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。 知道 How are you? 的意思,不等于知道它不是用来问名字的。 ## 复习流 - 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。 - 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。 - 答错的项隔几题后回来,而不是立刻重问。 测试 296 通过,flutter analyze 干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,8 +178,16 @@ AI 对话中的词条必须来自当前允许语言范围,或是事先标为
|
||||
| 产出核心(会用) | 单元内容包中需要主动说、写的词、词块和句型;每单元 10–15 项 | 3.1–3.2 的五种状态和掌握规则 | 计入 |
|
||||
| 理解词(会认) | 听懂、看懂即可的词;每单元 A1 20–35 项、A2 30–50 项、B1 50–80 项 | 新学 → 认识 → 熟悉 | 不逐项计入;升级时抽查(4.2) |
|
||||
| 扩展词 | 听读材料中查过或多次出现、由用户加入复习的词 | 同理解词 | 不计入,也不参与升级抽查 |
|
||||
| 词库词 | 独立词库中该等级的词,不依赖课程进度 | 同理解词 | 不计入,也不参与升级抽查 |
|
||||
|
||||
**学习流程**:单词不单独背,跟着单元场景学。
|
||||
**独立词库**:理解词只能跟着单元走,学完 A0 十课词汇量也只增加约 22 个实词,
|
||||
不足以解决“记不住单词”。因此另设一份独立词库(`assets/words/wordbank.json`),
|
||||
词表范围和等级由 CEFR-J Vocabulary Profile 决定,从第一天起全部可用。
|
||||
新词按**口语频率**发放(同等级内从最常用的开始),不按字母序。
|
||||
词库词与理解词共用同一套状态机和间隔阶梯,同样不进产出核心的升级分母。
|
||||
数据来源与许可见 `kouyu_english/tool/words/DATA-NOTE.md`。
|
||||
|
||||
**学习流程**:会用的词不单独背,跟着单元场景学;会认的词另有独立入口。
|
||||
|
||||
1. 会用的词:预热(只记“见过”)→ 在场景句中听辨 → 说、读、写的替换练习 → 受控对话中用出来 → 独立尝试 → 按 3.1 间隔复习,每次复习换语境(如从地铁站换到火车站)。
|
||||
2. 会认的词:不进预热,直接出现在教学段的听读材料里,首次出现可点开释义;教学段结束时做 2–3 道快速识别题,之后按下述理解词规则复习。
|
||||
@@ -195,9 +203,29 @@ AI 对话中的词条必须来自当前允许语言范围,或是事先标为
|
||||
|
||||
理解词规则:
|
||||
|
||||
- 一次独立的听辨或阅读识别成功进入“认识”;至少间隔 7 天后,换题、换语境再次独立识别成功,进入“熟悉”,之后每 30 天抽查。
|
||||
- 识别失败回到“认识”,次日换题复核;不做诊断降级。
|
||||
- 一次独立的听辨或阅读识别成功进入“认识”。
|
||||
- 之后按间隔阶梯复习,每连续答对一次上一级:**1 → 3 → 7 → 15 → 30 → 60 → 120 天**,到 120 天封顶。
|
||||
第四次连续答对且距首次“认识”已满 7 天,进入“熟悉”。最快需要 1+3+7=11 天,
|
||||
满足“至少间隔 7 天后再次识别成功”的要求。每次复习换题、换语境。
|
||||
- 识别失败回到阶梯第一级,次日换题复核;状态最多回到“认识”,不做诊断降级。
|
||||
- **三个方向轮转**:新词第一次、以及答错后的次日复核,用看词题(英→中)——
|
||||
没见过拼写就先听音或先回想,都只是一串没有落点的声音。从第一次答对起按阶梯层级轮转:
|
||||
**看词(英→中)→ 听词(音→中)→ 想词(中→英)**,每个词三条通道都要过。
|
||||
只在纸面上认得的词,在口语里听不出来;只认得出、想不起来的词,用的时候不在手边。
|
||||
- 听词题只放音、不显示拼写,答完才一并给出拼写和音标,那一刻才是音与形对上的地方。
|
||||
想词题给中文、从几个**拼写**里选,答完前不放音(放了就是念答案)。
|
||||
- 想词题**仍然是识别**,不是产出:从选项里认出来,不要求自己拼写或说出。
|
||||
因此理解词的定位不变 —— 不计入产出核心的升级分母,也不走掌握五态。
|
||||
选项之间互不同义(释义重合的词不作干扰项),否则一道题会有两个正确答案。
|
||||
- **每日新词上限**:按每日时长设定,10 分钟 8 个、20 分钟 15 个、30 分钟 20 个。
|
||||
阶梯第一级是次日,今天引入的新词就是明天的工作量;没有上限时一次可以引入上百个词,
|
||||
第二天全部到期砸回来,正是“记不住”的成因。上限只限制**新引入**,
|
||||
到期复习和已引入但未作答的词不受限,中途退出的一轮下次可以接着做完。
|
||||
- 复习以 5–10 秒的快速识别题为主,可以成组出现,占复习预算不超过三分之一。
|
||||
该上限约束的是插进复习流的识别题;用户主动打开的单词页不受此限。
|
||||
- **入口**:单词页有独立 tab,同时在首页今日任务卡下方给一张认词卡,显示今天该过几个词、
|
||||
预计几分钟。认词与今日任务是并行的,不参与“今天最该做的一件事”的排序,
|
||||
所以它单独占一行而不抢主卡;今天没题时缩成一行状态,但入口始终在。
|
||||
- 在听读材料中答对与某个理解词直接相关的理解题,可计一次该词的识别证据。
|
||||
- 后续单元把某个理解词列为产出核心时,保留已有识别证据,从“认识”开始走完整掌握流程。
|
||||
|
||||
|
||||
@@ -77,6 +77,17 @@
|
||||
"sentence": "I’m Tom.",
|
||||
"meaning": "我是 Tom。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "想让对方知道你叫什么,自我介绍的时候说。",
|
||||
"reply": "对方通常回一句 Nice to meet you.",
|
||||
"swap": "My name is Tom. 意思一样,正式一点。",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 是问对方,I'm Tom. 是说自己;别人问了你才用这句回答。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,6 +106,24 @@
|
||||
"sentence": "What’s your name?",
|
||||
"meaning": "你叫什么名字?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "你想知道对方叫什么名字。",
|
||||
"reply": "对方会回 I'm Tom. 或 My name is Tom.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 是回答,What's your name? 是提问;想知道对方名字才用这句。"
|
||||
},
|
||||
{
|
||||
"id": "A0-P05",
|
||||
"note": "How are you? 问的是对方今天状态好不好,不是名字;见面先问好,想认识对方才问名字。"
|
||||
},
|
||||
{
|
||||
"id": "A0-P07",
|
||||
"note": "What's your phone number? 问的是号码;两句都以 What's your 开头,区别全在后面那个词。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -112,6 +141,20 @@
|
||||
"sentence": "Nice to meet you.",
|
||||
"meaning": "很高兴认识你。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "刚认识、知道对方名字之后,说一句客气话。",
|
||||
"reply": "对方会回 Nice to meet you, too.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 是告诉对方你叫什么,Nice to meet you. 是知道名字之后才说的客气话。"
|
||||
},
|
||||
{
|
||||
"id": "A0-P05",
|
||||
"note": "How are you? 是在问状态,要回答好不好;Nice to meet you. 只是客气话,不用回答。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -22,6 +22,20 @@
|
||||
"sentence": "How do you spell that?",
|
||||
"meaning": "那个怎么拼写?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "听到一个词(多半是名字),但不确定怎么写。",
|
||||
"reply": "对方会一个字母一个字母念给你听。",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 是问对方叫什么,How do you spell that? 是已经听到了名字、只是不知道怎么写。"
|
||||
},
|
||||
{
|
||||
"id": "A0-P20",
|
||||
"note": "Please say that again. 是没听清、要对方重说;这句是听清了、只是不会写。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -75,6 +75,16 @@
|
||||
"sentence": "How are you?",
|
||||
"meaning": "你怎么样?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "见面打招呼,问对方今天好不好。",
|
||||
"reply": "对方会回 I'm good. / I'm okay. / I'm tired.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 问的是名字;见面先说 How are you? 打招呼,想认识对方才问名字。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -98,6 +108,17 @@
|
||||
"sentence": "I’m tired today.",
|
||||
"meaning": "我今天很累。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人问你 How are you? 的时候,说出你今天的状态。",
|
||||
"reply": "说完可以反问一句 How are you?",
|
||||
"swap": "熟悉的人之间也可以只说 Good. 或 Okay.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 说的是名字,I'm tired. 说的是状态;I'm 后面放什么,看对方问的是哪一个。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -237,6 +237,16 @@
|
||||
"sentence": "What’s your phone number?",
|
||||
"meaning": "你的电话号码是多少?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "你想记下对方的电话号码。",
|
||||
"reply": "对方会回 My number is 123.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 问名字,这句问号码;都以 What's your 开头,听清后面的词再答。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -255,6 +265,16 @@
|
||||
"sentence": "My number is one-three-eight.",
|
||||
"meaning": "我的号码是一三八。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人问你电话号码的时候,把号码报出来。",
|
||||
"reply": "对方多半会复述一遍跟你确认。",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 回答的是名字,My number is 123. 回答的是号码。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"sentence": "What’s this?",
|
||||
"meaning": "这是什么?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "指着身边一样东西,问它叫什么。",
|
||||
"reply": "对方会回 It's a book.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 问的是人叫什么,What's this? 问的是东西是什么。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -118,6 +128,20 @@
|
||||
"sentence": "It’s a pen.",
|
||||
"meaning": "这是一支笔。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人指着一样东西问你那是什么,你来回答。",
|
||||
"reply": "对方可能接着问 How do you spell that?",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P09",
|
||||
"note": "What's this? 是问,It's a book. 是答;别人指着东西问你,才用这句。"
|
||||
},
|
||||
{
|
||||
"id": "A0-P13",
|
||||
"note": "This is my mother. 是把人介绍给别人,It's a book. 是说这东西是什么。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
"sentence": "Where are you from?",
|
||||
"meaning": "你来自哪里?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "想知道对方是哪里人。",
|
||||
"reply": "对方会回 I'm from China.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"note": "What's your name? 问名字,Where are you from? 问地方;Where 开头的一定是在问地点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -43,6 +53,16 @@
|
||||
"sentence": "I’m from China.",
|
||||
"meaning": "我来自中国。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人问 Where are you from? 的时候,说出你来自哪里。",
|
||||
"reply": "对方常接一句 Nice to meet you.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 是名字,I'm from China. 是地方;from 后面跟的一定是地点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
"sentence": "This is my sister.",
|
||||
"meaning": "这是我的姐妹。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "把身边的家人介绍给别人。",
|
||||
"reply": "对方会说 Nice to meet you.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"note": "I'm Tom. 介绍的是你自己,This is my mother. 介绍的是别人。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -147,6 +147,16 @@
|
||||
"sentence": "What day is it today?",
|
||||
"meaning": "今天星期几?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "不确定今天星期几,问别人。",
|
||||
"reply": "对方会回 It's Monday.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P16",
|
||||
"note": "What time is it? 问的是现在几点钟,What day is it today? 问的是今天星期几。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -173,6 +183,16 @@
|
||||
"sentence": "It’s Friday.",
|
||||
"meaning": "今天星期五。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人问今天星期几的时候回答。",
|
||||
"swap": "熟人之间也可以只说 Monday.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P17",
|
||||
"note": "It's Monday. 回答星期,It's three o'clock. 回答钟点;后面跟哪个,看别人问的是什么。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -190,6 +210,16 @@
|
||||
"sentence": "What time is it?",
|
||||
"meaning": "现在几点?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "想知道现在几点钟。",
|
||||
"reply": "对方会回 It's three o'clock.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P14",
|
||||
"note": "What day is it today? 问的是星期几,这句问的是几点钟。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -213,6 +243,15 @@
|
||||
"sentence": "It’s three o’clock.",
|
||||
"meaning": "现在三点。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "别人问几点的时候,报出整点时间。",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P15",
|
||||
"note": "It's Monday. 回答的是星期,It's three o'clock. 回答的是钟点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -130,6 +130,16 @@
|
||||
"sentence": "I like music.",
|
||||
"meaning": "我喜欢音乐。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "说出你喜欢的一样东西。",
|
||||
"reply": "对方可能反问一句 Do you like coffee?",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P19",
|
||||
"note": "I like tea. 是在说自己,Do you like tea? 才是问对方;问对方要把 Do you 放在最前面。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -147,6 +157,16 @@
|
||||
"sentence": "Do you like coffee?",
|
||||
"meaning": "你喜欢咖啡吗?"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "想知道对方喜不喜欢某样东西。",
|
||||
"reply": "对方会回 Yes. 或 No.",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P18",
|
||||
"note": "Do you like tea? 是问对方,I like tea. 是说自己喜欢。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
"sentence": "Please say that again.",
|
||||
"meaning": "请再说一遍。"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"when": "没听清对方说了什么,请他再说一次。",
|
||||
"reply": "对方会把刚才的话重说一遍,通常会慢一点。",
|
||||
"confuse": [
|
||||
{
|
||||
"id": "A0-P04",
|
||||
"note": "How do you spell that? 是听清了但不会写,这句是根本没听清。"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ part 'app_state_assessment.dart';
|
||||
part 'app_state_lesson.dart';
|
||||
part 'app_state_review.dart';
|
||||
part 'app_state_snapshot.dart';
|
||||
part 'app_state_words.dart';
|
||||
|
||||
/// Everything the app persists. Behaviour lives in the domain mixins that
|
||||
/// [AppState] composes.
|
||||
@@ -88,11 +89,31 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
|
||||
final Map<String, MasteryItem> mastery = {};
|
||||
|
||||
/// Recognition-only words, kept apart from [mastery] so they never enter a
|
||||
/// level's upgrade denominator (learning engine 3.5).
|
||||
final Map<String, WordKnowledge> wordKnowledge = {};
|
||||
|
||||
/// Words saved from lookup. They live outside any unit, so they are kept
|
||||
/// here to be put back into the word registry on the next start.
|
||||
final Map<String, RegisteredReceptiveWord> savedWords = {};
|
||||
|
||||
void _syncInBackground();
|
||||
}
|
||||
|
||||
class AppState extends _AppStateData
|
||||
with _ReviewAndMastery, _AssessmentProgress, _LessonProgress, _AiContent {
|
||||
with _ReviewAndMastery,
|
||||
_ReceptiveWords,
|
||||
_AssessmentProgress,
|
||||
_LessonProgress,
|
||||
_AiContent {
|
||||
/// Round-trip seam for tests: the real save and load go through
|
||||
/// `LocalSnapshotStore`, which needs a database.
|
||||
@visibleForTesting
|
||||
Map<String, dynamic> snapshotForTest() => _toSnapshotJson();
|
||||
|
||||
@visibleForTesting
|
||||
void restoreForTest(Map<String, dynamic> data) => _restore(data);
|
||||
|
||||
static const _storageKey = 'learning_state_v1';
|
||||
bool isLoaded = false;
|
||||
bool _writing = false;
|
||||
@@ -114,6 +135,9 @@ class AppState extends _AppStateData
|
||||
// Adapt the A1–B1 JSON packs into the runtime registries before the saved
|
||||
// snapshot is restored, so progress that points at those lessons resolves.
|
||||
await CourseRepository.instance.load();
|
||||
// The word bank is independent of the packs, so it loads whatever the
|
||||
// learner's lesson progress is.
|
||||
await WordBank.instance.load();
|
||||
final config = await AiConfigFile.loadFromAsset();
|
||||
if (config != null) {
|
||||
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
|
||||
|
||||
@@ -2,7 +2,8 @@ part of 'app_state.dart';
|
||||
|
||||
/// Position in the seed course, the in-lesson step flow, and the evidence
|
||||
/// lesson tasks produce.
|
||||
mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
mixin _LessonProgress
|
||||
on _AppStateData, _ReviewAndMastery, _ReceptiveWords, _AssessmentProgress {
|
||||
bool get hasResumableLessonDialogue =>
|
||||
dialogueDraft != null &&
|
||||
dialogueDraft!.lessonId == activeLessonId &&
|
||||
@@ -259,6 +260,10 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
);
|
||||
// The independent attempt closes the teaching segment, so this is where
|
||||
// the same-day recall is scheduled: the first retrieval happens hours
|
||||
// later today instead of a full day after teaching.
|
||||
scheduleSameDayRecap(_activeTargetItemIds);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -266,6 +271,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) {
|
||||
return;
|
||||
}
|
||||
// The unit's recognition-only words count as met once its lesson is
|
||||
// done; they start at 「新学」 and are asked on the word page.
|
||||
meetWordsOfUnit(activeLessonId);
|
||||
completedLessonIds.add(activeLessonId);
|
||||
completedLessons = completedLessonIds.length;
|
||||
final next = _nextIncompleteLessonId();
|
||||
@@ -540,7 +548,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
if (introduced.firstTaughtAt == null) {
|
||||
mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now());
|
||||
}
|
||||
if (reviewQueue.any((item) => item.id == id)) continue;
|
||||
if (_hasCheckpointTask(id)) continue;
|
||||
final template = coreReviewTemplate(id);
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
|
||||
@@ -2,13 +2,55 @@ part of 'app_state.dart';
|
||||
|
||||
/// Review queue scheduling and the mastery derived from attempt evidence.
|
||||
mixin _ReviewAndMastery on _AppStateData {
|
||||
/// The spaced checkpoint reviews that are due. Same-day recaps are a
|
||||
/// separate, unbudgeted queue ([dueRecaps]) and never count as backlog.
|
||||
List<ReviewItem> get dueReviews {
|
||||
final now = DateTime.now();
|
||||
final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList()
|
||||
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
|
||||
final due =
|
||||
reviewQueue
|
||||
.where(
|
||||
(item) =>
|
||||
item.kind == ReviewKind.checkpoint &&
|
||||
!item.dueAt.isAfter(now),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
|
||||
return due;
|
||||
}
|
||||
|
||||
/// Same-day recalls and in-session retries that are ready to be asked. They
|
||||
/// only strengthen encoding, so they are offered after the checkpoint plan
|
||||
/// and are dropped once the day they belong to has passed.
|
||||
List<ReviewItem> get dueRecaps {
|
||||
final now = DateTime.now();
|
||||
final cutoff = now.subtract(const Duration(days: 1));
|
||||
final due =
|
||||
reviewQueue
|
||||
.where(
|
||||
(item) =>
|
||||
item.kind == ReviewKind.recap &&
|
||||
!item.dueAt.isAfter(now) &&
|
||||
item.dueAt.isAfter(cutoff),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
|
||||
return due;
|
||||
}
|
||||
|
||||
/// Learning engine 2.2a: only what fits in today's budget is offered. The
|
||||
/// rest stay due and come up next time; nothing is dropped or rescheduled
|
||||
/// behind the learner's back.
|
||||
List<ReviewItem> get todayReviewPlan =>
|
||||
dueReviews.take(reviewBudgetSeconds ~/ reviewSecondsPerTask).toList();
|
||||
|
||||
/// Due checkpoint items left over after today's budget.
|
||||
int get postponedReviewCount => dueReviews.length - todayReviewPlan.length;
|
||||
|
||||
/// What this review session works through: the budgeted checkpoint tasks
|
||||
/// first, then the same-day recaps. A retry of an item missed earlier in
|
||||
/// the session therefore comes back a few tasks later, not immediately.
|
||||
List<ReviewItem> get reviewSession => [...todayReviewPlan, ...dueRecaps];
|
||||
|
||||
int get dueReviewCount => dueReviews.length;
|
||||
bool get reviewIsPrimary => dueReviewCount > 0;
|
||||
int get reviewBudgetSeconds => switch (dailyMinutes) {
|
||||
@@ -16,7 +58,10 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
30 => 8 * 60,
|
||||
_ => 5 * 60,
|
||||
};
|
||||
int get dueReviewEstimatedSeconds => dueReviewCount * 60;
|
||||
|
||||
/// Planned length of one review task, used for the budget and the backlog.
|
||||
static const reviewSecondsPerTask = 60;
|
||||
int get dueReviewEstimatedSeconds => dueReviewCount * reviewSecondsPerTask;
|
||||
bool get reviewBacklog {
|
||||
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7));
|
||||
return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 ||
|
||||
@@ -80,6 +125,17 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
addSavedWord(item);
|
||||
}
|
||||
|
||||
/// The queue slot holding [item]. An item can have both a checkpoint task
|
||||
/// and a same-day recap in flight, so the kind is part of the key.
|
||||
int _queueIndexOf(ReviewItem item) => reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id && candidate.kind == item.kind,
|
||||
);
|
||||
|
||||
/// Whether [id] already has a spaced checkpoint task queued.
|
||||
bool _hasCheckpointTask(String id) => reviewQueue.any(
|
||||
(item) => item.id == id && item.kind == ReviewKind.checkpoint,
|
||||
);
|
||||
|
||||
void completeReview(
|
||||
ReviewItem item, {
|
||||
required bool assisted,
|
||||
@@ -88,12 +144,21 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
final now = DateTime.now();
|
||||
if (current.kind == ReviewKind.recap) {
|
||||
_finishRecap(
|
||||
current,
|
||||
rawAnswer: rawAnswer,
|
||||
inputMode: inputMode,
|
||||
originalTranscript: originalTranscript,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// UI/network retries may still hold an old item instance. Only the
|
||||
// currently due queue entry is allowed to produce evidence.
|
||||
if (current.dueAt.isAfter(now)) return;
|
||||
@@ -161,10 +226,14 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
}
|
||||
|
||||
void reportReviewFailure(ReviewItem item) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
if (item.kind == ReviewKind.recap) {
|
||||
// Learning engine 3.1: a same-day recall is encoding practice. Missing
|
||||
// it shows the answer again and never counts against the item.
|
||||
_finishRecap(item);
|
||||
return;
|
||||
}
|
||||
final existing =
|
||||
mastery[item.id] ??
|
||||
MasteryItem(
|
||||
@@ -194,13 +263,151 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
: item.successfulReviews,
|
||||
);
|
||||
_addAttemptEvidence(item, outcome: EvidenceKind.languageError);
|
||||
_scheduleSessionRetry(item);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void postponeReview(ReviewItem item) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
/// Learning engine 3.2: an item missed now comes back once more before the
|
||||
/// session ends, after the remaining due tasks. That re-ask is practice —
|
||||
/// it neither advances a checkpoint nor counts as a second failure — so the
|
||||
/// next real check still happens tomorrow on a different task.
|
||||
void _scheduleSessionRetry(ReviewItem item) {
|
||||
if (reviewQueue.any(
|
||||
(candidate) => candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: item.id,
|
||||
target: item.target,
|
||||
prompt: item.prompt,
|
||||
hint: item.hint,
|
||||
dueAt: DateTime.now(),
|
||||
skill: item.skill,
|
||||
kind: ReviewKind.recap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Learning engine 2.2/3.1: one short recall of what was just taught, a
|
||||
/// couple of hours later on the same day. It is the item's first retrieval
|
||||
/// outside the lesson it was taught in; the first checkpoint still falls on
|
||||
/// the next day. Only exposure is recorded, so no checkpoint moves and the
|
||||
/// day's single checkpoint advance stays available for the real review.
|
||||
void scheduleSameDayRecap(Iterable<String> itemIds) {
|
||||
final now = DateTime.now();
|
||||
// Yesterday's uncollected recaps are gone: they belonged to that day.
|
||||
reviewQueue.removeWhere(
|
||||
(item) =>
|
||||
item.kind == ReviewKind.recap &&
|
||||
item.dueAt.isBefore(now.subtract(const Duration(days: 1))),
|
||||
);
|
||||
var added = false;
|
||||
for (final id in itemIds) {
|
||||
if (!isCoreItem(id)) continue;
|
||||
if (reviewQueue.any(
|
||||
(item) => item.id == id && item.kind == ReviewKind.recap,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
final template = coreReviewTemplate(id);
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: coreItemLabel(id),
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: now.add(const Duration(hours: 2)),
|
||||
skill: template.skill,
|
||||
kind: ReviewKind.recap,
|
||||
),
|
||||
);
|
||||
added = true;
|
||||
}
|
||||
if (added) {
|
||||
notifyListeners();
|
||||
_syncInBackground();
|
||||
}
|
||||
}
|
||||
|
||||
/// Learning engine 3.1: the recognition step in front of the first
|
||||
/// checkpoint review — hear or read the item, pick its meaning. Passing it
|
||||
/// records 「认识」 evidence, which nothing else in the review flow produced;
|
||||
/// the checkpoint itself is still earned by producing the item right after.
|
||||
/// A miss is not a language failure: it shows the meaning and the learner
|
||||
/// continues to the production task with help.
|
||||
void recordRecognitionGate(ReviewItem item, {required bool correct}) {
|
||||
final outcome = correct
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.exposure;
|
||||
_addAttemptEvidence(
|
||||
item.copyWith(skill: recognitionGateSkill),
|
||||
outcome: outcome,
|
||||
assisted: !correct,
|
||||
);
|
||||
// Exposure neither raises the status nor clears a pending re-check, so a
|
||||
// miss here leaves the item exactly where it was.
|
||||
_recordEvidence(item.id, outcome);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether [item] should open with the recognition question: the first
|
||||
/// checkpoint of a core item, one day after it was taught.
|
||||
bool needsRecognitionGate(ReviewItem item) =>
|
||||
item.kind == ReviewKind.checkpoint &&
|
||||
isCoreItem(item.id) &&
|
||||
(mastery[item.id]?.checkpoint ?? 0) == 0 &&
|
||||
recognitionDistractors(item.id).isNotEmpty;
|
||||
|
||||
/// The remediation after a missed review: the course explains when the item
|
||||
/// is used, then asks which taught sentence a situation calls for. Answering
|
||||
/// it is 「认识」 evidence and never advances a checkpoint — the miss that led
|
||||
/// here has already been recorded, and re-asking on the spot is not an
|
||||
/// independent interval (learning engine 3.1).
|
||||
void recordContrastAnswer(ReviewItem item, {required bool correct}) {
|
||||
final outcome = correct
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.exposure;
|
||||
_addAttemptEvidence(
|
||||
item.copyWith(skill: contrastSkill),
|
||||
outcome: outcome,
|
||||
assisted: !correct,
|
||||
);
|
||||
_recordEvidence(item.id, outcome);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Closes a recap: it records the attempt as exposure and leaves the queue.
|
||||
/// Exposure keeps it out of both the checkpoint count and the mastery
|
||||
/// status, which is what makes a same-day recall safe to repeat.
|
||||
void _finishRecap(
|
||||
ReviewItem item, {
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
bool assisted = false,
|
||||
}) {
|
||||
reviewQueue.removeWhere(
|
||||
(candidate) =>
|
||||
candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
);
|
||||
_addAttemptEvidence(
|
||||
item,
|
||||
outcome: EvidenceKind.exposure,
|
||||
assisted: assisted,
|
||||
rawAnswer: rawAnswer,
|
||||
inputMode: inputMode,
|
||||
originalTranscript: originalTranscript,
|
||||
recordingPath: recordingPath,
|
||||
);
|
||||
notifyListeners();
|
||||
_syncInBackground();
|
||||
}
|
||||
|
||||
void postponeReview(ReviewItem item) {
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
reviewQueue[index] = item.copyWith(
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
@@ -241,20 +448,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
);
|
||||
}
|
||||
|
||||
void addSavedWord(VocabularyItem item) {
|
||||
if (reviewQueue.any((review) => review.id == item.id)) return;
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: item.id,
|
||||
target: item.word,
|
||||
prompt: item.example,
|
||||
hint: item.meaning,
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
skill: '认识与回忆',
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
/// Implemented by [_ReceptiveWords]: a saved word is recognition-only, so
|
||||
/// it never joins the checkpoint queue.
|
||||
void addSavedWord(VocabularyItem item);
|
||||
|
||||
/// Adds one low-priority, non-core recap based on a completed independent
|
||||
/// dialogue. It is deliberately separate from A0 denominator items.
|
||||
@@ -263,7 +459,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
final day =
|
||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
final id = 'dialogue-$sceneId-$day';
|
||||
if (reviewQueue.any((item) => item.id == id)) return;
|
||||
if (_hasCheckpointTask(id)) return;
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
@@ -294,7 +490,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
});
|
||||
if (candidates.isEmpty) return;
|
||||
final id = candidates.first;
|
||||
final index = reviewQueue.indexWhere((item) => item.id == id);
|
||||
final index = reviewQueue.indexWhere(
|
||||
(item) => item.id == id && item.kind == ReviewKind.checkpoint,
|
||||
);
|
||||
if (index >= 0) {
|
||||
final current = reviewQueue[index];
|
||||
final template = coreReviewVariant(id, current.variantIndex + 1);
|
||||
@@ -324,7 +522,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
|
||||
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(item) => item.id == variant.targetItemId,
|
||||
(item) =>
|
||||
item.id == variant.targetItemId &&
|
||||
item.kind == ReviewKind.checkpoint,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
@@ -346,9 +546,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
/// its checkpoint contribution is removed; the stable core item remains.
|
||||
void reportGeneratedReviewVariant(ReviewItem item) {
|
||||
if (!item.isAiGenerated) return;
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
final key = '${item.id}:${item.variantIndex}';
|
||||
if (!reportedAiVariantKeys.add(key)) return;
|
||||
@@ -393,6 +591,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
}
|
||||
for (var index = 0; index < reviewQueue.length; index++) {
|
||||
final item = reviewQueue[index];
|
||||
if (item.kind == ReviewKind.recap) continue;
|
||||
final rebuilt = mastery[item.id];
|
||||
if (rebuilt != null) {
|
||||
reviewQueue[index] = item.copyWith(
|
||||
@@ -419,6 +618,12 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
firstTaughtAt = event.createdAt;
|
||||
}
|
||||
if (!isReview) continue;
|
||||
// The recognition gate and the contrast question are 「认识」 evidence
|
||||
// only; producing the item is what earns a checkpoint.
|
||||
if (event.skill == recognitionGateSkill ||
|
||||
event.skill == contrastSkill) {
|
||||
continue;
|
||||
}
|
||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||
final day =
|
||||
'${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}';
|
||||
@@ -487,6 +692,10 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
'听力理解',
|
||||
'阅读理解',
|
||||
dictationSkill,
|
||||
// Picking the right sentence for a situation shows the learner knows when
|
||||
// to use it. Producing it unaided is a separate, higher bar, so this is
|
||||
// recognition evidence and never stands in for recall.
|
||||
contrastSkill,
|
||||
};
|
||||
|
||||
/// Controlled lesson steps (follow-reading, scripted dialogue) are practice,
|
||||
|
||||
@@ -194,6 +194,63 @@ extension _AppStateSnapshot on AppState {
|
||||
),
|
||||
);
|
||||
}
|
||||
final storedWords = data['savedWords'] as List<dynamic>?;
|
||||
if (storedWords != null) {
|
||||
savedWords
|
||||
..clear()
|
||||
..addEntries(
|
||||
storedWords.whereType<Map<String, dynamic>>().map(
|
||||
(item) => MapEntry(item['id'] as String, (
|
||||
level: item['level'] as String? ?? 'A0',
|
||||
unit: '',
|
||||
en: item['en'] as String? ?? '',
|
||||
zh: item['zh'] as String? ?? '',
|
||||
ipa: item['ipa'] as String? ?? '',
|
||||
)),
|
||||
),
|
||||
);
|
||||
restoreSavedWords();
|
||||
}
|
||||
final savedWordStates = data['wordKnowledge'] as List<dynamic>?;
|
||||
if (savedWordStates != null) {
|
||||
wordKnowledge
|
||||
..clear()
|
||||
..addEntries(
|
||||
savedWordStates.whereType<Map<String, dynamic>>().map((item) {
|
||||
final id = item['id'] as String;
|
||||
return MapEntry(
|
||||
id,
|
||||
WordKnowledge(
|
||||
id: id,
|
||||
status: _enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
),
|
||||
recognizedAt: DateTime.tryParse(
|
||||
item['recognizedAt'] as String? ?? '',
|
||||
),
|
||||
dueAt: DateTime.tryParse(item['dueAt'] as String? ?? ''),
|
||||
misses: item['misses'] as int? ?? 0,
|
||||
// Snapshots written before the spacing ladder carry no step;
|
||||
// place them on the rung their status implies.
|
||||
step: item['step'] as int? ?? switch (_enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
)) {
|
||||
WordStatus.newWord => 0,
|
||||
WordStatus.recognized => 1,
|
||||
WordStatus.familiar => 4,
|
||||
},
|
||||
startedAt: DateTime.tryParse(
|
||||
item['startedAt'] as String? ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
final savedMastery = data['mastery'] as List<dynamic>?;
|
||||
if (savedMastery != null) {
|
||||
mastery
|
||||
@@ -357,6 +414,7 @@ extension _AppStateSnapshot on AppState {
|
||||
'hint': item.hint,
|
||||
'dueAt': item.dueAt.toIso8601String(),
|
||||
'skill': item.skill,
|
||||
'kind': item.kind.name,
|
||||
'attempts': item.attempts,
|
||||
'successfulReviews': item.successfulReviews,
|
||||
'variantIndex': item.variantIndex,
|
||||
@@ -365,6 +423,17 @@ extension _AppStateSnapshot on AppState {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'wordKnowledge': wordKnowledge.values.map((w) => w.toJson()).toList(),
|
||||
'savedWords': [
|
||||
for (final entry in savedWords.entries)
|
||||
{
|
||||
'id': entry.key,
|
||||
'en': entry.value.en,
|
||||
'zh': entry.value.zh,
|
||||
'level': entry.value.level,
|
||||
'ipa': entry.value.ipa,
|
||||
},
|
||||
],
|
||||
'mastery': mastery.values
|
||||
.map(
|
||||
(item) => {
|
||||
@@ -480,6 +549,7 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
|
||||
hint: data['hint'] as String,
|
||||
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
||||
skill: data['skill'] as String,
|
||||
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
|
||||
attempts: data['attempts'] as int? ?? 0,
|
||||
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
||||
variantIndex: data['variantIndex'] as int? ?? 0,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Learning engine 3.5: the recognition-only layer. These words are never
|
||||
/// asked to be produced, they never enter a level's upgrade denominator, and
|
||||
/// they get their own three states instead of the five of a core item.
|
||||
mixin _ReceptiveWords on _AppStateData {
|
||||
/// 「认识」 holds for a week before a second, different question can turn it
|
||||
/// into 「熟悉」; after that the word is only spot-checked monthly.
|
||||
/// Days to the next check after each success in a row. The early rungs are
|
||||
/// short on purpose: a word asked once and then not again for a week is a
|
||||
/// word that has to be learned from scratch the second time.
|
||||
static const _ladderDays = [1, 3, 7, 15, 30, 60, 120];
|
||||
|
||||
/// The rung at which recognition counts as 「熟悉」. Reaching it takes
|
||||
/// 1 + 3 + 7 = 11 days at the fastest, which satisfies the spec's "a second
|
||||
/// success at least seven days later".
|
||||
static const _familiarStep = 4;
|
||||
static const _firstGap = Duration(days: 7);
|
||||
|
||||
/// One recognition question is 5–10 seconds, so the plan is sized in those
|
||||
/// rather than in the minute a produced review takes.
|
||||
static const wordSecondsPerTask = 10;
|
||||
|
||||
/// Records that a word was met in a lesson. It starts as 「新学」 with no
|
||||
/// question scheduled: meeting a word is exposure, not recognition.
|
||||
void meetWord(String id) {
|
||||
if (!isReceptiveWord(id) || wordKnowledge.containsKey(id)) return;
|
||||
wordKnowledge[id] = WordKnowledge(
|
||||
id: id,
|
||||
status: WordStatus.newWord,
|
||||
startedAt: DateTime.now(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Learning engine 3.5: a word saved from lookup is an extension word. It
|
||||
/// follows the same recognition-only rules as a unit's receptive words and
|
||||
/// never enters the checkpoint queue.
|
||||
void addSavedWord(VocabularyItem item) {
|
||||
if (savedWords.containsKey(item.id)) return;
|
||||
final level = allLessons
|
||||
.where((lesson) => lesson.id == activeLessonId)
|
||||
.map((lesson) => lesson.level)
|
||||
.firstOrNull;
|
||||
savedWords[item.id] = (
|
||||
level: level ?? 'A0',
|
||||
unit: '',
|
||||
en: item.word,
|
||||
zh: item.meaning,
|
||||
ipa: item.ipa ?? '',
|
||||
);
|
||||
_registerSavedWords();
|
||||
wordKnowledge[item.id] = WordKnowledge(
|
||||
id: item.id,
|
||||
status: WordStatus.newWord,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Puts the saved words back into the registry, which is otherwise built
|
||||
/// from the course packs alone.
|
||||
void _registerSavedWords() {
|
||||
for (final entry in savedWords.entries) {
|
||||
registerSavedWord(
|
||||
entry.key,
|
||||
en: entry.value.en,
|
||||
zh: entry.value.zh,
|
||||
level: entry.value.level,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void restoreSavedWords() => _registerSavedWords();
|
||||
|
||||
void meetWordsOfUnit(String unit) {
|
||||
var added = false;
|
||||
for (final id in receptiveWordsOfUnit(unit)) {
|
||||
if (wordKnowledge.containsKey(id)) continue;
|
||||
wordKnowledge[id] = WordKnowledge(id: id, status: WordStatus.newWord);
|
||||
added = true;
|
||||
}
|
||||
if (added) notifyListeners();
|
||||
}
|
||||
|
||||
/// One independent recognition attempt.
|
||||
///
|
||||
/// A success moves 「新学」 to 「认识」; it only reaches 「熟悉」 when the
|
||||
/// success comes at least seven days after the first one. A miss drops the
|
||||
/// word back to 「认识」 and asks again tomorrow with another question —
|
||||
/// there is no diagnosis and no further downgrade.
|
||||
void recordWordRecognition(String id, {required bool correct}) {
|
||||
if (!isReceptiveWord(id)) return;
|
||||
final now = DateTime.now();
|
||||
final current =
|
||||
wordKnowledge[id] ?? WordKnowledge(id: id, status: WordStatus.newWord);
|
||||
if (!correct) {
|
||||
// A miss drops the word to the bottom of the ladder and asks again
|
||||
// tomorrow. It never falls below 「认识」 once earned: a single lapse
|
||||
// does not mean the word was never known (learning engine 3.5).
|
||||
wordKnowledge[id] = WordKnowledge(
|
||||
id: id,
|
||||
status: current.recognizedAt == null
|
||||
? WordStatus.newWord
|
||||
: WordStatus.recognized,
|
||||
recognizedAt: current.recognizedAt,
|
||||
dueAt: _tomorrow(now),
|
||||
misses: current.misses + 1,
|
||||
startedAt: current.startedAt,
|
||||
);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
final step = current.step + 1;
|
||||
final earned = current.recognizedAt ?? now;
|
||||
wordKnowledge[id] = WordKnowledge(
|
||||
id: id,
|
||||
status: _statusFor(step, earned, now),
|
||||
recognizedAt: earned,
|
||||
dueAt: now.add(Duration(days: _gapDays(step))),
|
||||
misses: current.misses,
|
||||
step: step,
|
||||
// Carried over, or answering a word would take it back out of today's
|
||||
// count and the daily cap could be spent over and over in one sitting.
|
||||
startedAt: current.startedAt,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// The ladder rung for the nth success in a row, holding at the last one.
|
||||
static int _gapDays(int step) =>
|
||||
_ladderDays[(step < _ladderDays.length ? step : _ladderDays.length) - 1];
|
||||
|
||||
/// 「熟悉」 needs both enough rungs and enough calendar: a learner who let
|
||||
/// the word sit for weeks between checks gets there on the same rung, but
|
||||
/// nobody gets there in three days.
|
||||
static WordStatus _statusFor(int step, DateTime earned, DateTime now) {
|
||||
if (step == 0) return WordStatus.newWord;
|
||||
if (step >= _familiarStep && !now.isBefore(earned.add(_firstGap))) {
|
||||
return WordStatus.familiar;
|
||||
}
|
||||
return WordStatus.recognized;
|
||||
}
|
||||
|
||||
DateTime _tomorrow(DateTime now) =>
|
||||
DateTime(now.year, now.month, now.day).add(const Duration(days: 1, hours: 9));
|
||||
|
||||
/// Words whose check has come due, weakest first.
|
||||
List<String> get dueWords {
|
||||
final now = DateTime.now();
|
||||
final due = wordKnowledge.values
|
||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
|
||||
return a.dueAt!.compareTo(b.dueAt!);
|
||||
});
|
||||
return [for (final word in due) word.id];
|
||||
}
|
||||
|
||||
/// Words met but never asked yet — the queue the word page offers first.
|
||||
List<String> get unaskedWords => [
|
||||
for (final word in wordKnowledge.values)
|
||||
if (word.status == WordStatus.newWord && word.dueAt == null) word.id,
|
||||
];
|
||||
|
||||
/// Words that have been missed and are not 「熟悉」 yet.
|
||||
List<String> get shakyWords {
|
||||
final shaky = wordKnowledge.values
|
||||
.where((word) => word.misses > 0 && word.status != WordStatus.familiar)
|
||||
.toList()
|
||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||
return [for (final word in shaky) word.id];
|
||||
}
|
||||
|
||||
/// Learning engine 3.5: word recognition takes no more than a third of the
|
||||
/// daily review budget. This caps only what is *inserted into a review
|
||||
/// session*; the word page is opened on purpose and is not budgeted.
|
||||
int get wordBudgetSeconds => reviewBudgetSeconds ~/ 3;
|
||||
int get wordPlanSize => wordBudgetSeconds ~/ wordSecondsPerTask;
|
||||
|
||||
/// Which way round this word's next question is asked.
|
||||
///
|
||||
/// A word is always introduced in writing: hearing a word you have never
|
||||
/// seen spelled gives you nothing to hold on to, and the day after a miss is
|
||||
/// no time to take the spelling away either. From the first success on the
|
||||
/// three rotate — see it, hear it, then produce it from the meaning — so a
|
||||
/// word is not called known on the strength of one direction. Recognising a
|
||||
/// word on the page is not the same as catching it in speech, and neither
|
||||
/// one means it comes to mind when you need it.
|
||||
WordAskMode wordAskMode(String id) {
|
||||
final step = wordKnowledge[id]?.step ?? 0;
|
||||
return WordAskMode.values[step % WordAskMode.values.length];
|
||||
}
|
||||
|
||||
/// How many questions one round on the word page asks.
|
||||
static const wordSessionSize = 20;
|
||||
|
||||
/// New words the bank may hand out in a day.
|
||||
///
|
||||
/// Without a cap, one sitting can start a hundred words and every one of
|
||||
/// them comes back the next day, which is the pile that made them not stick
|
||||
/// in the first place. The ladder's first rung is one day, so today's new
|
||||
/// words are tomorrow's workload.
|
||||
int get newWordsPerDay => switch (dailyMinutes) {
|
||||
10 => 8,
|
||||
30 => 20,
|
||||
_ => 15,
|
||||
};
|
||||
|
||||
/// New words already started today, whatever they came from.
|
||||
int get newWordsStartedToday {
|
||||
final now = DateTime.now();
|
||||
return wordKnowledge.values.where((word) {
|
||||
final started = word.startedAt;
|
||||
return started != null &&
|
||||
started.year == now.year &&
|
||||
started.month == now.month &&
|
||||
started.day == now.day;
|
||||
}).length;
|
||||
}
|
||||
|
||||
/// New words the bank may still hand out today.
|
||||
int get newWordQuota {
|
||||
final left = newWordsPerDay - newWordsStartedToday;
|
||||
return left > 0 ? left : 0;
|
||||
}
|
||||
|
||||
/// Bank words not started yet: easiest level first, and inside a level the
|
||||
/// most common in spoken English first. The bank is independent of lesson
|
||||
/// progress, so these are available from day one.
|
||||
List<String> get unstartedBankWords {
|
||||
final byLevel = <String, List<String>>{};
|
||||
for (final entry in receptiveWordRegistry.entries) {
|
||||
if (!WordBank.isBankUnit(entry.value.unit)) continue;
|
||||
if (wordKnowledge.containsKey(entry.key)) continue;
|
||||
byLevel.putIfAbsent(entry.value.level, () => []).add(entry.key);
|
||||
}
|
||||
final bank = WordBank.instance;
|
||||
final ordered = <String>[];
|
||||
for (final level in const ['A1', 'A2', 'B1']) {
|
||||
final words = byLevel[level];
|
||||
if (words == null) continue;
|
||||
words.sort((a, b) {
|
||||
final byRank = bank.rankOf(a).compareTo(bank.rankOf(b));
|
||||
return byRank != 0 ? byRank : a.compareTo(b);
|
||||
});
|
||||
ordered.addAll(words);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// Counted without building and sorting the ordered list: the home card asks
|
||||
/// for this on every rebuild and the bank is a few thousand words.
|
||||
int get unstartedBankWordCount {
|
||||
var count = 0;
|
||||
for (final entry in receptiveWordRegistry.entries) {
|
||||
if (!WordBank.isBankUnit(entry.value.unit)) continue;
|
||||
if (wordKnowledge.containsKey(entry.key)) continue;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Minutes a round of [size] questions takes, rounded up, for the cards that
|
||||
/// offer one.
|
||||
int wordSessionMinutes(int size) => (size * wordSecondsPerTask + 59) ~/ 60;
|
||||
|
||||
/// How many questions [wordSession] would hand out, without starting any
|
||||
/// new word: the list view needs the number before the learner taps.
|
||||
int get wordSessionPreviewSize {
|
||||
final fresh = unstartedBankWordCount < newWordQuota
|
||||
? unstartedBankWordCount
|
||||
: newWordQuota;
|
||||
final ready = {...dueWords, ...unaskedWords}.length + fresh;
|
||||
return ready < wordSessionSize ? ready : wordSessionSize;
|
||||
}
|
||||
|
||||
/// One round on the word page: what is due first, then words met in a unit
|
||||
/// but never asked, then new words from the bank.
|
||||
///
|
||||
/// A new word is registered as it is handed out, so a round the learner
|
||||
/// never finishes does not fill the list with words they have not seen.
|
||||
List<WordQuestion> wordSession({int size = wordSessionSize}) {
|
||||
final plan = <WordQuestion>[];
|
||||
final seen = <String>{};
|
||||
var quota = newWordQuota;
|
||||
void take(Iterable<String> ids, {bool start = false}) {
|
||||
for (final id in ids) {
|
||||
if (plan.length >= size) return;
|
||||
if (start && quota <= 0) return;
|
||||
if (!seen.add(id)) continue;
|
||||
final question = receptiveQuestion(
|
||||
id,
|
||||
variant: wordKnowledge[id]?.misses ?? 0,
|
||||
mode: wordAskMode(id),
|
||||
);
|
||||
if (question == null) continue;
|
||||
if (start) {
|
||||
wordKnowledge[id] = WordKnowledge(
|
||||
id: id,
|
||||
status: WordStatus.newWord,
|
||||
startedAt: DateTime.now(),
|
||||
);
|
||||
quota -= 1;
|
||||
}
|
||||
plan.add(question);
|
||||
}
|
||||
}
|
||||
|
||||
take(dueWords);
|
||||
take(unaskedWords);
|
||||
take(unstartedBankWords, start: true);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// Today's word questions: what is due first, then words never asked.
|
||||
List<WordQuestion> get todayWordPlan {
|
||||
final plan = <WordQuestion>[];
|
||||
for (final id in [...dueWords, ...unaskedWords]) {
|
||||
if (plan.length >= wordPlanSize) break;
|
||||
final question = receptiveQuestion(
|
||||
id,
|
||||
variant: wordKnowledge[id]?.misses ?? 0,
|
||||
mode: wordAskMode(id),
|
||||
);
|
||||
if (question != null) plan.add(question);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
int get dueWordCount => dueWords.length;
|
||||
|
||||
WordStatus wordStatus(String id) =>
|
||||
wordKnowledge[id]?.status ?? WordStatus.newWord;
|
||||
|
||||
/// Every word met, grouped by the unit that taught it, in unit order.
|
||||
Map<String, List<String>> get wordsByUnit {
|
||||
final grouped = <String, List<String>>{};
|
||||
for (final id in wordKnowledge.keys) {
|
||||
grouped.putIfAbsent(wordUnit(id), () => []).add(id);
|
||||
}
|
||||
final units = grouped.keys.toList()..sort();
|
||||
return {for (final unit in units) unit: grouped[unit]!..sort()};
|
||||
}
|
||||
|
||||
int get familiarWordCount => wordKnowledge.values
|
||||
.where((word) => word.status == WordStatus.familiar)
|
||||
.length;
|
||||
int get recognizedWordCount => wordKnowledge.values
|
||||
.where((word) => word.status == WordStatus.recognized)
|
||||
.length;
|
||||
|
||||
int get reviewBudgetSeconds;
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -313,6 +313,56 @@ class PackSegment {
|
||||
}
|
||||
}
|
||||
|
||||
/// One taught item that is easy to mistake for another, with the difference
|
||||
/// spelled out. `A0-P02` (`What's your name?`) lists `A0-P05`
|
||||
/// (`How are you?`), because knowing both sentences is not the same as
|
||||
/// knowing which one a situation calls for.
|
||||
class Confusable {
|
||||
const Confusable({required this.id, required this.note});
|
||||
|
||||
final String id;
|
||||
final String note;
|
||||
|
||||
factory Confusable.fromJson(Map<String, dynamic> json) => Confusable(
|
||||
id: json['id'] as String? ?? '',
|
||||
note: json['note'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// How a pattern is used, as opposed to what it means: the situation it
|
||||
/// belongs to, the reply it invites, an alternative wording, and the taught
|
||||
/// items it is confused with. The course JSON carries this; nothing here is
|
||||
/// generated at runtime.
|
||||
class CoreUsage {
|
||||
const CoreUsage({
|
||||
this.when = '',
|
||||
this.reply = '',
|
||||
this.swap = '',
|
||||
this.confuse = const [],
|
||||
});
|
||||
|
||||
/// The situation the item is used in, in one line.
|
||||
final String when;
|
||||
|
||||
/// What the other person usually answers, or how to answer it.
|
||||
final String reply;
|
||||
|
||||
/// Another taught way to say the same thing.
|
||||
final String swap;
|
||||
|
||||
final List<Confusable> confuse;
|
||||
|
||||
bool get isEmpty =>
|
||||
when.isEmpty && reply.isEmpty && swap.isEmpty && confuse.isEmpty;
|
||||
|
||||
factory CoreUsage.fromJson(Map<String, dynamic> json) => CoreUsage(
|
||||
when: json['when'] as String? ?? '',
|
||||
reply: json['reply'] as String? ?? '',
|
||||
swap: json['swap'] as String? ?? '',
|
||||
confuse: _objectList(json['confuse']).map(Confusable.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A produced core item (word / phrase / pattern) with its acceptance rule.
|
||||
class CoreItem {
|
||||
const CoreItem({
|
||||
@@ -327,6 +377,7 @@ class CoreItem {
|
||||
this.reviewHint = '',
|
||||
this.dictationSentence = '',
|
||||
this.dictationMeaning = '',
|
||||
this.usage = const CoreUsage(),
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -348,6 +399,9 @@ class CoreItem {
|
||||
final String dictationSentence;
|
||||
final String dictationMeaning;
|
||||
|
||||
/// When to use the item, empty for anything that needs no explaining.
|
||||
final CoreUsage usage;
|
||||
|
||||
factory CoreItem.fromJson(Map<String, dynamic> json) {
|
||||
final example = _objectOrEmpty(json['example']);
|
||||
final review = _objectOrEmpty(json['review']);
|
||||
@@ -364,6 +418,7 @@ class CoreItem {
|
||||
reviewHint: review['hint'] as String? ?? '',
|
||||
dictationSentence: dictation['sentence'] as String? ?? '',
|
||||
dictationMeaning: dictation['meaning'] as String? ?? '',
|
||||
usage: CoreUsage.fromJson(_objectOrEmpty(json['usage'])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,10 @@ class CourseRepository {
|
||||
|
||||
// Core items: labels, acceptance rules and offline review prompts.
|
||||
for (final item in pack.coreItems) {
|
||||
registerCoreItem(item, level: pack.level);
|
||||
registerCoreItem(item, level: pack.level, unit: pack.id);
|
||||
}
|
||||
for (final word in pack.receptiveWords) {
|
||||
registerReceptiveWord(word, level: pack.level, unit: pack.id);
|
||||
}
|
||||
|
||||
final segments = <LessonSegment>[];
|
||||
|
||||
@@ -9,3 +9,4 @@ export 'core_items.dart';
|
||||
export 'course_catalog.dart';
|
||||
export 'course_models.dart';
|
||||
export 'option_order.dart';
|
||||
export 'word_bank.dart';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'core_items.dart';
|
||||
|
||||
/// A bank word used in a sentence, with the sentence's Chinese.
|
||||
typedef WordExample = ({String en, String zh});
|
||||
|
||||
/// The standalone word bank (`assets/words/wordbank.json`).
|
||||
///
|
||||
/// It is deliberately independent of the course packs: the words a learner can
|
||||
/// study must not be capped by how many units they have finished, which was
|
||||
/// the whole problem with growing vocabulary out of `receptiveWords` alone.
|
||||
/// CEFR-J decides which words and at what level; the bank only carries the
|
||||
/// IPA and the Chinese gloss.
|
||||
class WordBank {
|
||||
WordBank._();
|
||||
static final instance = WordBank._();
|
||||
|
||||
static const _asset = 'assets/words/wordbank.json';
|
||||
|
||||
/// The unit a bank word is filed under, e.g. `bank-A1`.
|
||||
static String unitOf(String level) => 'bank-$level';
|
||||
static bool isBankUnit(String unit) => unit.startsWith('bank-');
|
||||
static String levelOfUnit(String unit) => unit.substring('bank-'.length);
|
||||
|
||||
bool _loaded = false;
|
||||
final Map<String, String> _extraSenses = {};
|
||||
final Map<String, WordExample> _examples = {};
|
||||
final Map<String, int> _ranks = {};
|
||||
|
||||
/// Senses beyond the one used as the quiz answer; empty when there are none.
|
||||
String moreSenses(String id) => _extraSenses[id] ?? '';
|
||||
|
||||
/// The word in a sentence, which is what the meaning is shown against once
|
||||
/// the answer is out. Null when the bank has none for this word.
|
||||
WordExample? exampleOf(String id) => _examples[id];
|
||||
|
||||
/// How common the word is in spoken English, 0 being the most common. New
|
||||
/// words are handed out in this order so the bank does not start with a
|
||||
/// whole week of words beginning with `a`.
|
||||
static const unranked = 99999;
|
||||
int rankOf(String id) => _ranks[id] ?? unranked;
|
||||
|
||||
Future<void> load({AssetBundle? bundle}) async {
|
||||
if (_loaded) return;
|
||||
final raw = await (bundle ?? rootBundle).loadString(_asset);
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
for (final entry in (data['words'] as List<dynamic>)) {
|
||||
final word = entry as Map<String, dynamic>;
|
||||
final id = word['id'] as String;
|
||||
final level = word['level'] as String? ?? 'A1';
|
||||
registerBankWord(
|
||||
id,
|
||||
en: word['en'] as String? ?? '',
|
||||
zh: word['zh'] as String? ?? '',
|
||||
ipa: word['ipa'] as String? ?? '',
|
||||
level: level,
|
||||
unit: unitOf(level),
|
||||
);
|
||||
final more = word['more'] as String? ?? '';
|
||||
if (more.isNotEmpty) _extraSenses[id] = more;
|
||||
_ranks[id] = word['rank'] as int? ?? unranked;
|
||||
final en = word['ex'] as String? ?? '';
|
||||
if (en.isNotEmpty) {
|
||||
_examples[id] = (en: en, zh: word['exZh'] as String? ?? '');
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
/// Test seam: drops what was loaded so another bundle can be loaded.
|
||||
void resetForTest() {
|
||||
_loaded = false;
|
||||
_extraSenses.clear();
|
||||
_examples.clear();
|
||||
_ranks.clear();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ enum LearningGoal { dailyLife, travel, workStarter }
|
||||
|
||||
enum PlacementLevel { beginner, someBasics, simpleConversation }
|
||||
|
||||
enum AppTab { learn, dialogue, review, profile }
|
||||
enum AppTab { learn, dialogue, review, words, profile }
|
||||
|
||||
enum LessonStep {
|
||||
preview,
|
||||
@@ -18,6 +18,72 @@ enum LessonStep {
|
||||
|
||||
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
|
||||
|
||||
/// Learning engine 3.5: a recognition-only word has its own three states,
|
||||
/// deliberately shorter than [MasteryStatus]. It is never asked to be spelled
|
||||
/// or said, and it never counts toward a level's upgrade denominator.
|
||||
enum WordStatus { newWord, recognized, familiar }
|
||||
|
||||
/// What the app knows about one recognition-only word.
|
||||
class WordKnowledge {
|
||||
const WordKnowledge({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.recognizedAt,
|
||||
this.dueAt,
|
||||
this.misses = 0,
|
||||
this.step = 0,
|
||||
this.startedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final WordStatus status;
|
||||
|
||||
/// When it first became 「认识」. 「熟悉」 needs a second success at least
|
||||
/// seven days after this, on a different question.
|
||||
final DateTime? recognizedAt;
|
||||
|
||||
/// When it comes up for a check again; null once nothing is scheduled.
|
||||
final DateTime? dueAt;
|
||||
|
||||
/// How often recognition has failed, used to surface the weak ones.
|
||||
final int misses;
|
||||
|
||||
/// Successes in a row, which is the rung of the spacing ladder this word is
|
||||
/// on. A miss puts it back to 0.
|
||||
final int step;
|
||||
|
||||
/// When the word entered the learner's list, which is what the daily cap on
|
||||
/// new words is counted against.
|
||||
final DateTime? startedAt;
|
||||
|
||||
WordKnowledge copyWith({
|
||||
WordStatus? status,
|
||||
DateTime? recognizedAt,
|
||||
DateTime? dueAt,
|
||||
int? misses,
|
||||
int? step,
|
||||
DateTime? startedAt,
|
||||
}) => WordKnowledge(
|
||||
id: id,
|
||||
status: status ?? this.status,
|
||||
recognizedAt: recognizedAt ?? this.recognizedAt,
|
||||
dueAt: dueAt ?? this.dueAt,
|
||||
misses: misses ?? this.misses,
|
||||
step: step ?? this.step,
|
||||
startedAt: startedAt ?? this.startedAt,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'status': status.name,
|
||||
'recognizedAt': recognizedAt?.toIso8601String(),
|
||||
'dueAt': dueAt?.toIso8601String(),
|
||||
'misses': misses,
|
||||
'step': step,
|
||||
'startedAt': startedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
enum EvidenceKind {
|
||||
exposure,
|
||||
assisted,
|
||||
@@ -392,6 +458,13 @@ class SentenceAnalysisResult {
|
||||
);
|
||||
}
|
||||
|
||||
/// What a queued review is for. A `checkpoint` task carries the four spaced
|
||||
/// checkpoints of learning engine 3.1. A `recap` task is the same-day recall
|
||||
/// after a segment, or the re-ask of an item missed earlier in the session:
|
||||
/// it only strengthens encoding, so it never advances a checkpoint and a miss
|
||||
/// is never counted as a language failure.
|
||||
enum ReviewKind { checkpoint, recap }
|
||||
|
||||
class ReviewItem {
|
||||
const ReviewItem({
|
||||
required this.id,
|
||||
@@ -400,6 +473,7 @@ class ReviewItem {
|
||||
required this.hint,
|
||||
required this.dueAt,
|
||||
required this.skill,
|
||||
this.kind = ReviewKind.checkpoint,
|
||||
this.attempts = 0,
|
||||
this.successfulReviews = 0,
|
||||
this.variantIndex = 0,
|
||||
@@ -413,6 +487,7 @@ class ReviewItem {
|
||||
final String hint;
|
||||
final DateTime dueAt;
|
||||
final String skill;
|
||||
final ReviewKind kind;
|
||||
final int attempts;
|
||||
final int successfulReviews;
|
||||
final int variantIndex;
|
||||
@@ -421,6 +496,7 @@ class ReviewItem {
|
||||
|
||||
ReviewItem copyWith({
|
||||
DateTime? dueAt,
|
||||
ReviewKind? kind,
|
||||
int? attempts,
|
||||
int? successfulReviews,
|
||||
int? variantIndex,
|
||||
@@ -436,6 +512,7 @@ class ReviewItem {
|
||||
hint: hint ?? this.hint,
|
||||
dueAt: dueAt ?? this.dueAt,
|
||||
skill: skill ?? this.skill,
|
||||
kind: kind ?? this.kind,
|
||||
attempts: attempts ?? this.attempts,
|
||||
successfulReviews: successfulReviews ?? this.successfulReviews,
|
||||
variantIndex: variantIndex ?? this.variantIndex,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/app_theme.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import 'lesson_path.dart';
|
||||
import 'today_task_card.dart';
|
||||
import 'word_task_card.dart';
|
||||
|
||||
/// 学习 tab:今天的任务在上,完整课程路径在下。
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -17,6 +18,7 @@ class HomePage extends StatelessWidget {
|
||||
required this.onResumeLessonDialogue,
|
||||
required this.onStartReinforcement,
|
||||
required this.onOpenAssessment,
|
||||
required this.onOpenWords,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
@@ -26,6 +28,7 @@ class HomePage extends StatelessWidget {
|
||||
final VoidCallback onResumeLessonDialogue;
|
||||
final VoidCallback onStartReinforcement;
|
||||
final ValueChanged<String> onOpenAssessment;
|
||||
final VoidCallback onOpenWords;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
@@ -41,6 +44,7 @@ class HomePage extends StatelessWidget {
|
||||
onStartReinforcement: onStartReinforcement,
|
||||
onOpenAssessment: onOpenAssessment,
|
||||
),
|
||||
WordTaskCard(state: state, onOpenWords: onOpenWords),
|
||||
LessonPath(state: state, onOpenLesson: onOpenLesson),
|
||||
const _FrameworkNote(),
|
||||
],
|
||||
|
||||
@@ -69,15 +69,29 @@ class TodayTaskCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (state.reviewIsPrimary) {
|
||||
final count = state.dueReviewCount;
|
||||
final planned = state.todayReviewPlan.length;
|
||||
final postponed = state.postponedReviewCount;
|
||||
return _TodayTask(
|
||||
label: '今日复习',
|
||||
title: '先复习 $count 项',
|
||||
note: state.reviewBacklog
|
||||
title: '先复习 $planned 项',
|
||||
note: postponed > 0
|
||||
? '今天的复习时间安排 $planned 项,其余 $postponed 项明天接着做,不会丢。'
|
||||
: state.reviewBacklog
|
||||
? '有积压项目;先花几分钟清掉到期复习,再开启新课。'
|
||||
: '之前练过的关键句,今天换个情境再用一次。',
|
||||
action: '开始复习',
|
||||
minutes: '${(count * 2).clamp(2, 10)} 分钟',
|
||||
minutes: '${(planned * 2).clamp(2, 10)} 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
// 学完一段后隔一会儿再想起来一次,明天的第一次检查才不会从零开始。
|
||||
if (state.dueRecaps.isNotEmpty) {
|
||||
return _TodayTask(
|
||||
label: '课后快闪',
|
||||
title: '把刚学的 ${state.dueRecaps.length} 项再想一遍',
|
||||
note: '趁还记得再提取一次,明天更容易想起来。答不出也不算错,不计检查点。',
|
||||
action: '开始快闪',
|
||||
minutes: '1 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../words/words_page.dart';
|
||||
|
||||
/// 首页的认词入口。
|
||||
///
|
||||
/// 认词跟今日任务是并行的,不是竞争关系:一个题几秒钟,不该去抢上面那张
|
||||
/// 今日任务卡的「今天最该做的一件事」。所以它单独占一行 —— 有题时是一张能
|
||||
/// 直接开始的卡,没题时缩成一行状态,但入口始终在。
|
||||
class WordTaskCard extends StatelessWidget {
|
||||
const WordTaskCard({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onOpenWords,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onOpenWords;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final planned = state.wordSessionPreviewSize;
|
||||
if (planned == 0) return _restRow(context);
|
||||
return SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
onTap: onOpenWords,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Eyebrow('认词')),
|
||||
Text(
|
||||
'${state.wordSessionMinutes(planned)} 分钟',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
wordTaskHeadline(state),
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'$planned 个题,听到、看到能认出来就行,不用会说。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SecondaryButton(label: '开始认词', onPressed: onOpenWords),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _restRow(BuildContext context) => SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
onTap: onOpenWords,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.abc_outlined, color: AppColors.muted, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.newWordQuota > 0
|
||||
? '今天的词都过完了'
|
||||
: '今天的新词学满 ${state.newWordsPerDay} 个了,明天继续',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'认识 ${state.recognizedWordCount} · 熟悉 ${state.familiarWordCount}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../../core/voice_service.dart';
|
||||
import '../../core/writing_feedback.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
import '../../widgets/usage_card.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
part 'steps/independent_step.dart';
|
||||
@@ -148,6 +149,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
case LessonStep.speaking:
|
||||
content = _SpeakingStep(
|
||||
state: widget.state,
|
||||
segmentId: segment.id,
|
||||
text: activity.speaking,
|
||||
tip: activity.speakingTip,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
|
||||
@@ -47,6 +47,7 @@ class _PreviewStep extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
UsageCard(itemId: item.id, state: state),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
|
||||
|
||||
@@ -3,11 +3,13 @@ part of '../lesson_flow.dart';
|
||||
class _SpeakingStep extends StatefulWidget {
|
||||
const _SpeakingStep({
|
||||
required this.state,
|
||||
required this.segmentId,
|
||||
required this.text,
|
||||
this.tip = '',
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String segmentId;
|
||||
final String text;
|
||||
final String tip;
|
||||
final AppState state;
|
||||
@@ -92,6 +94,7 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
|
||||
@@ -91,6 +91,7 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../core/courses/courses.dart';
|
||||
import '../../core/speech_compare.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/usage_card.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class ReviewPage extends StatefulWidget {
|
||||
@@ -50,6 +51,15 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
|
||||
/// Items whose recognition question has been answered in this session. The
|
||||
/// gate is asked once per item; the production task follows immediately.
|
||||
final Set<String> gateCleared = {};
|
||||
|
||||
/// The item whose contrast question is being shown after a miss, and the
|
||||
/// option picked, if any. Set only while the remediation is on screen.
|
||||
ReviewItem? contrastFor;
|
||||
String? contrastPicked;
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@@ -156,10 +166,204 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
});
|
||||
}
|
||||
|
||||
/// Options for the recognition question: the meaning plus distractors from
|
||||
/// the same unit. The answer's position is stable per item so it does not
|
||||
/// move while the learner reads, but it is not always first.
|
||||
List<String> _recognitionOptions(String id, String answer) {
|
||||
final options = [answer, ...recognitionDistractors(id)];
|
||||
final offset = id.hashCode.abs() % options.length;
|
||||
return [...options.skip(offset), ...options.take(offset)];
|
||||
}
|
||||
|
||||
void _answerGate(ReviewItem item, {required bool correct}) {
|
||||
widget.state.recordRecognitionGate(item, correct: correct);
|
||||
setState(() {
|
||||
gateCleared.add(item.id);
|
||||
// Being shown the meaning is help, so finishing the production step
|
||||
// afterwards is recorded as assisted.
|
||||
if (!correct) usedHelp = true;
|
||||
});
|
||||
if (!correct) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'它的意思是:${coreRecognitionQuestion(item.id).answer}。下一步试着写出英文。',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step one of an item's first checkpoint: hear or read it and pick what it
|
||||
/// means. Recall of the English comes right after. Learning engine 3.1 puts
|
||||
/// 「认识」 before 「可回忆」, so the day after a lesson opens with a question
|
||||
/// the learner can answer instead of the hardest one.
|
||||
Widget _recognitionView(ReviewItem item) {
|
||||
final question = coreRecognitionQuestion(item.id);
|
||||
final answer = question.answer;
|
||||
final english = question.shown;
|
||||
final audio = english;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.reviewSession.length} 项待完成'),
|
||||
Text(
|
||||
'先认出来:它是什么意思?',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
'第 1 步,共 2 步 · 认出来 → 说出来',
|
||||
style: TextStyle(color: AppColors.green, fontSize: 13),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(english, style: const TextStyle(fontSize: 22)),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(audio),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
TextButton(
|
||||
onPressed: () => _play(audio, slow: true),
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final option in _recognitionOptions(item.id, answer))
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _answerGate(item, correct: option == answer),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _answerGate(item, correct: false),
|
||||
child: const Text('不认识'),
|
||||
),
|
||||
Text(
|
||||
'选错或选「不认识」都不算答错,会直接告诉你意思,再一起练说出来。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportMiss(ReviewItem item) {
|
||||
widget.state.reportReviewFailure(item);
|
||||
// Being told the answer is the moment to say what the item is for. A
|
||||
// learner who could not produce `What's your name?` often knows the words
|
||||
// and not the situation.
|
||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
contrastFor = item;
|
||||
contrastPicked = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(_resetAnswer);
|
||||
}
|
||||
|
||||
void _answerContrast(ReviewItem item, String picked, String answer) {
|
||||
widget.state.recordContrastAnswer(item, correct: picked == answer);
|
||||
setState(() => contrastPicked = picked);
|
||||
}
|
||||
|
||||
/// The remediation after a miss: the usage note, then the situation with the
|
||||
/// taught sentences to choose between. It is teaching, not a checkpoint —
|
||||
/// the miss is already recorded and answering here cannot undo it.
|
||||
Widget _contrastView(ReviewItem item) {
|
||||
final question = coreContrastQuestion(
|
||||
item.id,
|
||||
isTaught: widget.state.mastery.containsKey,
|
||||
)!;
|
||||
final picked = contrastPicked;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('先弄清楚什么时候用'),
|
||||
Text(
|
||||
'这句话用在这种情况:',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
||||
),
|
||||
const Text('这时候该说哪一句?'),
|
||||
for (final option in question.options)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: picked == null
|
||||
? () => _answerContrast(item, option, question.answer)
|
||||
: null,
|
||||
style: picked == null
|
||||
? null
|
||||
: OutlinedButton.styleFrom(
|
||||
backgroundColor: option == question.answer
|
||||
? AppColors.softGreen
|
||||
: null,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (picked != null) ...[
|
||||
SectionCard(
|
||||
tint: picked == question.answer
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: Text(
|
||||
picked == question.answer
|
||||
? '对,就是这句。${question.note}'
|
||||
: '这里要说 ${question.answer}。${question.note}',
|
||||
style: TextStyle(color: AppColors.warmInk, height: 1.45),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '继续复习',
|
||||
onPressed: () => setState(() {
|
||||
contrastFor = null;
|
||||
contrastPicked = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'这一步不算检查点,也不会影响刚才那道题的结果。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultView(
|
||||
({String answer, String reference, String? audio, bool assisted}) result,
|
||||
) {
|
||||
final remaining = widget.state.dueReviews.length;
|
||||
final remaining = widget.state.reviewSession.length;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -311,11 +515,12 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (contrastFor case final item?) return _contrastView(item);
|
||||
if (lastResult case final result?) return _resultView(result);
|
||||
final item = widget.state.dueReviews.isEmpty
|
||||
? null
|
||||
: widget.state.dueReviews.first;
|
||||
final session = widget.state.reviewSession;
|
||||
final item = session.isEmpty ? null : session.first;
|
||||
if (item == null) {
|
||||
final postponed = widget.state.postponedReviewCount;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -324,12 +529,20 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
'到期项目已安排下次复练。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
|
||||
Text(
|
||||
postponed > 0
|
||||
? '今天的复习时间用完了,还有 $postponed 项到期,明天接着做,没有被丢掉。'
|
||||
: '记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。',
|
||||
),
|
||||
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.state.needsRecognitionGate(item) &&
|
||||
!gateCleared.contains(item.id)) {
|
||||
return _recognitionView(item);
|
||||
}
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
@@ -337,17 +550,33 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
final audioText = reviewAudioText(item.id, item.target);
|
||||
final checkpoint =
|
||||
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
|
||||
final checkpointLabel = checkpoint >= 4
|
||||
final isRecap = item.kind == ReviewKind.recap;
|
||||
final checkpointLabel = isRecap
|
||||
? '不计检查点 · 只是趁热再想一遍'
|
||||
: checkpoint >= 4
|
||||
? '30 天抽查'
|
||||
: '第 ${checkpoint + 1} / 4 个间隔检查点';
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
|
||||
Eyebrow(
|
||||
isRecap
|
||||
? '再想一遍 · 还有 ${widget.state.reviewSession.length} 项'
|
||||
: '今天复习 · ${widget.state.reviewSession.length} 项待完成',
|
||||
),
|
||||
Text(
|
||||
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。',
|
||||
dictation != null
|
||||
? '听一听,写下来。'
|
||||
: isRecap
|
||||
? '刚学过的,现在还想得起来吗?'
|
||||
: '不看答案,试着回答。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (isRecap)
|
||||
Text(
|
||||
'答不出也不算错,看一眼答案就好——这一次只是帮你记住,真正的检查在明天。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
Text(
|
||||
'目标技能:${item.skill}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -465,10 +694,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('暂时想不起来'),
|
||||
onPressed: () {
|
||||
widget.state.reportReviewFailure(item);
|
||||
setState(_resetAnswer);
|
||||
},
|
||||
onPressed: () => _reportMiss(item),
|
||||
),
|
||||
ActionChip(
|
||||
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
|
||||
@@ -509,6 +735,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: Text('听示范:$audioText'),
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../home/home_page.dart";
|
||||
import "../lesson/lesson_flow.dart";
|
||||
import "../profile/profile_page.dart";
|
||||
import "../review/review_page.dart";
|
||||
import "../words/words_page.dart";
|
||||
|
||||
class LearningShell extends StatefulWidget {
|
||||
const LearningShell({super.key, required this.state});
|
||||
@@ -197,6 +198,11 @@ class _LearningShellState extends State<LearningShell> {
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: "复习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
@@ -240,6 +246,7 @@ class _LearningShellState extends State<LearningShell> {
|
||||
.firstOrNull;
|
||||
if (pack != null) showAssessment(pack);
|
||||
},
|
||||
onOpenWords: () => showTab(AppTab.words),
|
||||
);
|
||||
case AppTab.dialogue:
|
||||
return DialogueScenePage(
|
||||
@@ -252,6 +259,8 @@ class _LearningShellState extends State<LearningShell> {
|
||||
onFinished: () => showTab(AppTab.learn),
|
||||
onOpenAdaptiveLesson: showAdaptiveLesson,
|
||||
);
|
||||
case AppTab.words:
|
||||
return WordsPage(state: widget.state);
|
||||
case AppTab.profile:
|
||||
return ProfilePage(
|
||||
state: widget.state,
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// What the round of recognition is about right now. The home card and this
|
||||
/// page share it so the two never describe today's words differently.
|
||||
String wordTaskHeadline(AppState state) {
|
||||
final due = state.dueWords.length;
|
||||
if (due > 0) return '今天有 $due 个词该过一遍';
|
||||
final unasked = state.unaskedWords.length;
|
||||
if (unasked > 0) return '有 $unasked 个新学的词还没问过';
|
||||
return '今天还能学 ${state.newWordQuota} 个新词';
|
||||
}
|
||||
|
||||
/// The word list the learner can open on its own (learning engine 3.5).
|
||||
///
|
||||
/// Words are never asked to be produced here: the only task is a 5–10 second
|
||||
/// recognition question, and the rest of the page is just what has been met.
|
||||
class WordsPage extends StatefulWidget {
|
||||
const WordsPage({super.key, required this.state});
|
||||
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
State<WordsPage> createState() => _WordsPageState();
|
||||
}
|
||||
|
||||
class _WordsPageState extends State<WordsPage> {
|
||||
List<WordQuestion>? quiz;
|
||||
int index = 0;
|
||||
String? picked;
|
||||
int right = 0;
|
||||
|
||||
void _startQuiz() {
|
||||
final plan = widget.state.wordSession();
|
||||
if (plan.isEmpty) return;
|
||||
setState(() {
|
||||
quiz = plan;
|
||||
index = 0;
|
||||
picked = null;
|
||||
right = 0;
|
||||
});
|
||||
_playIfHeard(plan.first);
|
||||
}
|
||||
|
||||
/// A listening question plays itself: the learner should not have to find a
|
||||
/// button before the question has even been asked.
|
||||
void _playIfHeard(WordQuestion question) {
|
||||
if (question.mode != WordAskMode.listen) return;
|
||||
VoiceService.instance.speak(wordEnglish(question.id));
|
||||
}
|
||||
|
||||
void _answer(WordQuestion question, String option) {
|
||||
final correct = option == question.answer;
|
||||
widget.state.recordWordRecognition(question.id, correct: correct);
|
||||
setState(() {
|
||||
picked = option;
|
||||
if (correct) right += 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _next() {
|
||||
final last = index + 1 >= quiz!.length;
|
||||
setState(() {
|
||||
if (last) {
|
||||
quiz = null;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
picked = null;
|
||||
});
|
||||
if (!last) _playIfHeard(quiz![index]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (quiz case final questions?) return _quizView(questions[index]);
|
||||
return _listView();
|
||||
}
|
||||
|
||||
Widget _quizView(WordQuestion question) {
|
||||
final answered = picked != null;
|
||||
final english = wordEnglish(question.id);
|
||||
final ipa = wordIpa(question.id);
|
||||
// Before the answer each way round gives a different clue; after it all
|
||||
// three land on the spelling, which is what the question was for.
|
||||
// Withholding it earlier is the question: a heard word shown spelled is
|
||||
// a reading question, and a meaning shown next to its word is no question
|
||||
// at all.
|
||||
final prompt = switch (question.mode) {
|
||||
WordAskMode.read => english,
|
||||
WordAskMode.listen => answered ? english : '· · ·',
|
||||
WordAskMode.recall => answered ? english : question.shown,
|
||||
};
|
||||
// Speaking the word before a recall answer would just read it out.
|
||||
final canSpeak = answered || question.mode != WordAskMode.recall;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow(
|
||||
'${switch (question.mode) {
|
||||
WordAskMode.read => '认词',
|
||||
WordAskMode.listen => '听词',
|
||||
WordAskMode.recall => '想词',
|
||||
}} · ${index + 1} / ${quiz!.length}',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
prompt,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (answered &&
|
||||
ipa.isNotEmpty &&
|
||||
question.mode != WordAskMode.read)
|
||||
Text(ipa, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canSpeak)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
question.mode == WordAskMode.listen
|
||||
? Icons.replay_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
),
|
||||
tooltip: question.mode == WordAskMode.listen ? '再听一遍' : null,
|
||||
onPressed: () => VoiceService.instance.speak(english),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(switch (question.mode) {
|
||||
WordAskMode.read => '它是什么意思?只要认出来就行,不用会说。',
|
||||
WordAskMode.listen => '听到的是哪个意思?听不清可以再听一遍。',
|
||||
WordAskMode.recall => '哪个词是这个意思?认出来就行,不用自己拼。',
|
||||
}),
|
||||
for (final option in question.options)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: answered ? null : () => _answer(question, option),
|
||||
style: !answered || option != question.answer
|
||||
? null
|
||||
: OutlinedButton.styleFrom(
|
||||
backgroundColor: AppColors.softGreen,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (answered) ...[
|
||||
SectionCard(
|
||||
tint: picked == question.answer
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
picked == question.answer
|
||||
? '对了。'
|
||||
: question.mode == WordAskMode.recall
|
||||
? '「${question.shown}」是 $english,明天再问你一次。'
|
||||
: '$english 是「${question.answer}」,明天再问你一次。',
|
||||
style: TextStyle(color: AppColors.warmInk, height: 1.45),
|
||||
),
|
||||
// The sentence is the point of the feedback: a gloss alone is
|
||||
// what did not stick the first time.
|
||||
if (WordBank.instance.exampleOf(question.id) case final sample?) ...[
|
||||
Text(
|
||||
sample.en,
|
||||
style: TextStyle(
|
||||
color: AppColors.warmInk,
|
||||
fontSize: 16,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
if (sample.zh.isNotEmpty)
|
||||
Text(
|
||||
sample.zh,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (WordBank.instance.moreSenses(question.id) case final more
|
||||
when more.isNotEmpty)
|
||||
Text(
|
||||
'也有「$more」的意思。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: index + 1 >= quiz!.length ? '做完了' : '下一个',
|
||||
onPressed: _next,
|
||||
),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
quiz = null;
|
||||
picked = null;
|
||||
}),
|
||||
child: const Text('先不做了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _listView() {
|
||||
final state = widget.state;
|
||||
final shaky = state.shakyWords;
|
||||
final grouped = state.wordsByUnit;
|
||||
final waiting = state.unstartedBankWordCount;
|
||||
final quota = state.newWordQuota;
|
||||
final planned = state.wordSessionPreviewSize;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('学过的词'),
|
||||
Text(
|
||||
grouped.isEmpty
|
||||
? '还没有学过的词'
|
||||
: '认识 ${state.recognizedWordCount} · 熟悉 ${state.familiarWordCount}',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('这些词只要听到、看到能认出来就够了,不用背着写出来。'),
|
||||
if (planned > 0)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
wordTaskHeadline(state),
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
Text(
|
||||
'$planned 个题,一个题几秒钟。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
PrimaryButton(label: '开始认词', onPressed: _startQuiz),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (grouped.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(
|
||||
quota > 0
|
||||
? '今天的词都过完了,剩下的等到期再问。'
|
||||
: '今天的新词学满 ${state.newWordsPerDay} 个了,到期的也都过完了。'
|
||||
'明天它们会回来找你。',
|
||||
),
|
||||
),
|
||||
if (waiting > 0)
|
||||
Text(
|
||||
quota > 0
|
||||
? '词库里还有 $waiting 个词没开始,今天还能学 $quota 个。'
|
||||
: '词库里还有 $waiting 个词没开始,明天继续。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (shaky.isNotEmpty) ...[
|
||||
const Eyebrow('没记住的'),
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in shaky.take(8)) _WordRow(id: id, state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
for (final entry in grouped.entries) ...[
|
||||
Eyebrow(_unitTitle(entry.key)),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in entry.value.take(_groupLimit))
|
||||
_WordRow(id: id, state: state),
|
||||
if (entry.value.length > _groupLimit)
|
||||
Text(
|
||||
'还有 ${entry.value.length - _groupLimit} 个',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A unit group is a reminder of what has been met, not a dictionary, so it
|
||||
/// stops before the bank turns it into an endless scroll.
|
||||
static const _groupLimit = 30;
|
||||
|
||||
String _unitTitle(String unit) {
|
||||
if (unit.isEmpty) return '查词收藏';
|
||||
if (WordBank.isBankUnit(unit)) return '词库 · ${WordBank.levelOfUnit(unit)}';
|
||||
final lesson = allLessons.where((item) => item.id == unit);
|
||||
return lesson.isEmpty ? unit : lesson.first.title;
|
||||
}
|
||||
}
|
||||
|
||||
class _WordRow extends StatelessWidget {
|
||||
const _WordRow({required this.id, required this.state});
|
||||
|
||||
final String id;
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = state.wordStatus(id);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
wordEnglish(id),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
wordIpa(id).isEmpty
|
||||
? wordMeaning(id)
|
||||
: '${wordIpa(id)} ${wordMeaning(id)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
},
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../core/models.dart';
|
||||
import '../core/courses/courses.dart';
|
||||
import '../core/voice_service.dart';
|
||||
import 'app_widgets.dart';
|
||||
import 'usage_card.dart';
|
||||
|
||||
List<VocabularyItem>? _cachedEntries;
|
||||
Map<String, VocabularyItem>? _cachedExactMap;
|
||||
@@ -454,7 +455,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将短语 "${phrase.phrase}" 加入复习计划'),
|
||||
content: Text('已加入单词,之后在「单词」里认它'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -469,7 +470,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将 "${item.word}" 加入复习计划'),
|
||||
content: Text('已加入单词,之后在「单词」里认它'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -663,7 +664,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
_addedToReview.contains(phrase.phrase.toLowerCase())
|
||||
? Chip(
|
||||
label: Text(
|
||||
'已在复习',
|
||||
'已在单词',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
avatar: Icon(
|
||||
@@ -688,7 +689,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
size: 14,
|
||||
),
|
||||
label: const Text(
|
||||
'加复习',
|
||||
'加到单词',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -843,14 +844,15 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
|
||||
),
|
||||
UsageCard(itemId: entry!.id, state: widget.state),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label:
|
||||
_addedToReview.contains(entry!.word.toLowerCase())
|
||||
? '已在复习中'
|
||||
: '加入复习',
|
||||
? '已在单词表'
|
||||
: '加到单词',
|
||||
onPressed: () {
|
||||
_addVocabItemToReview(entry!);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/app_state.dart';
|
||||
import '../core/app_theme.dart';
|
||||
import '../core/courses/courses.dart';
|
||||
import 'app_widgets.dart';
|
||||
|
||||
/// What a taught pattern is *for*, next to what it means. Knowing that
|
||||
/// `How are you?` means 你怎么样 does not tell a learner that it is not the
|
||||
/// way to ask someone's name, so the course states the situation, the reply
|
||||
/// it invites, and the items it is easiest to mix up with.
|
||||
///
|
||||
/// The card renders nothing when the pack explains nothing about the item,
|
||||
/// which is the normal case for a plain word.
|
||||
class UsageCard extends StatelessWidget {
|
||||
const UsageCard({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
required this.state,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final String itemId;
|
||||
final AppState state;
|
||||
|
||||
/// Inside a task step, only the situation and the difference are shown —
|
||||
/// the learner is mid-exercise, not reading a reference card.
|
||||
final bool compact;
|
||||
|
||||
/// A pattern counts as taught once it has a mastery row, which
|
||||
/// `AppState` creates when the lesson first presents it.
|
||||
bool _isTaught(String id) => state.mastery.containsKey(id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final usage = coreUsage(itemId);
|
||||
if (usage == null) return const SizedBox.shrink();
|
||||
final lines = <({String label, String text})>[
|
||||
if (usage.when.isNotEmpty) (label: '什么时候用', text: usage.when),
|
||||
if (!compact && usage.reply.isNotEmpty) (label: '对方会怎么答', text: usage.reply),
|
||||
if (!compact && usage.swap.isNotEmpty) (label: '换个说法', text: usage.swap),
|
||||
// Mid-exercise only the nearest confusion is worth a line; the full
|
||||
// list belongs on the preview and lookup cards.
|
||||
for (final other in visibleConfusables(
|
||||
itemId,
|
||||
isTaught: _isTaught,
|
||||
).take(compact ? 1 : 3))
|
||||
if (other.note.isNotEmpty)
|
||||
(label: '别和 ${coreItemSpoken(other.id)} 弄混', text: other.note),
|
||||
];
|
||||
if (lines.isEmpty) return const SizedBox.shrink();
|
||||
return SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final line in lines)
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: AppColors.warmInk,
|
||||
height: 1.45,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${line.label}:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextSpan(text: line.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every usage note a teaching segment carries, in course order. Shown in the
|
||||
/// speaking and writing steps, where a learner is about to produce the pattern
|
||||
/// and the question is which one this situation calls for.
|
||||
class SegmentUsage extends StatelessWidget {
|
||||
const SegmentUsage({
|
||||
super.key,
|
||||
required this.segmentId,
|
||||
required this.state,
|
||||
this.compact = true,
|
||||
});
|
||||
|
||||
final String segmentId;
|
||||
final AppState state;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ids = findLessonSegment(segmentId)?.targetItemIds ?? const <String>[];
|
||||
final explained = [for (final id in ids) if (coreUsage(id) != null) id];
|
||||
if (explained.isEmpty) return const SizedBox.shrink();
|
||||
return SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final id in explained)
|
||||
UsageCard(itemId: id, state: state, compact: compact),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ flutter:
|
||||
- assets/config/
|
||||
- assets/models/sense_voice/
|
||||
- assets/courses/
|
||||
- assets/words/
|
||||
- assets/courses/A0/
|
||||
- assets/courses/A1/
|
||||
- assets/courses/A2/
|
||||
|
||||
@@ -108,6 +108,13 @@ void main() {
|
||||
skill: '回忆表达',
|
||||
),
|
||||
);
|
||||
state.mastery['A0-P12'] = const MasteryItem(
|
||||
id: 'A0-P12',
|
||||
label: "I'm from …",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
|
||||
@@ -59,6 +59,99 @@ void main() {
|
||||
expect(failures, isEmpty);
|
||||
});
|
||||
|
||||
test('every A0 pattern says when it is used', () {
|
||||
final missing = [
|
||||
for (final pack in CourseRepository.instance.units)
|
||||
if (pack.level == 'A0')
|
||||
for (final item in pack.coreItems)
|
||||
if (item.type != 'word' && (coreUsage(item.id)?.when ?? '').isEmpty)
|
||||
'${item.id} "${item.en}"',
|
||||
];
|
||||
expect(
|
||||
missing,
|
||||
isEmpty,
|
||||
reason: '句型要写清什么时候用,否则学会了也不知道该用哪一句',
|
||||
);
|
||||
});
|
||||
|
||||
test('every confusable points at a real taught item with a note', () {
|
||||
final broken = <String>[];
|
||||
for (final pack in CourseRepository.instance.units) {
|
||||
for (final item in pack.coreItems) {
|
||||
for (final other in item.usage.confuse) {
|
||||
if (!isCoreItem(other.id)) broken.add('${item.id} → ${other.id} 不存在');
|
||||
if (other.note.isEmpty) broken.add('${item.id} → ${other.id} 没写区别');
|
||||
if (other.id == item.id) broken.add('${item.id} 指向自己');
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(broken, isEmpty);
|
||||
});
|
||||
|
||||
test('every A0 pattern has something to be told apart from', () {
|
||||
final alone = [
|
||||
for (final pack in CourseRepository.instance.units)
|
||||
if (pack.level == 'A0')
|
||||
for (final item in pack.coreItems)
|
||||
if (item.type != 'word' && item.usage.confuse.isEmpty)
|
||||
'${item.id} "${item.en}"',
|
||||
];
|
||||
expect(alone, isEmpty, reason: '每个句型都要有一句容易混的,才出得了辨析题');
|
||||
});
|
||||
|
||||
test('a pattern can be told apart from something already taught', () {
|
||||
final order = {
|
||||
for (var i = 0; i < CourseRepository.instance.units.length; i++)
|
||||
CourseRepository.instance.units[i].id: i,
|
||||
};
|
||||
final late = <String>[];
|
||||
for (final pack in CourseRepository.instance.units) {
|
||||
if (pack.level != 'A0') continue;
|
||||
for (final item in pack.coreItems) {
|
||||
if (item.type == 'word' || item.usage.confuse.isEmpty) continue;
|
||||
// The first review of an item comes a day after its own unit, so a
|
||||
// pairing that only names later units has nothing to ask about then.
|
||||
final reachable = item.usage.confuse.any((other) {
|
||||
final unit = coreItemRegistry[other.id]?.unit;
|
||||
return unit != null && (order[unit] ?? 999) <= order[pack.id]!;
|
||||
});
|
||||
if (!reachable) late.add('${item.id} 的易混句都在后面的单元');
|
||||
}
|
||||
}
|
||||
expect(late, isEmpty);
|
||||
});
|
||||
|
||||
test('a contrast question offers the item and something else to pick', () {
|
||||
final broken = <String>[];
|
||||
for (final id in allCoreItemIds) {
|
||||
if ((coreUsage(id)?.confuse ?? const []).isEmpty) continue;
|
||||
final question = coreContrastQuestion(id);
|
||||
if (question == null) {
|
||||
broken.add('$id has confusables but no question');
|
||||
continue;
|
||||
}
|
||||
// Two options is a real choice; more than three turns recall into a
|
||||
// reading exercise.
|
||||
if (question.options.length < 2 || question.options.length > 3) {
|
||||
broken.add('$id has ${question.options.length} options');
|
||||
}
|
||||
// A situation that quotes one of the sentences gives the choice away.
|
||||
for (final option in question.options) {
|
||||
if (question.situation.contains(option)) {
|
||||
broken.add('$id 的情境里写出了选项「$option」');
|
||||
}
|
||||
}
|
||||
if (!question.options.contains(question.answer)) {
|
||||
broken.add('$id answer is not among the options');
|
||||
}
|
||||
// Two sentences a learner has to tell apart cannot be the same string.
|
||||
if (question.options.toSet().length != question.options.length) {
|
||||
broken.add('$id repeats an option');
|
||||
}
|
||||
}
|
||||
expect(broken, isEmpty);
|
||||
});
|
||||
|
||||
test('assessment tasks are answerable and replacements point home', () {
|
||||
final ids = {for (final pack in assessmentPacks) pack.id};
|
||||
final failures = <String>[];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/features/shell/learning_shell.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// The word bank is reachable from its own tab, but nothing on the 学习 tab
|
||||
/// mentioned it, so a learner who opens the app to today's task never saw
|
||||
/// that words were waiting. These cover the entry point, not the quiz.
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
await WordBank.instance.load();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
});
|
||||
|
||||
testWidgets('the 学习 tab offers a round of words and opens the word tab', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('认词'), findsOneWidget);
|
||||
expect(find.text('今天还能学 15 个新词'), findsOneWidget);
|
||||
expect(find.text('15 个题,听到、看到能认出来就行,不用会说。'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('学过的词'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the entry stays once the day is done, as a line not a task', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
for (final question in state.wordSession()) {
|
||||
state.recordWordRecognition(question.id, correct: true);
|
||||
}
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordSessionPreviewSize, 0);
|
||||
expect(find.text('开始认词'), findsNothing);
|
||||
expect(find.text('今天的新词学满 15 个了,明天继续'), findsOneWidget);
|
||||
expect(find.text('认识 15 · 熟悉 0'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('今天的新词学满 15 个了,明天继续'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('学过的词'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,8 @@ void main() {
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(4));
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
||||
expect(find.text('单词'), findsOneWidget);
|
||||
expect(find.text('今日新课'), findsOneWidget);
|
||||
expect(find.text('课程路径'), findsOneWidget);
|
||||
expect(find.textContaining('第 1 课 · '), findsWidgets);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/course_pack.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/words/words_page.dart';
|
||||
|
||||
/// Learning engine 3.5: the recognition-only layer — one success to 「认识」,
|
||||
/// a second one at least seven days later to 「熟悉」, a monthly spot check
|
||||
/// after that, and a miss that only ever drops back to 「认识」.
|
||||
/// Calendar days from today to the day the word is next due.
|
||||
///
|
||||
/// A miss schedules by wall clock (tomorrow morning), not by adding 24 hours,
|
||||
/// so measuring the gap as a duration answers 0 for a test run in the evening.
|
||||
/// Counting days on the calendar is what the ladder actually means.
|
||||
int gapDays(DateTime? due) {
|
||||
final today = DateTime.now();
|
||||
return DateTime(due!.year, due.month, due.day)
|
||||
.difference(DateTime(today.year, today.month, today.day))
|
||||
.inDays;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
registerSavedWord('t-ready', en: 'ready', zh: '准备好', level: 'A0');
|
||||
registerSavedWord('t-later', en: 'later', zh: '晚点', level: 'A0');
|
||||
registerSavedWord('t-busy', en: 'busy', zh: '忙', level: 'A0');
|
||||
registerSavedWord('t-tired', en: 'tired', zh: '累', level: 'A0');
|
||||
registerSavedWord('t-early', en: 'early', zh: '早', level: 'A0');
|
||||
});
|
||||
|
||||
test('a met word starts as 新学 with nothing scheduled', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.newWord);
|
||||
expect(word.dueAt, isNull);
|
||||
expect(state.unaskedWords, contains('t-ready'));
|
||||
});
|
||||
|
||||
test('one success reaches 认识 and comes back the next day', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.step, 1);
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
expect(state.dueWords, isEmpty);
|
||||
});
|
||||
|
||||
test('each success in a row stretches the gap', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
for (final expected in [1, 3, 7, 15, 30, 60, 120, 120]) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(gapDays(state.wordKnowledge['t-ready']!.dueAt), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('successes in a row are not enough for 熟悉 without the days', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
for (var i = 0; i < 5; i++) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
}
|
||||
// Five rungs climbed inside one minute: the calendar has not moved.
|
||||
expect(state.wordKnowledge['t-ready']!.status, WordStatus.recognized);
|
||||
});
|
||||
|
||||
test('the fourth success a week on reaches 熟悉', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.recognized,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 8)),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
step: 3,
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.familiar);
|
||||
expect(gapDays(word.dueAt), 15);
|
||||
});
|
||||
|
||||
test('a miss drops 熟悉 back to 认识 and asks again tomorrow', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.familiar,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 40)),
|
||||
dueAt: DateTime.now(),
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.misses, 1);
|
||||
expect(word.step, 0, reason: 'a miss goes back to the first rung');
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
expect(state.shakyWords, contains('t-ready'));
|
||||
});
|
||||
|
||||
test('a miss never touches the core mastery of an item', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
expect(state.mastery, isEmpty);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.knownItemCount, 0);
|
||||
});
|
||||
|
||||
test('a recovered word has to wait another week for 熟悉', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.familiar,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 40)),
|
||||
dueAt: DateTime.now(),
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.step, 1, reason: 'the ladder is climbed again from the bottom');
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
});
|
||||
|
||||
test('a snapshot written before the ladder lands on a sensible rung', () {
|
||||
final state = AppState();
|
||||
state.restoreForTest({
|
||||
'wordKnowledge': [
|
||||
{
|
||||
'id': 't-ready',
|
||||
'status': 'familiar',
|
||||
'recognizedAt': DateTime.now()
|
||||
.subtract(const Duration(days: 40))
|
||||
.toIso8601String(),
|
||||
'dueAt': DateTime.now().toIso8601String(),
|
||||
'misses': 0,
|
||||
},
|
||||
{'id': 't-busy', 'status': 'recognized', 'misses': 1},
|
||||
{'id': 't-later', 'status': 'newWord', 'misses': 0},
|
||||
],
|
||||
});
|
||||
expect(state.wordKnowledge['t-ready']!.step, 4);
|
||||
expect(state.wordKnowledge['t-busy']!.step, 1);
|
||||
expect(state.wordKnowledge['t-later']!.step, 0);
|
||||
});
|
||||
|
||||
test('a question shows the word and offers the meaning among others', () {
|
||||
final question = receptiveQuestion('t-ready')!;
|
||||
expect(question.shown, 'ready');
|
||||
expect(question.options, contains('准备好'));
|
||||
expect(question.options.length, greaterThanOrEqualTo(2));
|
||||
expect(question.options.toSet().length, question.options.length);
|
||||
});
|
||||
|
||||
test('a re-ask after a miss uses another set of wrong meanings', () {
|
||||
final first = receptiveQuestion('t-ready', variant: 0)!;
|
||||
final second = receptiveQuestion('t-ready', variant: 1)!;
|
||||
expect(first.options.toSet(), isNot(second.options.toSet()));
|
||||
});
|
||||
|
||||
test('word questions take no more than a third of the review budget', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
expect(state.wordBudgetSeconds * 3, lessThanOrEqualTo(state.reviewBudgetSeconds));
|
||||
for (var i = 0; i < 60; i++) {
|
||||
state.wordKnowledge['t-ready$i'] = WordKnowledge(
|
||||
id: 't-ready$i',
|
||||
status: WordStatus.newWord,
|
||||
);
|
||||
registerSavedWord('t-ready$i', en: 'w$i', zh: '意思$i', level: 'A0');
|
||||
}
|
||||
expect(state.todayWordPlan.length, state.wordPlanSize);
|
||||
expect(state.wordPlanSize, lessThan(60));
|
||||
});
|
||||
|
||||
test('a saved lookup word is recognition-only, not a checkpoint task', () {
|
||||
final state = AppState();
|
||||
state.addSavedWord(
|
||||
const VocabularyItem(
|
||||
id: 'saved-ready',
|
||||
word: 'ready',
|
||||
meaning: '准备好',
|
||||
example: '',
|
||||
exampleMeaning: '',
|
||||
),
|
||||
);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.mastery, isEmpty);
|
||||
expect(state.wordStatus('saved-ready'), WordStatus.newWord);
|
||||
expect(isReceptiveWord('saved-ready'), isTrue);
|
||||
expect(state.wordsByUnit[''], contains('saved-ready'));
|
||||
});
|
||||
|
||||
test('word progress survives a save and reload', () async {
|
||||
final state = AppState();
|
||||
state.addSavedWord(
|
||||
const VocabularyItem(
|
||||
id: 'saved-later',
|
||||
word: 'later',
|
||||
meaning: '晚点',
|
||||
example: '',
|
||||
exampleMeaning: '',
|
||||
),
|
||||
);
|
||||
state.recordWordRecognition('saved-later', correct: true);
|
||||
|
||||
final restored = AppState()..restoreForTest(state.snapshotForTest());
|
||||
expect(restored.wordStatus('saved-later'), WordStatus.recognized);
|
||||
expect(restored.savedWords['saved-later']?.en, 'later');
|
||||
expect(wordEnglish('saved-later'), 'later');
|
||||
});
|
||||
|
||||
test('finishing a unit puts its receptive words on the word page', () {
|
||||
registerReceptiveWord(
|
||||
const ReceptiveWord(id: 'u-repeat', en: 'repeat', zh: '重复'),
|
||||
level: 'A0',
|
||||
unit: 'a0-01',
|
||||
);
|
||||
final state = AppState();
|
||||
state.meetWordsOfUnit('a0-01');
|
||||
expect(state.wordsByUnit['a0-01'], contains('u-repeat'));
|
||||
expect(state.wordStatus('u-repeat'), WordStatus.newWord);
|
||||
// Meeting a word is exposure, so nothing is scheduled by itself.
|
||||
expect(state.dueWords, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('the word page asks a word and records the answer', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final question = state.todayWordPlan.first;
|
||||
expect(question.id, 't-ready');
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, '准备好'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordStatus('t-ready'), WordStatus.recognized);
|
||||
expect(find.text('对了。'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('a word is introduced in writing, then heard, then recalled', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
// Nothing to hold on to yet, so the first sight of a word is spelled out.
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.read);
|
||||
for (final expected in [
|
||||
WordAskMode.listen,
|
||||
WordAskMode.recall,
|
||||
WordAskMode.read,
|
||||
WordAskMode.listen,
|
||||
WordAskMode.recall,
|
||||
]) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a recall question asks for the word and offers spellings', () {
|
||||
final question = receptiveQuestion('t-ready', mode: WordAskMode.recall)!;
|
||||
expect(question.shown, '准备好');
|
||||
expect(question.answer, 'ready');
|
||||
expect(question.options, contains('ready'));
|
||||
expect(question.options, hasLength(3));
|
||||
for (final option in question.options) {
|
||||
// Options are spellings; a meaning among them would give the answer away.
|
||||
expect(option, matches(RegExp(r'^[a-zA-Z ]+$')), reason: option);
|
||||
}
|
||||
});
|
||||
|
||||
test('no two options mean the same thing', () {
|
||||
// A second word glossed 「准备好」 would make both spellings right.
|
||||
registerSavedWord('t-set', en: 'set', zh: '准备好', level: 'A0');
|
||||
final question = receptiveQuestion('t-ready', mode: WordAskMode.recall)!;
|
||||
expect(question.options, isNot(contains('set')));
|
||||
});
|
||||
|
||||
test('the day after a miss shows the spelling again', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.listen);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
// A miss is no time to take the spelling away on top of it.
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.read);
|
||||
});
|
||||
|
||||
test('a round carries the mode each word is due for', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.meetWord('t-later');
|
||||
state.recordWordRecognition('t-later', correct: true);
|
||||
state.wordKnowledge['t-later'] = state.wordKnowledge['t-later']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
final modes = {
|
||||
for (final question in state.wordSession()) question.id: question.mode,
|
||||
};
|
||||
expect(modes['t-ready'], WordAskMode.read);
|
||||
expect(modes['t-later'], WordAskMode.listen);
|
||||
});
|
||||
|
||||
testWidgets('a listening question withholds the spelling until answered', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
state.wordKnowledge['t-ready'] = state.wordKnowledge['t-ready']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('听词 · 1 / 1'), findsOneWidget);
|
||||
// Showing 'ready' here would turn the question back into a reading one.
|
||||
// The list behind the quiz is gone, so this is the whole screen.
|
||||
expect(find.text('ready'), findsNothing);
|
||||
expect(find.text('· · ·'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.replay_outlined), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, '准备好'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Now it is worth seeing: this is where the sound meets the spelling.
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
expect(state.wordKnowledge['t-ready']!.step, 2);
|
||||
});
|
||||
|
||||
testWidgets('a recall question asks from the meaning, with nothing to play', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.recall);
|
||||
state.wordKnowledge['t-ready'] = state.wordKnowledge['t-ready']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('想词 · 1 / 1'), findsOneWidget);
|
||||
// The prompt is the meaning, and it is the only Chinese on screen: the
|
||||
// options are spellings.
|
||||
expect(find.text('准备好'), findsOneWidget);
|
||||
// Playing the word here would simply read out the answer.
|
||||
expect(find.byIcon(Icons.volume_up_outlined), findsNothing);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'ready'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Once the answer is in: the prompt turns into the spelling, which now
|
||||
// stands beside the option that was picked, and the word can be played.
|
||||
expect(find.text('ready'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.volume_up_outlined), findsOneWidget);
|
||||
expect(state.wordKnowledge['t-ready']!.step, 3);
|
||||
});
|
||||
|
||||
testWidgets('a wrong pick tells the learner the meaning', (tester) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final wrong = state.todayWordPlan.first.options.firstWhere(
|
||||
(option) => option != '准备好',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, wrong));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordKnowledge['t-ready']!.misses, 1);
|
||||
expect(find.textContaining('准备好'), findsWidgets);
|
||||
});
|
||||
}
|
||||
@@ -106,6 +106,13 @@ void main() {
|
||||
skill: '回忆表达',
|
||||
),
|
||||
);
|
||||
state.mastery['A0-P12'] = const MasteryItem(
|
||||
id: 'A0-P12',
|
||||
label: "I'm from …",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
final content = jsonEncode({
|
||||
'schemaVersion': 'writing-feedback-1',
|
||||
'verdict': 'rewrite',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
/// Learning engine 2.2/2.2a/3.1: the same-day recall, the recognition step in
|
||||
/// front of the first checkpoint, the daily review budget, and the re-ask of
|
||||
/// an item missed earlier in the session.
|
||||
void main() {
|
||||
ReviewItem due(String id) => ReviewItem(
|
||||
id: id,
|
||||
target: coreItemEnglish(id),
|
||||
prompt: coreReviewTemplate(id).prompt,
|
||||
hint: coreItemEnglish(id),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
skill: '回忆表达',
|
||||
);
|
||||
|
||||
test('a same-day recap is scheduled for later today, not immediately', () {
|
||||
final state = AppState();
|
||||
state.scheduleSameDayRecap(['A0-W36']);
|
||||
final recap = state.reviewQueue.singleWhere(
|
||||
(item) => item.kind == ReviewKind.recap,
|
||||
);
|
||||
final now = DateTime.now();
|
||||
expect(recap.dueAt.isAfter(now), isTrue);
|
||||
// 「同一天」指的是隔几小时再提取一次,不是明天再说。深夜学习时它会落在
|
||||
// 次日凌晨,那仍然是同一轮学习,所以这里比的是间隔而不是日历日。
|
||||
expect(recap.dueAt.difference(now).inMinutes, closeTo(120, 2));
|
||||
// It is not offered until it comes due: the point is the delay.
|
||||
expect(state.dueRecaps, isEmpty);
|
||||
});
|
||||
|
||||
test('finishing a same-day recap advances no checkpoint', () {
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(
|
||||
due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()),
|
||||
);
|
||||
state.completeReview(state.dueRecaps.single, assisted: false);
|
||||
expect(state.mastery['A0-W36']?.checkpoint ?? 0, 0);
|
||||
expect(state.reviewQueue.where((i) => i.kind == ReviewKind.recap), isEmpty);
|
||||
});
|
||||
|
||||
test('missing a same-day recap is not a language failure', () {
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(
|
||||
due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()),
|
||||
);
|
||||
state.reportReviewFailure(state.dueRecaps.single);
|
||||
expect(state.mastery['A0-W36']?.needsReview ?? false, isFalse);
|
||||
expect(state.dueRecaps, isEmpty);
|
||||
});
|
||||
|
||||
test('the first checkpoint opens with a recognition question', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
expect(state.needsRecognitionGate(item), isTrue);
|
||||
expect(recognitionDistractors('A0-W36'), isNotEmpty);
|
||||
});
|
||||
|
||||
test('passing recognition records 认识 but no checkpoint', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: true);
|
||||
final mastery = state.mastery['A0-W36']!;
|
||||
expect(mastery.checkpoint, 0);
|
||||
expect(mastery.status, MasteryStatus.recognize);
|
||||
// The checkpoint is still earned by producing the item afterwards.
|
||||
state.completeReview(state.dueReviews.single, assisted: false);
|
||||
expect(state.mastery['A0-W36']!.checkpoint, 1);
|
||||
});
|
||||
|
||||
test('a recognition miss does not count against the item', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: false);
|
||||
final mastery = state.mastery['A0-W36']!;
|
||||
expect(mastery.needsReview, isFalse);
|
||||
expect(mastery.checkpoint, 0);
|
||||
});
|
||||
|
||||
test('a recognition success survives rebuilding mastery from evidence', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: true);
|
||||
state.rebuildMasteryFromEvidence();
|
||||
expect(state.mastery['A0-W36']!.checkpoint, 0);
|
||||
expect(state.mastery['A0-W36']!.status, MasteryStatus.recognize);
|
||||
});
|
||||
|
||||
test('due reviews beyond the daily budget wait, they are not dropped', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
for (final id in a0CoreItems.keys.take(10)) {
|
||||
state.reviewQueue.add(due(id));
|
||||
}
|
||||
expect(state.dueReviews.length, 10);
|
||||
expect(state.todayReviewPlan.length, 5);
|
||||
expect(state.postponedReviewCount, 5);
|
||||
// Nothing was rescheduled behind the learner's back.
|
||||
expect(state.dueReviews.length, 10);
|
||||
});
|
||||
|
||||
test('a missed item comes back later in the same session', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
state.reviewQueue.add(due('A0-W36'));
|
||||
state.reviewQueue.add(due('A0-W37'));
|
||||
state.reportReviewFailure(state.dueReviews.first);
|
||||
final session = state.reviewSession;
|
||||
// The retry is asked after the other due tasks, not straight away.
|
||||
expect(session.first.id, 'A0-W37');
|
||||
expect(session.last.id, 'A0-W36');
|
||||
expect(session.last.kind, ReviewKind.recap);
|
||||
|
||||
final checkpointBefore = state.mastery['A0-W36']!.checkpoint;
|
||||
state.completeReview(session.last, assisted: false);
|
||||
// The retry is practice: the real check still happens tomorrow.
|
||||
expect(state.mastery['A0-W36']!.checkpoint, checkpointBefore);
|
||||
expect(state.mastery['A0-W36']!.needsReview, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -169,14 +169,16 @@ void main() {
|
||||
contextSentence: "I'd like to check in please.",
|
||||
);
|
||||
|
||||
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
|
||||
final saved = state.reviewQueue.firstWhere(
|
||||
(r) => r.target == 'check in',
|
||||
// Learning engine 3.5: a saved phrase is an extension word. It is
|
||||
// recognition-only, so it goes to the word list, not the checkpoint
|
||||
// queue where it would be asked to be produced.
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
final id = state.savedWords.keys.singleWhere(
|
||||
(key) => state.savedWords[key]!.en == 'check in',
|
||||
);
|
||||
|
||||
expect(saved.hint, contains('办理登机或入住'));
|
||||
expect(saved.hint, contains('酒店机场高频词'));
|
||||
expect(saved.prompt, "I'd like to check in please.");
|
||||
expect(state.savedWords[id]!.zh, contains('办理登机或入住'));
|
||||
expect(state.savedWords[id]!.zh, contains('酒店机场高频词'));
|
||||
expect(state.wordStatus(id), WordStatus.newWord);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -234,19 +236,20 @@ void main() {
|
||||
expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
|
||||
expect(find.text('departure gate'), findsOneWidget);
|
||||
expect(find.text('登机口'), findsOneWidget);
|
||||
expect(find.text('加复习'), findsOneWidget);
|
||||
expect(find.text('加到单词'), findsOneWidget);
|
||||
|
||||
// Scroll to '加复习' and tap
|
||||
await tester.ensureVisible(find.text('加复习'));
|
||||
// Scroll to '加到单词' and tap
|
||||
await tester.ensureVisible(find.text('加到单词'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('加复习'));
|
||||
await tester.tap(find.text('加到单词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
state.reviewQueue.any((r) => r.target == 'departure gate'),
|
||||
state.savedWords.values.any((word) => word.en == 'departure gate'),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('已在复习'), findsOneWidget);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(find.text('已在单词'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/review/review_page.dart';
|
||||
|
||||
/// Knowing what a sentence means is not knowing when to say it. The packs
|
||||
/// carry that as `usage`; these cover what the app does with it — learning
|
||||
/// engine 3.1 (what counts as which evidence) and 3.5 (recognition inside a
|
||||
/// situation).
|
||||
void main() {
|
||||
ReviewItem due(String id) => ReviewItem(
|
||||
id: id,
|
||||
target: coreItemEnglish(id),
|
||||
prompt: coreReviewTemplate(id).prompt,
|
||||
hint: coreItemEnglish(id),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
skill: '回忆表达',
|
||||
);
|
||||
|
||||
test('the pack says when a pattern is used and what it is confused with', () {
|
||||
final usage = coreUsage('A0-P02')!;
|
||||
expect(usage.when, isNotEmpty);
|
||||
expect(usage.reply, isNotEmpty);
|
||||
expect(
|
||||
usage.confuse.map((entry) => entry.id),
|
||||
contains('A0-P05'),
|
||||
reason: 'What\'s your name? 和 How are you? 正是分不清的那一对',
|
||||
);
|
||||
});
|
||||
|
||||
test('a contrast question asks which sentence the situation calls for', () {
|
||||
final question = coreContrastQuestion('A0-P02')!;
|
||||
expect(question.situation, coreUsage('A0-P02')!.when);
|
||||
expect(question.answer, coreItemSpoken('A0-P02'));
|
||||
expect(question.options, contains(coreItemSpoken('A0-P05')));
|
||||
expect(question.note, isNotEmpty);
|
||||
});
|
||||
|
||||
test('an untaught confusable is left out of the question', () {
|
||||
// `How are you?` is taught two units later than `What's your name?`, so
|
||||
// on the day P02 is first reviewed it is not a choice the learner could
|
||||
// make sense of. Its same-unit partner still is.
|
||||
final early = coreContrastQuestion('A0-P02', isTaught: (_) => false)!;
|
||||
expect(early.options, isNot(contains(coreItemSpoken('A0-P05'))));
|
||||
expect(early.options, contains(coreItemSpoken('A0-P01')));
|
||||
// Once the learner has met it, it is the option worth offering.
|
||||
final later = coreContrastQuestion(
|
||||
'A0-P02',
|
||||
isTaught: (id) => id == 'A0-P05',
|
||||
)!;
|
||||
expect(later.options, contains(coreItemSpoken('A0-P05')));
|
||||
});
|
||||
|
||||
test('a contrast question never offers more than three sentences', () {
|
||||
// `What's your name?` is confused with three other taught sentences.
|
||||
expect(coreUsage('A0-P02')!.confuse.length, greaterThan(2));
|
||||
expect(coreContrastQuestion('A0-P02')!.options, hasLength(3));
|
||||
});
|
||||
|
||||
test('answering a contrast question records 认识, not a checkpoint', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
final mastery = state.mastery['A0-P02']!;
|
||||
expect(mastery.status, MasteryStatus.recognize);
|
||||
expect(mastery.checkpoint, 0);
|
||||
// It is not recall either: the learner picked the sentence, not said it.
|
||||
expect(mastery.status, isNot(MasteryStatus.recall));
|
||||
});
|
||||
|
||||
test('a contrast answer survives rebuilding mastery from evidence', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
state.rebuildMasteryFromEvidence();
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
expect(state.mastery['A0-P02']!.status, MasteryStatus.recognize);
|
||||
});
|
||||
|
||||
test('the contrast question cannot undo the miss that led to it', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.reportReviewFailure(item);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
});
|
||||
|
||||
test('getting the contrast wrong is not a language failure', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: false);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isFalse);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
});
|
||||
|
||||
testWidgets('a miss opens the explanation, then the contrast question', (
|
||||
tester,
|
||||
) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(MethodChannel(name), (_) async => null);
|
||||
}
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
final reportError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exception is! MissingPluginException) {
|
||||
reportError?.call(details);
|
||||
}
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = reportError);
|
||||
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(due('A0-P02'));
|
||||
// Past the first checkpoint, so the review opens on the production task
|
||||
// rather than the recognition question.
|
||||
state.mastery['A0-P02'] = const MasteryItem(
|
||||
id: 'A0-P02',
|
||||
label: "What's your name?",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ReviewPage(
|
||||
state: state,
|
||||
onFinished: () {},
|
||||
onOpenAdaptiveLesson: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final giveUp = find.text('暂时想不起来');
|
||||
await tester.ensureVisible(giveUp);
|
||||
await tester.tap(giveUp);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('先弄清楚什么时候用'), findsOneWidget);
|
||||
expect(find.textContaining(coreUsage('A0-P02')!.when), findsWidgets);
|
||||
|
||||
final checkpointAfterMiss = state.mastery['A0-P02']!.checkpoint;
|
||||
final wrong = find.widgetWithText(
|
||||
OutlinedButton,
|
||||
coreItemSpoken('A0-P01'),
|
||||
);
|
||||
await tester.ensureVisible(wrong);
|
||||
await tester.tap(wrong);
|
||||
await tester.pumpAndSettle();
|
||||
// The answer is named and the difference explained, and the miss stands.
|
||||
expect(
|
||||
find.textContaining('这里要说 ${coreItemSpoken('A0-P02')}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, checkpointAfterMiss);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
/// The word bank exists so vocabulary is not capped by lesson progress: a
|
||||
/// learner still on A0 must have words to recognise from the first day.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
await WordBank.instance.load();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
});
|
||||
|
||||
test('the bank carries a few thousand words with gloss and IPA', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
expect(ids.length, greaterThan(2000));
|
||||
for (final id in ids) {
|
||||
expect(wordEnglish(id), isNotEmpty, reason: id);
|
||||
expect(wordMeaning(id), isNotEmpty, reason: id);
|
||||
}
|
||||
final withIpa = ids.where((id) => wordIpa(id).isNotEmpty);
|
||||
expect(withIpa.length / ids.length, greaterThan(0.98));
|
||||
});
|
||||
|
||||
test('every level pool is filed under its own unit', () {
|
||||
final units = receptiveWordRegistry.values
|
||||
.map((word) => word.unit)
|
||||
.where(WordBank.isBankUnit)
|
||||
.toSet();
|
||||
expect(units, containsAll(['bank-A1', 'bank-A2', 'bank-B1']));
|
||||
expect(WordBank.levelOfUnit('bank-A2'), 'A2');
|
||||
});
|
||||
|
||||
test('new words are handed out by spoken frequency, not alphabetically', () {
|
||||
final state = AppState();
|
||||
final first = state.unstartedBankWords.take(12);
|
||||
final words = first.map(wordEnglish).toList();
|
||||
// Alphabetical order would open with `a.m.`, `ability`, `able`.
|
||||
expect(words, contains('know'));
|
||||
expect(words.first, isNot('a.m.'));
|
||||
final ranks = first.map(WordBank.instance.rankOf).toList();
|
||||
expect(ranks, orderedEquals(ranks.toList()..sort()));
|
||||
});
|
||||
|
||||
test('almost every word has a real frequency rank', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
final ranked = ids.where(
|
||||
(id) => WordBank.instance.rankOf(id) != WordBank.unranked,
|
||||
);
|
||||
expect(ranked.length / ids.length, greaterThan(0.99));
|
||||
});
|
||||
|
||||
test('a fresh learner has bank words waiting and none started', () {
|
||||
final state = AppState();
|
||||
expect(state.wordKnowledge, isEmpty);
|
||||
expect(state.unstartedBankWordCount, greaterThan(2000));
|
||||
expect(state.wordSessionPreviewSize, state.newWordsPerDay);
|
||||
});
|
||||
|
||||
test('a round hands out new bank words, easiest level first', () {
|
||||
final state = AppState();
|
||||
final plan = state.wordSession(size: 5);
|
||||
expect(plan, hasLength(5));
|
||||
for (final question in plan) {
|
||||
expect(question.options, contains(question.answer));
|
||||
expect(question.options, hasLength(3));
|
||||
expect(receptiveWordRegistry[question.id]!.level, 'A1');
|
||||
expect(state.wordStatus(question.id), WordStatus.newWord);
|
||||
}
|
||||
});
|
||||
|
||||
test('the day has a cap on how many new words the bank starts', () {
|
||||
final state = AppState();
|
||||
expect(state.newWordsPerDay, 15, reason: 'the 20-minute default');
|
||||
final first = state.wordSession(size: 20);
|
||||
expect(first, hasLength(15), reason: 'a round stops at the cap');
|
||||
expect(state.newWordQuota, 0);
|
||||
// A second round adds nothing new. It re-offers the same words, which is
|
||||
// right: a round left half-finished should be finishable.
|
||||
final second = state.wordSession(size: 20);
|
||||
expect(second.map((q) => q.id), first.map((q) => q.id));
|
||||
expect(state.newWordsStartedToday, 15);
|
||||
});
|
||||
|
||||
test('the cap follows the daily time budget', () {
|
||||
final state = AppState();
|
||||
state.setDailyMinutes(10);
|
||||
expect(state.newWordsPerDay, 8);
|
||||
expect(state.wordSession(size: 20), hasLength(8));
|
||||
state.setDailyMinutes(30);
|
||||
// The cap moved, so the rest of today's allowance opens up.
|
||||
expect(state.newWordQuota, 12);
|
||||
});
|
||||
|
||||
test('what is due is asked even after the new-word cap is used up', () {
|
||||
final state = AppState();
|
||||
final started = state.wordSession(size: 20);
|
||||
expect(state.newWordQuota, 0);
|
||||
for (final question in started.take(3)) {
|
||||
state.recordWordRecognition(question.id, correct: false);
|
||||
}
|
||||
// Missed words are due tomorrow, so nothing is due right now -- but the
|
||||
// cap must not be what is stopping them.
|
||||
state.wordKnowledge[started.first.id] = state
|
||||
.wordKnowledge[started.first.id]!
|
||||
.copyWith(dueAt: DateTime.now().subtract(const Duration(hours: 1)));
|
||||
final next = state.wordSession(size: 20);
|
||||
expect(next.map((q) => q.id), contains(started.first.id));
|
||||
});
|
||||
|
||||
test('answering a new word does not give the cap back', () {
|
||||
final state = AppState();
|
||||
for (final question in state.wordSession(size: 20)) {
|
||||
state.recordWordRecognition(question.id, correct: true);
|
||||
}
|
||||
// Answering is what a round is for; if it cleared the word off today's
|
||||
// count, one sitting could start the bank's whole A1 list.
|
||||
expect(state.newWordsStartedToday, 15);
|
||||
expect(state.newWordQuota, 0);
|
||||
expect(state.wordSession(size: 20), isEmpty);
|
||||
});
|
||||
|
||||
test('a word started today counts against the cap after a reload', () {
|
||||
final state = AppState();
|
||||
state.wordSession(size: 5);
|
||||
final restored = AppState()..restoreForTest(state.snapshotForTest());
|
||||
expect(restored.newWordsStartedToday, 5);
|
||||
expect(restored.newWordQuota, 10);
|
||||
});
|
||||
|
||||
test('a word handed out once is not handed out again next round', () {
|
||||
final state = AppState();
|
||||
final first = state.wordSession(size: 5).map((q) => q.id).toSet();
|
||||
for (final id in first) {
|
||||
state.recordWordRecognition(id, correct: true);
|
||||
}
|
||||
final second = state.wordSession(size: 5).map((q) => q.id).toSet();
|
||||
expect(second.intersection(first), isEmpty);
|
||||
});
|
||||
|
||||
test('a missed word waits for tomorrow instead of the same round', () {
|
||||
final state = AppState();
|
||||
final missed = state.wordSession(size: 3).map((q) => q.id).toSet();
|
||||
for (final id in missed) {
|
||||
state.recordWordRecognition(id, correct: false);
|
||||
}
|
||||
final next = state.wordSession(size: 10).map((q) => q.id).toSet();
|
||||
expect(next.intersection(missed), isEmpty);
|
||||
expect(state.shakyWords.toSet(), missed);
|
||||
});
|
||||
|
||||
test('a word met in a unit is asked before a new one from the bank', () {
|
||||
registerSavedWord('t-unit', en: 'ready', zh: '准备好', level: 'A0');
|
||||
final state = AppState();
|
||||
state.meetWord('t-unit');
|
||||
expect(state.wordSession(size: 4).first.id, 't-unit');
|
||||
});
|
||||
|
||||
test('bank words stay out of the checkpoint queue and mastery', () {
|
||||
final state = AppState();
|
||||
final plan = state.wordSession(size: 3);
|
||||
for (final question in plan) {
|
||||
state.recordWordRecognition(question.id, correct: false);
|
||||
}
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.mastery, isEmpty);
|
||||
});
|
||||
|
||||
test('every word carries an example sentence with its Chinese', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
for (final id in ids) {
|
||||
final sample = WordBank.instance.exampleOf(id);
|
||||
expect(sample, isNotNull, reason: id);
|
||||
expect(sample!.en, isNotEmpty, reason: id);
|
||||
expect(sample.zh, isNotEmpty, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('a gloss is short enough to be a quiz option', () {
|
||||
final glosses = receptiveWordRegistry.entries
|
||||
.where((entry) => WordBank.isBankUnit(entry.value.unit))
|
||||
.map((entry) => entry.value.zh);
|
||||
for (final gloss in glosses) {
|
||||
expect(gloss.length, lessThanOrEqualTo(10), reason: gloss);
|
||||
expect(gloss, isNot(contains('(')), reason: gloss);
|
||||
expect(gloss, isNot(contains('(')), reason: gloss);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra senses are kept aside from the quiz answer', () {
|
||||
final withMore = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.instance.moreSenses(id).isNotEmpty,
|
||||
);
|
||||
expect(withMore, isNotEmpty);
|
||||
for (final id in withMore.take(50)) {
|
||||
expect(
|
||||
WordBank.instance.moreSenses(id).split(';'),
|
||||
isNot(contains(wordMeaning(id))),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# 词库数据来源与许可
|
||||
|
||||
`assets/words/wordbank.json` 由两份来源合成,两份的地位不同,处置方式也不同。
|
||||
|
||||
## 1. 等级与词表范围:CEFR-J Vocabulary Profile 1.5
|
||||
|
||||
- 决定**哪些词进词库、算哪一级**。
|
||||
- 登记在 `tool/courses/lexicon/SOURCES.md`(版本、下载地址、SHA-256、许可原文),
|
||||
与 `tool/courses/pools/{a1,a2,b1}-word-pool.csv` 的生成用的是同一份数据。
|
||||
- CEFR-J 本身**只有分级,没有释义、音标、例句、词频** —— 它是分级表,不是词典。
|
||||
|
||||
## 2. 音标:公开词书(有许可风险,未解决)
|
||||
|
||||
- 来源:<https://github.com/lilinji/English>,由 `tool/words/fetch.sh` 下载其中的
|
||||
入门级词书(KET、PET、小学/初中教材词表)。
|
||||
- 许可:仓库标 CC BY-NC-SA 4.0,README 自称「整理自公开来源,仅供个人学习,严禁商用」。
|
||||
上游权利不明,**低于本项目 `SOURCES.md` 对课程数据的标准**。
|
||||
- 结论:**自用、不分发,风险基本为零;一旦要分发或上架,这部分必须换掉。**
|
||||
换的时候只需要替换音标一列 —— 词表范围和等级由 CEFR-J 决定,释义和例句已经由 AI 重写,
|
||||
都不受影响。
|
||||
|
||||
## 3. 释义与例句:AI 重写(deepseek-flash)
|
||||
|
||||
- `tool/words/enrich_wordbank.py` 把词书释义当**参考**喂给模型,明确告诉它词书质量不稳定、
|
||||
可以推翻;模型返回主释义、次要义项、一个例句和例句中文。
|
||||
- `tool/words/apply_enrichment.py` 校验后合并:释义为空、带括号、超过 10 个字的一律丢弃,
|
||||
保留词书原值。实际全部 2748 条都通过了校验。
|
||||
- 结果缓存在 `ENRICH_CACHE` 指定的 jsonl 里,按 id 去重,中断可续跑,不会重复付费。
|
||||
- 一次全量:输入约 10 万 token,输出约 63 万 token(模型会先推理,输出占大头)。
|
||||
|
||||
## 4. 词频:OpenSubtitles 计数(MIT)
|
||||
|
||||
- 来源:<https://github.com/hermitdave/FrequencyWords> 的 `content/2018/en/en_50k.txt`,
|
||||
仓库 MIT 许可,计数来自 OpenSubtitles 字幕语料。
|
||||
- 决定**新词按什么顺序发给用户**。同等级内从最常用的开始,不按字母序。
|
||||
- 为什么不用网页语料:先试了 `first20hours/google-10000-english`(公共领域),
|
||||
对词库只有 78% 覆盖,缺的全是 `ankle`、`ambulance`、`asleep`、`aspirin` 这类日常词 ——
|
||||
网页语料对口语词覆盖不好。字幕语料覆盖 97%,缺的只有 `bus stop` 这类多词短语。
|
||||
- 多词短语取组成词里**最罕见**那个词的名次:短语不会比它最难的组成词更容易。
|
||||
- `tool/words/add_frequency.py` 把名次写进 `rank` 字段;表里完全查不到的 11 个词
|
||||
排在本等级末尾。
|
||||
|
||||
排序效果:
|
||||
|
||||
| 等级 | 前 12 个词 |
|
||||
| --- | --- |
|
||||
| A1 | no not know just there here like get go right out about |
|
||||
| A2 | it yeah even first name ever enough understand next dead actually bit |
|
||||
| B1 | working rest able stupid sometimes sort serious scared hang bet beat calm |
|
||||
|
||||
## 合成规则
|
||||
|
||||
`tool/words/build_wordbank.py`:
|
||||
|
||||
1. 读 `tool/courses/pools/*-word-pool.csv`,只取 `status == pool` 且非虚词的词条;
|
||||
2. 词书里的词只有落在词池里才进词库,**词书不能往词库里加词**;
|
||||
3. 释义按词性拆分,优先匹配 CEFR-J 标注的词性,丢掉 `[美俚]` `<美>` 这类限定义项,
|
||||
去掉全部括号,每个义项取第一个逗号项;
|
||||
4. 第一个义项进 `zh`(选择题答案),其余进 `more`(只在词条详情里显示);
|
||||
5. `add_frequency.py` 最后写入 `rank`,决定发放顺序。
|
||||
|
||||
## 现状
|
||||
|
||||
| 项 | 数值 |
|
||||
| --- | --- |
|
||||
| 词条总数 | 2748(A1 807 / A2 912 / B1 1029)|
|
||||
| A1 词池覆盖 | 807 / 828 = 97% |
|
||||
| A2 词池覆盖 | 912 / 1065 = 86% |
|
||||
| B1 词池覆盖 | 1029 / 1557 = 66% |
|
||||
| 有例句 | 2748(100%)|
|
||||
| 有词频名次 | 2737(99.6%)|
|
||||
| 有次要义项 | 2304 |
|
||||
| 无音标 | 37 条 |
|
||||
|
||||
规格 5.4 要求覆盖率 ≥85%:A1、A2 达标,**B1 偏低**,因为只下了入门级词书,
|
||||
补 B1 需要再下四六级一类的中高级词书。
|
||||
|
||||
## 释义缺陷的处置
|
||||
|
||||
词书原始数据有相当一部分词条只收了罕见义,规则清洗修不掉,全部由 AI 重写解决:
|
||||
|
||||
| 词 | 词书原值 | 现值 |
|
||||
| --- | --- | --- |
|
||||
| ticket | 加标签于;指派 | 票(次要义:罚单)|
|
||||
| it | 信息技术information technology | 它 |
|
||||
| check | 制止;检验 | 检查(次要义:核对;支票)|
|
||||
| accept | 承认;同意;承兑 | 接受(次要义:同意)|
|
||||
|
||||
`apply_enrichment.py` 还会打印一份「需要人眼看一下」的例句清单:它用词干前缀判断例句里
|
||||
是否真的用到了这个词,而 `teach → taught`、`bus stop` 这类变形和词组它判断不了,
|
||||
所以只报告、不丢弃。最近一次是 9 条,全部核对无误。
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Writes a frequency rank onto every bank word.
|
||||
|
||||
Without this the bank is handed out in alphabetical order, so the first two
|
||||
weeks are nothing but words starting with `a`. Rank 0 is the most common word.
|
||||
|
||||
A multi-word entry (`bus stop`, `air conditioning`) is not in a word frequency
|
||||
list at all, so it takes the rank of its rarest part: a phrase is no easier
|
||||
than the hardest word in it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BANK = os.path.join(ROOT, 'assets/words/wordbank.json')
|
||||
|
||||
# Words the list does not have at all go last, but stay in their level.
|
||||
UNRANKED = 99999
|
||||
|
||||
|
||||
def load_ranks(path):
|
||||
ranks = {}
|
||||
with open(path) as handle:
|
||||
for position, line in enumerate(handle):
|
||||
parts = line.split()
|
||||
if len(parts) == 2:
|
||||
ranks.setdefault(parts[0], position)
|
||||
return ranks
|
||||
|
||||
|
||||
def rank_of(word, ranks):
|
||||
direct = ranks.get(word.lower())
|
||||
if direct is not None:
|
||||
return direct
|
||||
parts = [re.sub(r"[^a-z']", '', part) for part in word.lower().split()]
|
||||
parts = [part for part in parts if part]
|
||||
found = [ranks[part] for part in parts if part in ranks]
|
||||
if len(found) == len(parts) and found:
|
||||
return max(found)
|
||||
return UNRANKED
|
||||
|
||||
|
||||
def main():
|
||||
frequency = sys.argv[1]
|
||||
ranks = load_ranks(frequency)
|
||||
with open(BANK) as handle:
|
||||
bank = json.load(handle)
|
||||
unranked = 0
|
||||
for word in bank['words']:
|
||||
rank = rank_of(word['en'], ranks)
|
||||
word['rank'] = rank
|
||||
if rank == UNRANKED:
|
||||
unranked += 1
|
||||
with open(BANK, 'w') as handle:
|
||||
json.dump(bank, handle, ensure_ascii=False, separators=(',', ':'))
|
||||
handle.write('\n')
|
||||
print('ranked %d, unranked %d' % (len(bank['words']) - unranked, unranked))
|
||||
for level in ('A1', 'A2', 'B1'):
|
||||
words = sorted(
|
||||
(w for w in bank['words'] if w['level'] == level),
|
||||
key=lambda w: w['rank'],
|
||||
)
|
||||
print('%s first 12: %s' % (level, ' '.join(w['en'] for w in words[:12])))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Merges the AI rewrite back into assets/words/wordbank.json.
|
||||
|
||||
Anything the model returned is checked before it lands: a gloss that is empty,
|
||||
parenthesised or sentence-long is worse than the word book's version, so the
|
||||
old value is kept and reported instead of being written over.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BANK = os.path.join(ROOT, 'assets/words/wordbank.json')
|
||||
|
||||
BAD_GLOSS = re.compile(r'[()()\[\]【】<>]')
|
||||
|
||||
|
||||
def usable_gloss(text):
|
||||
text = (text or '').strip()
|
||||
return bool(text) and len(text) <= 10 and not BAD_GLOSS.search(text)
|
||||
|
||||
|
||||
def mentions(word, sentence):
|
||||
"""Whether the sentence visibly uses the word.
|
||||
|
||||
This cannot be a hard check: `teach` shows up as "taught" and `bus stop`
|
||||
as two words, so a prefix match has no way to confirm them. It only flags
|
||||
a sentence as worth a human glance -- nothing is dropped over it.
|
||||
"""
|
||||
parts = [re.sub(r'[^a-z]', '', part) for part in word.lower().split()]
|
||||
stem = max(parts, key=len)[:3]
|
||||
return bool(stem) and stem in sentence.lower()
|
||||
|
||||
|
||||
def main():
|
||||
cache_path = os.environ.get(
|
||||
'ENRICH_CACHE', os.path.join(tempfile.gettempdir(), 'enrich_cache.jsonl')
|
||||
)
|
||||
rows = {}
|
||||
with open(cache_path) as handle:
|
||||
for line in handle:
|
||||
row = json.loads(line)
|
||||
rows[row['id']] = row
|
||||
with open(BANK) as handle:
|
||||
bank = json.load(handle)
|
||||
|
||||
kept = {'gloss': 0, 'example': 0, 'missing': 0, 'bad_gloss': 0, 'no_example': 0}
|
||||
unclear = []
|
||||
for word in bank['words']:
|
||||
row = rows.get(word['id'])
|
||||
if row is None:
|
||||
kept['missing'] += 1
|
||||
continue
|
||||
if usable_gloss(row.get('zh')):
|
||||
word['zh'] = row['zh'].strip()
|
||||
more = (row.get('more') or '').strip()
|
||||
senses = [s for s in more.split(';') if usable_gloss(s) and s != word['zh']]
|
||||
if senses:
|
||||
word['more'] = ';'.join(senses[:2])
|
||||
else:
|
||||
word.pop('more', None)
|
||||
kept['gloss'] += 1
|
||||
else:
|
||||
kept['bad_gloss'] += 1
|
||||
sentence = (row.get('ex') or '').strip()
|
||||
if not sentence:
|
||||
kept['no_example'] += 1
|
||||
continue
|
||||
word['ex'] = sentence
|
||||
word['exZh'] = (row.get('ex_zh') or '').strip()
|
||||
kept['example'] += 1
|
||||
if not mentions(word['en'], sentence):
|
||||
unclear.append('%s | %s' % (word['en'], sentence))
|
||||
|
||||
print(json.dumps(kept, indent=2))
|
||||
if unclear:
|
||||
print('%d sentences to eyeball:' % len(unclear))
|
||||
for line in unclear:
|
||||
print(' ' + line)
|
||||
if '--dry-run' in sys.argv:
|
||||
return
|
||||
bank['source'] = (
|
||||
'CEFR-J Vocabulary Profile 1.5 (levels) + 公开词书 (音标) + AI 重写 (释义与例句)'
|
||||
)
|
||||
with open(BANK, 'w') as handle:
|
||||
json.dump(bank, handle, ensure_ascii=False, separators=(',', ':'))
|
||||
handle.write('\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Builds an independent word bank: CEFR-J decides which words and at what
|
||||
level, the downloaded word books supply the IPA and the Chinese gloss.
|
||||
|
||||
CEFR-J carries no glosses at all, so nothing here overrides it -- a word only
|
||||
enters the bank if the level pool already contains it.
|
||||
"""
|
||||
import csv, glob, json, os, re, sys
|
||||
import openpyxl
|
||||
|
||||
POOLS = '/Users/shenlei/Work/English/kouyu_english/tool/courses/pools'
|
||||
|
||||
# The books write parts of speech as `n.` / `vt.`; CEFR-J spells them out.
|
||||
POS_MAP = {
|
||||
'n': 'noun', 'pron': 'pronoun', 'v': 'verb', 'vt': 'verb', 'vi': 'verb',
|
||||
'aux': 'verb', 'modal': 'verb', 'adj': 'adjective', 'adv': 'adverb',
|
||||
'prep': 'preposition', 'conj': 'conjunction', 'art': 'determiner',
|
||||
'det': 'determiner', 'num': 'number', 'int': 'interjection',
|
||||
}
|
||||
|
||||
|
||||
def load_pool():
|
||||
"""{word form: (headword, pos set, level)} for A1..B1, content words only."""
|
||||
pool = {}
|
||||
for level in ('A1', 'A2', 'B1'):
|
||||
for row in csv.DictReader(open(f'{POOLS}/{level.lower()}-word-pool.csv')):
|
||||
if row.get('status') != 'pool' or row.get('function_word') == 'yes':
|
||||
continue
|
||||
head = row['headword'].strip()
|
||||
for form in re.split(r'[/,]', head):
|
||||
form = form.strip().lower()
|
||||
if not form:
|
||||
continue
|
||||
entry = pool.setdefault(form, {'headword': head, 'pos': set(), 'level': level})
|
||||
if row.get('pos'):
|
||||
entry['pos'].add(row['pos'].strip())
|
||||
return pool
|
||||
|
||||
|
||||
def clean_gloss(raw, wanted_pos):
|
||||
"""Picks one part of speech out of a stacked gloss and splits it into senses a
|
||||
beginner needs. The books ship dictionary dumps: `apple` arrives as
|
||||
"苹果,苹果树,苹果似的东西;[美俚]炸弹,手榴弹..."."""
|
||||
if not raw:
|
||||
return ''
|
||||
lines = [l.strip() for l in str(raw).split('\n') if l.strip()]
|
||||
picked = None
|
||||
for line in lines:
|
||||
m = re.match(r'^([a-z]+)\.\s*(.+)$', line, re.I)
|
||||
if not m:
|
||||
continue
|
||||
pos = POS_MAP.get(m.group(1).lower())
|
||||
if pos and wanted_pos and pos in wanted_pos:
|
||||
picked = m.group(2)
|
||||
break
|
||||
if picked is None:
|
||||
picked = m.group(2)
|
||||
if picked is None:
|
||||
picked = lines[0]
|
||||
senses = []
|
||||
for sense in re.split(r'[;;]', picked):
|
||||
sense = sense.strip()
|
||||
# `[美俚]炸弹` and `<美>支票` are register labels on a sense a learner
|
||||
# will never need; drop the whole sense, not just the label.
|
||||
if re.match(r'^\s*[\[<【]', sense):
|
||||
continue
|
||||
sense = re.sub(r'[((][^))]*[))]', '', sense)
|
||||
sense = re.sub(r'[\[【][^\]】]*[\]】]', '', sense)
|
||||
# "苹果,苹果树,苹果似的东西" is one sense listed three ways.
|
||||
sense = re.split(r'[,,]', sense)[0].strip(' 。.、')
|
||||
if sense:
|
||||
senses.append(sense)
|
||||
return list(dict.fromkeys(senses))[:3]
|
||||
|
||||
|
||||
def main():
|
||||
pool = load_pool()
|
||||
bank, seen, skipped = {}, 0, 0
|
||||
for path in sorted(glob.glob('src/*.xlsx')):
|
||||
source = os.path.basename(path)
|
||||
wb = openpyxl.load_workbook(path, read_only=True)
|
||||
for row in wb.worksheets[0].iter_rows(min_row=2, values_only=True):
|
||||
if not row or not row[0]:
|
||||
continue
|
||||
seen += 1
|
||||
word = str(row[0]).strip()
|
||||
key = word.lower()
|
||||
hit = pool.get(key)
|
||||
if hit is None:
|
||||
skipped += 1
|
||||
continue
|
||||
senses = clean_gloss(row[3] if len(row) > 3 else '', hit['pos'])
|
||||
if not senses:
|
||||
skipped += 1
|
||||
continue
|
||||
uk = str(row[1] or '').strip()
|
||||
us = str(row[2] or '').strip()
|
||||
prev = bank.get(key)
|
||||
# Keep the first hit, but let a later book fill in a missing IPA.
|
||||
if prev:
|
||||
if not prev['ipa'] and (us or uk):
|
||||
prev['ipa'] = us or uk
|
||||
continue
|
||||
bank[key] = {
|
||||
'id': 'V-' + re.sub(r'[^a-z0-9]+', '-', key).strip('-'),
|
||||
'en': word,
|
||||
# One sense is what a quiz option should say; the rest are kept
|
||||
# for the word's detail line.
|
||||
'zh': senses[0],
|
||||
'more': ';'.join(senses[1:]),
|
||||
'ipa': us or uk,
|
||||
'level': hit['level'], 'source': source,
|
||||
}
|
||||
wb.close()
|
||||
|
||||
out = [bank[k] for k in sorted(bank)]
|
||||
json.dump(out, open('wordbank.json', 'w'), ensure_ascii=False, indent=1)
|
||||
|
||||
from collections import Counter
|
||||
by_level = Counter(w['level'] for w in out)
|
||||
print(f'读入词条 {seen},未匹配/丢弃 {skipped}')
|
||||
print(f'词库产出 {len(out)} ' + ' '.join(f'{k} {by_level[k]}' for k in ('A1', 'A2', 'B1')))
|
||||
for level in ('A1', 'A2', 'B1'):
|
||||
total = len({f for f, v in pool.items() if v['level'] == level})
|
||||
print(f' {level} 词池覆盖 {by_level[level]}/{total} = {by_level[level]/total:.0%}')
|
||||
print(f' 无音标 {sum(1 for w in out if not w["ipa"])}')
|
||||
print('\n样例:')
|
||||
for w in out[:8]:
|
||||
print(f' {w["en"]:12} {w["ipa"]:18} {w["zh"]:10} [{w["level"]}] {w["more"]}')
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Rewrites the Chinese gloss of every bank word and gives it an example
|
||||
sentence, using the AI service configured in assets/config/ai_config.json.
|
||||
|
||||
The downloaded word books are a dictionary dump: some entries carry only a rare
|
||||
sense (`ticket` as a verb and nothing else), some are unusable as a quiz answer.
|
||||
Cleaning rules cannot invent the missing sense, so the model rewrites the gloss
|
||||
outright, with the book's version passed only as a hint it may overrule.
|
||||
|
||||
Results are cached one JSON object per line, so an interrupted run resumes
|
||||
instead of paying for the same words twice.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BANK = os.path.join(ROOT, 'assets/words/wordbank.json')
|
||||
CONFIG = os.path.join(ROOT, 'assets/config/ai_config.json')
|
||||
|
||||
# A word the model reasons about at length can truncate its whole batch; set
|
||||
# ENRICH_BATCH smaller to pick those stragglers up on a second run.
|
||||
BATCH = int(os.environ.get('ENRICH_BATCH', '12'))
|
||||
WORKERS = 8
|
||||
RETRIES = 3
|
||||
|
||||
SYSTEM = """你在为一个中国成年人用的英语学习 App 整理词库。对每个词输出:
|
||||
|
||||
- zh:最常用的那一个中文释义。2 到 6 个字,不要词性标注,不要括号,不要罗列同义词。
|
||||
- more:其他常用义,最多 2 个,用「;」分隔;没有别的常用义就给空字符串。
|
||||
- ex:一个英文例句。日常口语场景,6 到 12 个词,必须用到 zh 所指的那个义项,
|
||||
句中其他词不要超过这个词的 CEFR 等级。
|
||||
- ex_zh:例句的中文翻译,说人话,不要翻译腔。
|
||||
|
||||
输入里的「参考」来自一份质量不稳定的词书:有的只收了罕见义,有的缺了最常用的义项。
|
||||
不要盲从,以你自己对这个词最常用义的判断为准。
|
||||
|
||||
只输出 JSON 数组,每个元素形如
|
||||
{"id":"...","zh":"...","more":"...","ex":"...","ex_zh":"..."}
|
||||
不要输出任何别的文字,不要用 markdown 代码块。"""
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG) as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def call(config, prompt):
|
||||
"""One chat completion, through curl: this machine's Python does not trust
|
||||
the local certificate chain but curl does."""
|
||||
body = {
|
||||
'model': config['model'],
|
||||
'messages': [
|
||||
{'role': 'system', 'content': SYSTEM},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'temperature': 0.3,
|
||||
# The configured model reasons before it answers, and those tokens come
|
||||
# out of the same budget -- a tight cap truncates the JSON mid-array.
|
||||
'max_tokens': 8000,
|
||||
}
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as payload:
|
||||
json.dump(body, payload, ensure_ascii=False)
|
||||
path = payload.name
|
||||
# The key goes through stdin, never argv, so it stays out of the process list.
|
||||
conf = (
|
||||
'url = "%s/v1/chat/completions"\n'
|
||||
'header = "Authorization: Bearer %s"\n'
|
||||
'header = "Content-Type: application/json"\n'
|
||||
'data-binary = "@%s"\n'
|
||||
'max-time = 300\n'
|
||||
) % (config['endpoint'].rstrip('/'), config['apiKey'], path)
|
||||
try:
|
||||
done = subprocess.run(
|
||||
['curl', '-sS', '--config', '-'], input=conf, capture_output=True, text=True
|
||||
)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
if done.returncode != 0:
|
||||
raise RuntimeError(done.stderr[:200])
|
||||
answer = json.loads(done.stdout)
|
||||
if 'choices' not in answer:
|
||||
raise RuntimeError(done.stdout[:200])
|
||||
choice = answer['choices'][0]
|
||||
if choice.get('finish_reason') == 'length':
|
||||
raise RuntimeError('truncated at max_tokens')
|
||||
return choice['message']['content'], answer.get('usage', {})
|
||||
|
||||
|
||||
def parse(text):
|
||||
"""The model is told to return bare JSON; strip a code fence when it adds one."""
|
||||
text = text.strip()
|
||||
if text.startswith('```'):
|
||||
text = text.split('\n', 1)[1].rsplit('```', 1)[0]
|
||||
start, end = text.find('['), text.rfind(']')
|
||||
if start < 0 or end < 0:
|
||||
raise ValueError(text[:200])
|
||||
return json.loads(text[start : end + 1])
|
||||
|
||||
|
||||
def ask(config, batch):
|
||||
lines = [
|
||||
'%s | %s | %s | 参考:%s'
|
||||
% (word['id'], word['en'], word['level'], ';'.join(
|
||||
filter(None, [word.get('zh', ''), word.get('more', '')])) or '(无)')
|
||||
for word in batch
|
||||
]
|
||||
text, usage = call(config, '\n'.join(lines))
|
||||
wanted = {word['id'] for word in batch}
|
||||
rows = [row for row in parse(text) if row.get('id') in wanted]
|
||||
return rows, usage
|
||||
|
||||
|
||||
def main():
|
||||
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
cache_path = os.environ.get('ENRICH_CACHE', os.path.join(tempfile.gettempdir(), 'enrich_cache.jsonl'))
|
||||
done = {}
|
||||
if os.path.exists(cache_path):
|
||||
with open(cache_path) as handle:
|
||||
for line in handle:
|
||||
row = json.loads(line)
|
||||
done[row['id']] = row
|
||||
with open(BANK) as handle:
|
||||
bank = json.load(handle)
|
||||
todo = [word for word in bank['words'] if word['id'] not in done]
|
||||
if limit:
|
||||
todo = todo[:limit]
|
||||
print('cached %d, to do %d' % (len(done), len(todo)), flush=True)
|
||||
|
||||
config = load_config()
|
||||
batches = [todo[i : i + BATCH] for i in range(0, len(todo), BATCH)]
|
||||
spent = {'in': 0, 'out': 0, 'fail': 0}
|
||||
with open(cache_path, 'a') as cache:
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
||||
for index, result in enumerate(pool.map(lambda b: safe(config, b), batches)):
|
||||
rows, usage, error = result
|
||||
if error:
|
||||
spent['fail'] += 1
|
||||
print(' batch %d failed: %s' % (index, error), flush=True)
|
||||
continue
|
||||
for row in rows:
|
||||
cache.write(json.dumps(row, ensure_ascii=False) + '\n')
|
||||
cache.flush()
|
||||
spent['in'] += usage.get('prompt_tokens', 0)
|
||||
spent['out'] += usage.get('completion_tokens', 0)
|
||||
if index % 10 == 0:
|
||||
print(' %d/%d batches' % (index + 1, len(batches)), flush=True)
|
||||
print('tokens in %d out %d, failed batches %d' % (spent['in'], spent['out'], spent['fail']))
|
||||
print('cache: %s' % cache_path)
|
||||
|
||||
|
||||
def safe(config, batch):
|
||||
"""Retries a batch a few times; a truncated or malformed answer is common
|
||||
enough that giving up on the first one would leave holes everywhere."""
|
||||
last = 'no attempt'
|
||||
for attempt in range(RETRIES):
|
||||
try:
|
||||
rows, usage = ask(config, batch)
|
||||
if rows:
|
||||
return rows, usage, None
|
||||
last = 'empty result'
|
||||
except Exception as error:
|
||||
last = '%s: %s' % (type(error).__name__, error)
|
||||
return [], {}, last
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Downloads the beginner-level word books. The repo is CC BY-NC-SA 4.0 and its
|
||||
# own README calls the contents "compiled from public sources, personal use
|
||||
# only" -- see the note in DATA-NOTE.md before shipping any of this.
|
||||
set -u
|
||||
BASE="https://github.com/lilinji/English/raw/main"
|
||||
enc() { python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$1"; }
|
||||
get() {
|
||||
local path="$1" out="src/$2"
|
||||
[ -s "$out" ] && { echo " 已有 $2"; return; }
|
||||
curl -sL -o "$out" "$BASE/$(enc "$path")"
|
||||
if [ -s "$out" ] && head -c2 "$out" | grep -q PK; then echo " 取得 $2 ($(wc -c <"$out") 字节)"
|
||||
else echo " 失败 $2"; rm -f "$out"; fi
|
||||
}
|
||||
echo "剑桥 KET/PET:"
|
||||
get "9.其他(更多)/14天攻克KET核心词汇.xlsx" ket-core.xlsx
|
||||
get "9.其他(更多)/KET核心词 巧记速练.xlsx" ket-drill.xlsx
|
||||
get "9.其他(更多)/21天攻克PET核心词汇.xlsx" pet-core.xlsx
|
||||
get "9.其他(更多)/PET核心词 巧记速练.xlsx" pet-drill.xlsx
|
||||
get "9.其他(更多)/突破英文基础词汇.xlsx" basic.xlsx
|
||||
echo "人教版小学:"
|
||||
for g in 一 二 三 四 五 六; do
|
||||
for t in 上 下; do
|
||||
get "1.全国各大教材版本中小学同步/人教版/人教版一年级起点${g}年级${t}.xlsx" "rj-p1-${g}${t}.xlsx"
|
||||
done
|
||||
done
|
||||
echo "人教版初中(七年级):"
|
||||
get "1.全国各大教材版本中小学同步/人教版/人教版初中英语七年级上册.xlsx" rj-m7a.xlsx
|
||||
get "1.全国各大教材版本中小学同步/人教版/人教版初中英语七年级下册.xlsx" rj-m7b.xlsx
|
||||
|
||||
# Word frequency, for the order new words are handed out in. These are
|
||||
# OpenSubtitles counts, i.e. spoken language: a web corpus ranks `ankle` and
|
||||
# `asleep` far below where a learner of spoken English needs them.
|
||||
# hermitdave/FrequencyWords is MIT, a cleaner licence than the word books above.
|
||||
echo "词频表:"
|
||||
if [ -s src/en_50k.txt ]; then
|
||||
echo " 已有 en_50k.txt"
|
||||
else
|
||||
curl -sL -o src/en_50k.txt \
|
||||
"https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/en/en_50k.txt"
|
||||
echo " 取得 en_50k.txt ($(wc -l <src/en_50k.txt) 行)"
|
||||
fi
|
||||
Reference in New Issue
Block a user