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:
shenlei
2026-09-20 23:54:17 +09:00
co-authored by Claude Opus 5
parent 1a10ca88e0
commit 6b42b7abc3
52 changed files with 4044 additions and 82 deletions
+31 -3
View File
@@ -178,8 +178,16 @@ AI 对话中的词条必须来自当前允许语言范围,或是事先标为
| 产出核心(会用) | 单元内容包中需要主动说、写的词、词块和句型;每单元 10–15 项 | 3.1–3.2 的五种状态和掌握规则 | 计入 | | 产出核心(会用) | 单元内容包中需要主动说、写的词、词块和句型;每单元 10–15 项 | 3.1–3.2 的五种状态和掌握规则 | 计入 |
| 理解词(会认) | 听懂、看懂即可的词;每单元 A1 20–35 项、A2 3050 项、B1 50–80 项 | 新学 → 认识 → 熟悉 | 不逐项计入;升级时抽查(4.2) | | 理解词(会认) | 听懂、看懂即可的词;每单元 A1 20–35 项、A2 3050 项、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 间隔复习,每次复习换语境(如从地铁站换到火车站)。 1. 会用的词:预热(只记“见过”)→ 在场景句中听辨 → 说、读、写的替换练习 → 受控对话中用出来 → 独立尝试 → 按 3.1 间隔复习,每次复习换语境(如从地铁站换到火车站)。
2. 会认的词:不进预热,直接出现在教学段的听读材料里,首次出现可点开释义;教学段结束时做 2–3 道快速识别题,之后按下述理解词规则复习。 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 秒的快速识别题为主,可以成组出现,占复习预算不超过三分之一。 - 复习以 5–10 秒的快速识别题为主,可以成组出现,占复习预算不超过三分之一。
该上限约束的是插进复习流的识别题;用户主动打开的单词页不受此限。
- **入口**:单词页有独立 tab,同时在首页今日任务卡下方给一张认词卡,显示今天该过几个词、
预计几分钟。认词与今日任务是并行的,不参与“今天最该做的一件事”的排序,
所以它单独占一行而不抢主卡;今天没题时缩成一行状态,但入口始终在。
- 在听读材料中答对与某个理解词直接相关的理解题,可计一次该词的识别证据。 - 在听读材料中答对与某个理解词直接相关的理解题,可计一次该词的识别证据。
- 后续单元把某个理解词列为产出核心时,保留已有识别证据,从“认识”开始走完整掌握流程。 - 后续单元把某个理解词列为产出核心时,保留已有识别证据,从“认识”开始走完整掌握流程。
@@ -77,6 +77,17 @@
"sentence": "Im Tom.", "sentence": "Im Tom.",
"meaning": "我是 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": "Whats your name?", "sentence": "Whats your name?",
"meaning": "你叫什么名字?" "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.", "sentence": "Nice to meet you.",
"meaning": "很高兴认识你。" "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?", "sentence": "How do you spell that?",
"meaning": "那个怎么拼写?" "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?", "sentence": "How are you?",
"meaning": "你怎么样?" "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": "Im tired today.", "sentence": "Im tired today.",
"meaning": "我今天很累。" "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": "Whats your phone number?", "sentence": "Whats your phone number?",
"meaning": "你的电话号码是多少?" "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.", "sentence": "My number is one-three-eight.",
"meaning": "我的号码是一三八。" "meaning": "我的号码是一三八。"
} }
},
"usage": {
"when": "别人问你电话号码的时候,把号码报出来。",
"reply": "对方多半会复述一遍跟你确认。",
"confuse": [
{
"id": "A0-P01",
"note": "I'm Tom. 回答的是名字,My number is 123. 回答的是号码。"
}
]
} }
} }
], ],
@@ -93,6 +93,16 @@
"sentence": "Whats this?", "sentence": "Whats this?",
"meaning": "这是什么?" "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": "Its a pen.", "sentence": "Its a pen.",
"meaning": "这是一支笔。" "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?", "sentence": "Where are you from?",
"meaning": "你来自哪里?" "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": "Im from China.", "sentence": "Im from China.",
"meaning": "我来自中国。" "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.", "sentence": "This is my sister.",
"meaning": "这是我的姐妹。" "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?", "sentence": "What day is it today?",
"meaning": "今天星期几?" "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": "Its Friday.", "sentence": "Its Friday.",
"meaning": "今天星期五。" "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?", "sentence": "What time is it?",
"meaning": "现在几点?" "meaning": "现在几点?"
} }
},
"usage": {
"when": "想知道现在几点钟。",
"reply": "对方会回 It's three o'clock.",
"confuse": [
{
"id": "A0-P14",
"note": "What day is it today? 问的是星期几,这句问的是几点钟。"
}
]
} }
}, },
{ {
@@ -213,6 +243,15 @@
"sentence": "Its three oclock.", "sentence": "Its three oclock.",
"meaning": "现在三点。" "meaning": "现在三点。"
} }
},
"usage": {
"when": "别人问几点的时候,报出整点时间。",
"confuse": [
{
"id": "A0-P15",
"note": "It's Monday. 回答的是星期,It's three o'clock. 回答的是钟点。"
}
]
} }
} }
], ],
@@ -130,6 +130,16 @@
"sentence": "I like music.", "sentence": "I like music.",
"meaning": "我喜欢音乐。" "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?", "sentence": "Do you like coffee?",
"meaning": "你喜欢咖啡吗?" "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.", "sentence": "Please say that again.",
"meaning": "请再说一遍。" "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
+25 -1
View File
@@ -21,6 +21,7 @@ part 'app_state_assessment.dart';
part 'app_state_lesson.dart'; part 'app_state_lesson.dart';
part 'app_state_review.dart'; part 'app_state_review.dart';
part 'app_state_snapshot.dart'; part 'app_state_snapshot.dart';
part 'app_state_words.dart';
/// Everything the app persists. Behaviour lives in the domain mixins that /// Everything the app persists. Behaviour lives in the domain mixins that
/// [AppState] composes. /// [AppState] composes.
@@ -88,11 +89,31 @@ abstract class _AppStateData extends ChangeNotifier {
final Map<String, MasteryItem> mastery = {}; 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(); void _syncInBackground();
} }
class AppState extends _AppStateData 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'; static const _storageKey = 'learning_state_v1';
bool isLoaded = false; bool isLoaded = false;
bool _writing = false; bool _writing = false;
@@ -114,6 +135,9 @@ class AppState extends _AppStateData
// Adapt the A1B1 JSON packs into the runtime registries before the saved // Adapt the A1B1 JSON packs into the runtime registries before the saved
// snapshot is restored, so progress that points at those lessons resolves. // snapshot is restored, so progress that points at those lessons resolves.
await CourseRepository.instance.load(); 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(); final config = await AiConfigFile.loadFromAsset();
if (config != null) { if (config != null) {
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) { if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
+10 -2
View File
@@ -2,7 +2,8 @@ part of 'app_state.dart';
/// Position in the seed course, the in-lesson step flow, and the evidence /// Position in the seed course, the in-lesson step flow, and the evidence
/// lesson tasks produce. /// lesson tasks produce.
mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress { mixin _LessonProgress
on _AppStateData, _ReviewAndMastery, _ReceptiveWords, _AssessmentProgress {
bool get hasResumableLessonDialogue => bool get hasResumableLessonDialogue =>
dialogueDraft != null && dialogueDraft != null &&
dialogueDraft!.lessonId == activeLessonId && dialogueDraft!.lessonId == activeLessonId &&
@@ -259,6 +260,10 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
recordingPath: recordingPath, recordingPath: recordingPath,
assisted: assisted, 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(); notifyListeners();
} }
@@ -266,6 +271,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) { if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) {
return; 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); completedLessonIds.add(activeLessonId);
completedLessons = completedLessonIds.length; completedLessons = completedLessonIds.length;
final next = _nextIncompleteLessonId(); final next = _nextIncompleteLessonId();
@@ -540,7 +548,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
if (introduced.firstTaughtAt == null) { if (introduced.firstTaughtAt == null) {
mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now()); mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now());
} }
if (reviewQueue.any((item) => item.id == id)) continue; if (_hasCheckpointTask(id)) continue;
final template = coreReviewTemplate(id); final template = coreReviewTemplate(id);
reviewQueue.add( reviewQueue.add(
ReviewItem( ReviewItem(
+241 -32
View File
@@ -2,13 +2,55 @@ part of 'app_state.dart';
/// Review queue scheduling and the mastery derived from attempt evidence. /// Review queue scheduling and the mastery derived from attempt evidence.
mixin _ReviewAndMastery on _AppStateData { 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 { List<ReviewItem> get dueReviews {
final now = DateTime.now(); final now = DateTime.now();
final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList() final due =
..sort((a, b) => a.dueAt.compareTo(b.dueAt)); reviewQueue
.where(
(item) =>
item.kind == ReviewKind.checkpoint &&
!item.dueAt.isAfter(now),
)
.toList()
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
return due; 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; int get dueReviewCount => dueReviews.length;
bool get reviewIsPrimary => dueReviewCount > 0; bool get reviewIsPrimary => dueReviewCount > 0;
int get reviewBudgetSeconds => switch (dailyMinutes) { int get reviewBudgetSeconds => switch (dailyMinutes) {
@@ -16,7 +58,10 @@ mixin _ReviewAndMastery on _AppStateData {
30 => 8 * 60, 30 => 8 * 60,
_ => 5 * 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 { bool get reviewBacklog {
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)); final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7));
return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 || return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 ||
@@ -80,6 +125,17 @@ mixin _ReviewAndMastery on _AppStateData {
addSavedWord(item); 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( void completeReview(
ReviewItem item, { ReviewItem item, {
required bool assisted, required bool assisted,
@@ -88,12 +144,21 @@ mixin _ReviewAndMastery on _AppStateData {
String? originalTranscript, String? originalTranscript,
String? recordingPath, String? recordingPath,
}) { }) {
final index = reviewQueue.indexWhere( final index = _queueIndexOf(item);
(candidate) => candidate.id == item.id,
);
if (index < 0) return; if (index < 0) return;
final current = reviewQueue[index]; final current = reviewQueue[index];
final now = DateTime.now(); 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 // UI/network retries may still hold an old item instance. Only the
// currently due queue entry is allowed to produce evidence. // currently due queue entry is allowed to produce evidence.
if (current.dueAt.isAfter(now)) return; if (current.dueAt.isAfter(now)) return;
@@ -161,10 +226,14 @@ mixin _ReviewAndMastery on _AppStateData {
} }
void reportReviewFailure(ReviewItem item) { void reportReviewFailure(ReviewItem item) {
final index = reviewQueue.indexWhere( final index = _queueIndexOf(item);
(candidate) => candidate.id == item.id,
);
if (index < 0) return; 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 = final existing =
mastery[item.id] ?? mastery[item.id] ??
MasteryItem( MasteryItem(
@@ -194,13 +263,151 @@ mixin _ReviewAndMastery on _AppStateData {
: item.successfulReviews, : item.successfulReviews,
); );
_addAttemptEvidence(item, outcome: EvidenceKind.languageError); _addAttemptEvidence(item, outcome: EvidenceKind.languageError);
_scheduleSessionRetry(item);
notifyListeners(); notifyListeners();
} }
void postponeReview(ReviewItem item) { /// Learning engine 3.2: an item missed now comes back once more before the
final index = reviewQueue.indexWhere( /// session ends, after the remaining due tasks. That re-ask is practice —
(candidate) => candidate.id == item.id, /// 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; if (index < 0) return;
reviewQueue[index] = item.copyWith( reviewQueue[index] = item.copyWith(
dueAt: DateTime.now().add(const Duration(days: 1)), dueAt: DateTime.now().add(const Duration(days: 1)),
@@ -241,20 +448,9 @@ mixin _ReviewAndMastery on _AppStateData {
); );
} }
void addSavedWord(VocabularyItem item) { /// Implemented by [_ReceptiveWords]: a saved word is recognition-only, so
if (reviewQueue.any((review) => review.id == item.id)) return; /// it never joins the checkpoint queue.
reviewQueue.add( void addSavedWord(VocabularyItem item);
ReviewItem(
id: item.id,
target: item.word,
prompt: item.example,
hint: item.meaning,
dueAt: DateTime.now().add(const Duration(days: 1)),
skill: '认识与回忆',
),
);
notifyListeners();
}
/// Adds one low-priority, non-core recap based on a completed independent /// Adds one low-priority, non-core recap based on a completed independent
/// dialogue. It is deliberately separate from A0 denominator items. /// dialogue. It is deliberately separate from A0 denominator items.
@@ -263,7 +459,7 @@ mixin _ReviewAndMastery on _AppStateData {
final day = final day =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
final id = 'dialogue-$sceneId-$day'; final id = 'dialogue-$sceneId-$day';
if (reviewQueue.any((item) => item.id == id)) return; if (_hasCheckpointTask(id)) return;
reviewQueue.add( reviewQueue.add(
ReviewItem( ReviewItem(
id: id, id: id,
@@ -294,7 +490,9 @@ mixin _ReviewAndMastery on _AppStateData {
}); });
if (candidates.isEmpty) return; if (candidates.isEmpty) return;
final id = candidates.first; 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) { if (index >= 0) {
final current = reviewQueue[index]; final current = reviewQueue[index];
final template = coreReviewVariant(id, current.variantIndex + 1); final template = coreReviewVariant(id, current.variantIndex + 1);
@@ -324,7 +522,9 @@ mixin _ReviewAndMastery on _AppStateData {
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) { void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
final index = reviewQueue.indexWhere( final index = reviewQueue.indexWhere(
(item) => item.id == variant.targetItemId, (item) =>
item.id == variant.targetItemId &&
item.kind == ReviewKind.checkpoint,
); );
if (index < 0) return; if (index < 0) return;
final current = reviewQueue[index]; final current = reviewQueue[index];
@@ -346,9 +546,7 @@ mixin _ReviewAndMastery on _AppStateData {
/// its checkpoint contribution is removed; the stable core item remains. /// its checkpoint contribution is removed; the stable core item remains.
void reportGeneratedReviewVariant(ReviewItem item) { void reportGeneratedReviewVariant(ReviewItem item) {
if (!item.isAiGenerated) return; if (!item.isAiGenerated) return;
final index = reviewQueue.indexWhere( final index = _queueIndexOf(item);
(candidate) => candidate.id == item.id,
);
if (index < 0) return; if (index < 0) return;
final key = '${item.id}:${item.variantIndex}'; final key = '${item.id}:${item.variantIndex}';
if (!reportedAiVariantKeys.add(key)) return; if (!reportedAiVariantKeys.add(key)) return;
@@ -393,6 +591,7 @@ mixin _ReviewAndMastery on _AppStateData {
} }
for (var index = 0; index < reviewQueue.length; index++) { for (var index = 0; index < reviewQueue.length; index++) {
final item = reviewQueue[index]; final item = reviewQueue[index];
if (item.kind == ReviewKind.recap) continue;
final rebuilt = mastery[item.id]; final rebuilt = mastery[item.id];
if (rebuilt != null) { if (rebuilt != null) {
reviewQueue[index] = item.copyWith( reviewQueue[index] = item.copyWith(
@@ -419,6 +618,12 @@ mixin _ReviewAndMastery on _AppStateData {
firstTaughtAt = event.createdAt; firstTaughtAt = event.createdAt;
} }
if (!isReview) continue; 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) { if (event.outcome == EvidenceKind.independentSuccess) {
final day = final day =
'${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}'; '${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}';
@@ -487,6 +692,10 @@ mixin _ReviewAndMastery on _AppStateData {
'听力理解', '听力理解',
'阅读理解', '阅读理解',
dictationSkill, 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, /// 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>?; final savedMastery = data['mastery'] as List<dynamic>?;
if (savedMastery != null) { if (savedMastery != null) {
mastery mastery
@@ -357,6 +414,7 @@ extension _AppStateSnapshot on AppState {
'hint': item.hint, 'hint': item.hint,
'dueAt': item.dueAt.toIso8601String(), 'dueAt': item.dueAt.toIso8601String(),
'skill': item.skill, 'skill': item.skill,
'kind': item.kind.name,
'attempts': item.attempts, 'attempts': item.attempts,
'successfulReviews': item.successfulReviews, 'successfulReviews': item.successfulReviews,
'variantIndex': item.variantIndex, 'variantIndex': item.variantIndex,
@@ -365,6 +423,17 @@ extension _AppStateSnapshot on AppState {
}, },
) )
.toList(), .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 'mastery': mastery.values
.map( .map(
(item) => { (item) => {
@@ -480,6 +549,7 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
hint: data['hint'] as String, hint: data['hint'] as String,
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(), dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
skill: data['skill'] as String, skill: data['skill'] as String,
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
attempts: data['attempts'] as int? ?? 0, attempts: data['attempts'] as int? ?? 0,
successfulReviews: data['successfulReviews'] as int? ?? 0, successfulReviews: data['successfulReviews'] as int? ?? 0,
variantIndex: data['variantIndex'] as int? ?? 0, variantIndex: data['variantIndex'] as int? ?? 0,
+354
View File
@@ -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 510 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;
}
+352 -6
View File
@@ -1,8 +1,13 @@
import 'course_pack.dart'; import 'course_pack.dart';
/// A produced core item as the registries see it: the level of the pack that /// A produced core item as the registries see it: the pack that teaches it,
/// teaches it and its English and Chinese forms. /// its level, and its English and Chinese forms.
typedef RegisteredCoreItem = ({String level, String en, String zh}); typedef RegisteredCoreItem = ({
String level,
String unit,
String en,
String zh,
});
/// Every produced core item across A0B1, in course order, filled from the /// Every produced core item across A0B1, in course order, filled from the
/// packs' `coreItems` by `CourseRepository`. IDs are separate 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, 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 510 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 { class CoreReviewTemplate {
const CoreReviewTemplate({ const CoreReviewTemplate({
required this.prompt, required this.prompt,
@@ -43,9 +234,23 @@ final Map<String, String> coreReviewHints = <String, String>{};
final Map<String, DictationSentence> coreDictationSentences = final Map<String, DictationSentence> coreDictationSentences =
<String, DictationSentence>{}; <String, DictationSentence>{};
/// Registers [item], taught at [level], with its review data. /// How to use each core item that needs explaining, straight from the pack's
void registerCoreItem(CoreItem item, {required String level}) { /// `usage`. Items whose meaning is the whole story (`book`, `Monday`) have no
coreItemRegistry[item.id] = (level: level, en: item.en, zh: item.zh); /// 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; coreItemMatch[item.id] = item.match;
coreReviewTemplates[item.id] = CoreReviewTemplate( coreReviewTemplates[item.id] = CoreReviewTemplate(
prompt: item.reviewPrompt.isNotEmpty prompt: item.reviewPrompt.isNotEmpty
@@ -55,6 +260,7 @@ void registerCoreItem(CoreItem item, {required String level}) {
skill: item.type == 'word' ? '词汇回忆' : '回忆表达', skill: item.type == 'word' ? '词汇回忆' : '回忆表达',
); );
if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint; if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint;
if (!item.usage.isEmpty) coreUsages[item.id] = item.usage;
if (item.dictationSentence.isNotEmpty) { if (item.dictationSentence.isNotEmpty) {
coreDictationSentences[item.id] = ( coreDictationSentences[item.id] = (
sentence: item.dictationSentence, sentence: item.dictationSentence,
@@ -70,6 +276,8 @@ void clearCoreItems() {
coreReviewTemplates.clear(); coreReviewTemplates.clear();
coreReviewHints.clear(); coreReviewHints.clear();
coreDictationSentences.clear(); coreDictationSentences.clear();
coreUsages.clear();
receptiveWordRegistry.clear();
} }
/// The label a learner sees for a core item, falling back to its id. A0 /// 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. /// English use this.
String coreItemEnglish(String id) => coreItemRegistry[id]?.en ?? id; 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. /// Whether [id] is a produced core item the course tracks for mastery.
bool isCoreItem(String id) => coreItemRegistry.containsKey(id); bool isCoreItem(String id) => coreItemRegistry.containsKey(id);
@@ -121,6 +335,138 @@ CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
const dictationSkill = '听写'; const dictationSkill = '听写';
const spokenRecallSkill = '口头回忆'; 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 /// A concrete sentence built only from taught language, used to play a core
/// item in context and to check dictation. /// item in context and to check dictation.
typedef DictationSentence = ({String sentence, String meaning}); 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. /// A produced core item (word / phrase / pattern) with its acceptance rule.
class CoreItem { class CoreItem {
const CoreItem({ const CoreItem({
@@ -327,6 +377,7 @@ class CoreItem {
this.reviewHint = '', this.reviewHint = '',
this.dictationSentence = '', this.dictationSentence = '',
this.dictationMeaning = '', this.dictationMeaning = '',
this.usage = const CoreUsage(),
}); });
final String id; final String id;
@@ -348,6 +399,9 @@ class CoreItem {
final String dictationSentence; final String dictationSentence;
final String dictationMeaning; 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) { factory CoreItem.fromJson(Map<String, dynamic> json) {
final example = _objectOrEmpty(json['example']); final example = _objectOrEmpty(json['example']);
final review = _objectOrEmpty(json['review']); final review = _objectOrEmpty(json['review']);
@@ -364,6 +418,7 @@ class CoreItem {
reviewHint: review['hint'] as String? ?? '', reviewHint: review['hint'] as String? ?? '',
dictationSentence: dictation['sentence'] as String? ?? '', dictationSentence: dictation['sentence'] as String? ?? '',
dictationMeaning: dictation['meaning'] 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. // Core items: labels, acceptance rules and offline review prompts.
for (final item in pack.coreItems) { 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>[]; final segments = <LessonSegment>[];
@@ -9,3 +9,4 @@ export 'core_items.dart';
export 'course_catalog.dart'; export 'course_catalog.dart';
export 'course_models.dart'; export 'course_models.dart';
export 'option_order.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();
}
}
+78 -1
View File
@@ -2,7 +2,7 @@ enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation } enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { learn, dialogue, review, profile } enum AppTab { learn, dialogue, review, words, profile }
enum LessonStep { enum LessonStep {
preview, preview,
@@ -18,6 +18,72 @@ enum LessonStep {
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview } 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 { enum EvidenceKind {
exposure, exposure,
assisted, 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 { class ReviewItem {
const ReviewItem({ const ReviewItem({
required this.id, required this.id,
@@ -400,6 +473,7 @@ class ReviewItem {
required this.hint, required this.hint,
required this.dueAt, required this.dueAt,
required this.skill, required this.skill,
this.kind = ReviewKind.checkpoint,
this.attempts = 0, this.attempts = 0,
this.successfulReviews = 0, this.successfulReviews = 0,
this.variantIndex = 0, this.variantIndex = 0,
@@ -413,6 +487,7 @@ class ReviewItem {
final String hint; final String hint;
final DateTime dueAt; final DateTime dueAt;
final String skill; final String skill;
final ReviewKind kind;
final int attempts; final int attempts;
final int successfulReviews; final int successfulReviews;
final int variantIndex; final int variantIndex;
@@ -421,6 +496,7 @@ class ReviewItem {
ReviewItem copyWith({ ReviewItem copyWith({
DateTime? dueAt, DateTime? dueAt,
ReviewKind? kind,
int? attempts, int? attempts,
int? successfulReviews, int? successfulReviews,
int? variantIndex, int? variantIndex,
@@ -436,6 +512,7 @@ class ReviewItem {
hint: hint ?? this.hint, hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt, dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill, skill: skill ?? this.skill,
kind: kind ?? this.kind,
attempts: attempts ?? this.attempts, attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews, successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex, variantIndex: variantIndex ?? this.variantIndex,
@@ -5,6 +5,7 @@ import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import 'lesson_path.dart'; import 'lesson_path.dart';
import 'today_task_card.dart'; import 'today_task_card.dart';
import 'word_task_card.dart';
/// 学习 tab:今天的任务在上,完整课程路径在下。 /// 学习 tab:今天的任务在上,完整课程路径在下。
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
@@ -17,6 +18,7 @@ class HomePage extends StatelessWidget {
required this.onResumeLessonDialogue, required this.onResumeLessonDialogue,
required this.onStartReinforcement, required this.onStartReinforcement,
required this.onOpenAssessment, required this.onOpenAssessment,
required this.onOpenWords,
}); });
final AppState state; final AppState state;
@@ -26,6 +28,7 @@ class HomePage extends StatelessWidget {
final VoidCallback onResumeLessonDialogue; final VoidCallback onResumeLessonDialogue;
final VoidCallback onStartReinforcement; final VoidCallback onStartReinforcement;
final ValueChanged<String> onOpenAssessment; final ValueChanged<String> onOpenAssessment;
final VoidCallback onOpenWords;
@override @override
Widget build(BuildContext context) => AppPage( Widget build(BuildContext context) => AppPage(
@@ -41,6 +44,7 @@ class HomePage extends StatelessWidget {
onStartReinforcement: onStartReinforcement, onStartReinforcement: onStartReinforcement,
onOpenAssessment: onOpenAssessment, onOpenAssessment: onOpenAssessment,
), ),
WordTaskCard(state: state, onOpenWords: onOpenWords),
LessonPath(state: state, onOpenLesson: onOpenLesson), LessonPath(state: state, onOpenLesson: onOpenLesson),
const _FrameworkNote(), const _FrameworkNote(),
], ],
@@ -69,15 +69,29 @@ class TodayTaskCard extends StatelessWidget {
); );
} }
if (state.reviewIsPrimary) { if (state.reviewIsPrimary) {
final count = state.dueReviewCount; final planned = state.todayReviewPlan.length;
final postponed = state.postponedReviewCount;
return _TodayTask( return _TodayTask(
label: '今日复习', label: '今日复习',
title: '先复习 $count', title: '先复习 $planned',
note: state.reviewBacklog note: postponed > 0
? '今天的复习时间安排 $planned 项,其余 $postponed 项明天接着做,不会丢。'
: state.reviewBacklog
? '有积压项目;先花几分钟清掉到期复习,再开启新课。' ? '有积压项目;先花几分钟清掉到期复习,再开启新课。'
: '之前练过的关键句,今天换个情境再用一次。', : '之前练过的关键句,今天换个情境再用一次。',
action: '开始复习', 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, 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 '../../core/writing_feedback.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart'; import '../../widgets/lexicon_lookup.dart';
import '../../widgets/usage_card.dart';
import '../../widgets/voice_answer.dart'; import '../../widgets/voice_answer.dart';
part 'steps/independent_step.dart'; part 'steps/independent_step.dart';
@@ -148,6 +149,7 @@ class _LessonFlowState extends State<LessonFlow> {
case LessonStep.speaking: case LessonStep.speaking:
content = _SpeakingStep( content = _SpeakingStep(
state: widget.state, state: widget.state,
segmentId: segment.id,
text: activity.speaking, text: activity.speaking,
tip: activity.speakingTip, tip: activity.speakingTip,
keepRecording: widget.state.keepRecordings, keepRecording: widget.state.keepRecordings,
@@ -47,6 +47,7 @@ class _PreviewStep extends StatelessWidget {
], ],
), ),
), ),
UsageCard(itemId: item.id, state: state),
Wrap( Wrap(
spacing: 8, spacing: 8,
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)], children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
@@ -3,11 +3,13 @@ part of '../lesson_flow.dart';
class _SpeakingStep extends StatefulWidget { class _SpeakingStep extends StatefulWidget {
const _SpeakingStep({ const _SpeakingStep({
required this.state, required this.state,
required this.segmentId,
required this.text, required this.text,
this.tip = '', this.tip = '',
required this.keepRecording, required this.keepRecording,
required this.onContinue, required this.onContinue,
}); });
final String segmentId;
final String text; final String text;
final String tip; final String tip;
final AppState state; final AppState state;
@@ -92,6 +94,7 @@ class _SpeakingStepState extends State<_SpeakingStep>
style: TextStyle(color: AppColors.warmInk), style: TextStyle(color: AppColors.warmInk),
), ),
), ),
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
SecondaryButton( SecondaryButton(
label: transcribing label: transcribing
? '正在 AI 识别发音…' ? '正在 AI 识别发音…'
@@ -91,6 +91,7 @@ class _WritingStepState extends State<_WritingStep> {
tint: AppColors.surfaceMuted, tint: AppColors.surfaceMuted,
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'), child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
), ),
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
if (widget.showHelp) if (widget.showHelp)
SectionCard( SectionCard(
tint: AppColors.softGreen, tint: AppColors.softGreen,
@@ -11,6 +11,7 @@ import '../../core/courses/courses.dart';
import '../../core/speech_compare.dart'; import '../../core/speech_compare.dart';
import '../../core/voice_service.dart'; import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart'; import '../../widgets/app_widgets.dart';
import '../../widgets/usage_card.dart';
import '../../widgets/voice_answer.dart'; import '../../widgets/voice_answer.dart';
class ReviewPage extends StatefulWidget { class ReviewPage extends StatefulWidget {
@@ -50,6 +51,15 @@ class _ReviewPageState extends State<ReviewPage>
({String answer, String reference, String? audio, bool assisted})? lastResult; ({String answer, String reference, String? audio, bool assisted})? lastResult;
bool dictationPlayed = false; 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 @override
AppState get voiceState => widget.state; 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( Widget _resultView(
({String answer, String reference, String? audio, bool assisted}) result, ({String answer, String reference, String? audio, bool assisted}) result,
) { ) {
final remaining = widget.state.dueReviews.length; final remaining = widget.state.reviewSession.length;
return AppPage( return AppPage(
child: SpacedColumn( child: SpacedColumn(
children: [ children: [
@@ -311,11 +515,12 @@ class _ReviewPageState extends State<ReviewPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (contrastFor case final item?) return _contrastView(item);
if (lastResult case final result?) return _resultView(result); if (lastResult case final result?) return _resultView(result);
final item = widget.state.dueReviews.isEmpty final session = widget.state.reviewSession;
? null final item = session.isEmpty ? null : session.first;
: widget.state.dueReviews.first;
if (item == null) { if (item == null) {
final postponed = widget.state.postponedReviewCount;
return AppPage( return AppPage(
child: SpacedColumn( child: SpacedColumn(
children: [ children: [
@@ -324,12 +529,20 @@ class _ReviewPageState extends State<ReviewPage>
'到期项目已安排下次复练。', '到期项目已安排下次复练。',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
), ),
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'), Text(
postponed > 0
? '今天的复习时间用完了,还有 $postponed 项到期,明天接着做,没有被丢掉。'
: '记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。',
),
PrimaryButton(label: '回到首页', onPressed: widget.onFinished), PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
], ],
), ),
); );
} }
if (widget.state.needsRecognitionGate(item) &&
!gateCleared.contains(item.id)) {
return _recognitionView(item);
}
final dictation = item.skill == dictationSkill final dictation = item.skill == dictationSkill
? coreDictationSentences[item.id] ? coreDictationSentences[item.id]
: null; : null;
@@ -337,17 +550,33 @@ class _ReviewPageState extends State<ReviewPage>
final audioText = reviewAudioText(item.id, item.target); final audioText = reviewAudioText(item.id, item.target);
final checkpoint = final checkpoint =
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews; widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
final checkpointLabel = checkpoint >= 4 final isRecap = item.kind == ReviewKind.recap;
final checkpointLabel = isRecap
? '不计检查点 · 只是趁热再想一遍'
: checkpoint >= 4
? '30 天抽查' ? '30 天抽查'
: '${checkpoint + 1} / 4 个间隔检查点'; : '${checkpoint + 1} / 4 个间隔检查点';
return AppPage( return AppPage(
child: SpacedColumn( child: SpacedColumn(
children: [ children: [
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'), Eyebrow(
isRecap
? '再想一遍 · 还有 ${widget.state.reviewSession.length}'
: '今天复习 · ${widget.state.reviewSession.length} 项待完成',
),
Text( Text(
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。', dictation != null
? '听一听,写下来。'
: isRecap
? '刚学过的,现在还想得起来吗?'
: '不看答案,试着回答。',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
), ),
if (isRecap)
Text(
'答不出也不算错,看一眼答案就好——这一次只是帮你记住,真正的检查在明天。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
Text( Text(
'目标技能:${item.skill}', '目标技能:${item.skill}',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
@@ -465,10 +694,7 @@ class _ReviewPageState extends State<ReviewPage>
), ),
ActionChip( ActionChip(
label: const Text('暂时想不起来'), label: const Text('暂时想不起来'),
onPressed: () { onPressed: () => _reportMiss(item),
widget.state.reportReviewFailure(item);
setState(_resetAnswer);
},
), ),
ActionChip( ActionChip(
label: Text(generatingVariant ? '正在生成…' : '生成变式'), label: Text(generatingVariant ? '正在生成…' : '生成变式'),
@@ -509,6 +735,7 @@ class _ReviewPageState extends State<ReviewPage>
icon: const Icon(Icons.volume_up_outlined), icon: const Icon(Icons.volume_up_outlined),
label: Text('听示范:$audioText'), 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 "../lesson/lesson_flow.dart";
import "../profile/profile_page.dart"; import "../profile/profile_page.dart";
import "../review/review_page.dart"; import "../review/review_page.dart";
import "../words/words_page.dart";
class LearningShell extends StatefulWidget { class LearningShell extends StatefulWidget {
const LearningShell({super.key, required this.state}); const LearningShell({super.key, required this.state});
@@ -197,6 +198,11 @@ class _LearningShellState extends State<LearningShell> {
selectedIcon: Icon(Icons.refresh), selectedIcon: Icon(Icons.refresh),
label: "复习", label: "复习",
), ),
NavigationDestination(
icon: Icon(Icons.abc_outlined),
selectedIcon: Icon(Icons.abc),
label: "单词",
),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.person_outline), icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person), selectedIcon: Icon(Icons.person),
@@ -240,6 +246,7 @@ class _LearningShellState extends State<LearningShell> {
.firstOrNull; .firstOrNull;
if (pack != null) showAssessment(pack); if (pack != null) showAssessment(pack);
}, },
onOpenWords: () => showTab(AppTab.words),
); );
case AppTab.dialogue: case AppTab.dialogue:
return DialogueScenePage( return DialogueScenePage(
@@ -252,6 +259,8 @@ class _LearningShellState extends State<LearningShell> {
onFinished: () => showTab(AppTab.learn), onFinished: () => showTab(AppTab.learn),
onOpenAdaptiveLesson: showAdaptiveLesson, onOpenAdaptiveLesson: showAdaptiveLesson,
); );
case AppTab.words:
return WordsPage(state: widget.state);
case AppTab.profile: case AppTab.profile:
return ProfilePage( return ProfilePage(
state: widget.state, 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 510 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/courses/courses.dart';
import '../core/voice_service.dart'; import '../core/voice_service.dart';
import 'app_widgets.dart'; import 'app_widgets.dart';
import 'usage_card.dart';
List<VocabularyItem>? _cachedEntries; List<VocabularyItem>? _cachedEntries;
Map<String, VocabularyItem>? _cachedExactMap; Map<String, VocabularyItem>? _cachedExactMap;
@@ -454,7 +455,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
try { try {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('将短语 "${phrase.phrase}" 加入复习计划'), content: Text('加入单词,之后在「单词」里认它'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -469,7 +470,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
try { try {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('将 "${item.word}" 加入复习计划'), content: Text('加入单词,之后在「单词」里认它'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -663,7 +664,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
_addedToReview.contains(phrase.phrase.toLowerCase()) _addedToReview.contains(phrase.phrase.toLowerCase())
? Chip( ? Chip(
label: Text( label: Text(
'已在复习', '已在单词',
style: TextStyle(fontSize: 12), style: TextStyle(fontSize: 12),
), ),
avatar: Icon( avatar: Icon(
@@ -688,7 +689,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
size: 14, size: 14,
), ),
label: const Text( label: const Text(
'复习', '到单词',
style: TextStyle(fontSize: 12), style: TextStyle(fontSize: 12),
), ),
), ),
@@ -843,14 +844,15 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
tint: AppColors.softGreen, tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'), child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
), ),
UsageCard(itemId: entry!.id, state: widget.state),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: PrimaryButton( child: PrimaryButton(
label: label:
_addedToReview.contains(entry!.word.toLowerCase()) _addedToReview.contains(entry!.word.toLowerCase())
? '已在复习中' ? '已在单词表'
: '入复习', : '到单词',
onPressed: () { onPressed: () {
_addVocabItemToReview(entry!); _addVocabItemToReview(entry!);
}, },
+107
View File
@@ -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),
],
);
}
}
+1
View File
@@ -36,6 +36,7 @@ flutter:
- assets/config/ - assets/config/
- assets/models/sense_voice/ - assets/models/sense_voice/
- assets/courses/ - assets/courses/
- assets/words/
- assets/courses/A0/ - assets/courses/A0/
- assets/courses/A1/ - assets/courses/A1/
- assets/courses/A2/ - assets/courses/A2/
@@ -108,6 +108,13 @@ void main() {
skill: '回忆表达', skill: '回忆表达',
), ),
); );
state.mastery['A0-P12'] = const MasteryItem(
id: 'A0-P12',
label: "I'm from …",
status: MasteryStatus.recall,
evidence: [],
checkpoint: 1,
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
@@ -59,6 +59,99 @@ void main() {
expect(failures, isEmpty); 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', () { test('assessment tasks are answerable and replacements point home', () {
final ids = {for (final pack in assessmentPacks) pack.id}; final ids = {for (final pack in assessmentPacks) pack.id};
final failures = <String>[]; 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.pumpWidget(MaterialApp(home: LearningShell(state: state)));
await tester.pumpAndSettle(); 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.text('课程路径'), findsOneWidget); expect(find.text('课程路径'), findsOneWidget);
expect(find.textContaining('第 1 课 · '), findsWidgets); 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: '回忆表达', skill: '回忆表达',
), ),
); );
state.mastery['A0-P12'] = const MasteryItem(
id: 'A0-P12',
label: "I'm from …",
status: MasteryStatus.recall,
evidence: [],
checkpoint: 1,
);
final content = jsonEncode({ final content = jsonEncode({
'schemaVersion': 'writing-feedback-1', 'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite', '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);
});
}
+16 -13
View File
@@ -169,14 +169,16 @@ void main() {
contextSentence: "I'd like to check in please.", contextSentence: "I'd like to check in please.",
); );
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue); // Learning engine 3.5: a saved phrase is an extension word. It is
final saved = state.reviewQueue.firstWhere( // recognition-only, so it goes to the word list, not the checkpoint
(r) => r.target == 'check in', // 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(state.savedWords[id]!.zh, contains('办理登机或入住'));
expect(saved.hint, contains('办理登机或入住')); expect(state.savedWords[id]!.zh, contains('酒店机场高频词'));
expect(saved.hint, contains('酒店机场高频词')); expect(state.wordStatus(id), WordStatus.newWord);
expect(saved.prompt, "I'd like to check in please.");
}, },
); );
}); });
@@ -234,19 +236,20 @@ void main() {
expect(find.text('重点短语与搭配 (1)'), findsOneWidget); expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
expect(find.text('departure gate'), findsOneWidget); expect(find.text('departure gate'), findsOneWidget);
expect(find.text('登机口'), findsOneWidget); expect(find.text('登机口'), findsOneWidget);
expect(find.text('复习'), findsOneWidget); expect(find.text('到单词'), findsOneWidget);
// Scroll to '加复习' and tap // Scroll to '加到单词' and tap
await tester.ensureVisible(find.text('复习')); await tester.ensureVisible(find.text('到单词'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('复习')); await tester.tap(find.text('到单词'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
state.reviewQueue.any((r) => r.target == 'departure gate'), state.savedWords.values.any((word) => word.en == 'departure gate'),
isTrue, isTrue,
); );
expect(find.text('已在复习'), findsOneWidget); expect(state.reviewQueue, isEmpty);
expect(find.text('已在单词'), findsOneWidget);
}); });
}); });
} }
+175
View File
@@ -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);
});
}
+216
View File
@@ -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))),
);
}
});
}
+91
View File
@@ -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.0README 自称「整理自公开来源,仅供个人学习,严禁商用」。
上游权利不明,**低于本项目 `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`,决定发放顺序。
## 现状
| 项 | 数值 |
| --- | --- |
| 词条总数 | 2748A1 807 / A2 912 / B1 1029|
| A1 词池覆盖 | 807 / 828 = 97% |
| A2 词池覆盖 | 912 / 1065 = 86% |
| B1 词池覆盖 | 1029 / 1557 = 66% |
| 有例句 | 2748100%|
| 有词频名次 | 273799.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 条,全部核对无误。
+69
View File
@@ -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()
+131
View File
@@ -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()
+171
View File
@@ -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()
+42
View File
@@ -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