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
+352 -6
View File
@@ -1,8 +1,13 @@
import 'course_pack.dart';
/// A produced core item as the registries see it: the level of the pack that
/// teaches it and its English and Chinese forms.
typedef RegisteredCoreItem = ({String level, String en, String zh});
/// A produced core item as the registries see it: the pack that teaches it,
/// its level, and its English and Chinese forms.
typedef RegisteredCoreItem = ({
String level,
String unit,
String en,
String zh,
});
/// Every produced core item across A0–B1, in course order, filled from the
/// packs' `coreItems` by `CourseRepository`. IDs are separate from the
@@ -21,6 +26,192 @@ Map<String, String> get a0CoreItems => {
if (entry.value.level == 'A0') entry.key: entry.value.en,
};
/// A recognition-only word as the registries see it. Learning engine 3.5
/// keeps these apart from produced core items: they are heard and read, never
/// required to be said or spelled, and they do not count toward a level's
/// upgrade denominator.
typedef RegisteredReceptiveWord = ({
String level,
String unit,
String en,
String zh,
String ipa,
});
final Map<String, RegisteredReceptiveWord> receptiveWordRegistry =
<String, RegisteredReceptiveWord>{};
void registerReceptiveWord(
ReceptiveWord word, {
required String level,
required String unit,
}) {
receptiveWordRegistry[word.id] = (
level: level,
unit: unit,
en: word.en,
zh: word.zh,
ipa: '',
);
}
/// Registers a word saved from lookup. It has no unit, so its distractors
/// come from the same level.
void registerSavedWord(
String id, {
required String en,
required String zh,
required String level,
}) {
receptiveWordRegistry[id] = (
level: level,
unit: '',
en: en,
zh: zh,
ipa: '',
);
}
/// Registers one word of the standalone word bank. Unlike a unit's receptive
/// words, these do not wait for a lesson to be finished.
void registerBankWord(
String id, {
required String en,
required String zh,
required String ipa,
required String level,
required String unit,
}) {
receptiveWordRegistry[id] = (
level: level,
unit: unit,
en: en,
zh: zh,
ipa: ipa,
);
}
/// The IPA of a word, empty when none is known.
String wordIpa(String id) => receptiveWordRegistry[id]?.ipa ?? '';
/// Whether [id] is a recognition-only word.
bool isReceptiveWord(String id) => receptiveWordRegistry.containsKey(id);
/// The English of a word of either layer.
String wordEnglish(String id) =>
receptiveWordRegistry[id]?.en ?? coreItemEnglish(id);
/// The Chinese of a word of either layer.
String wordMeaning(String id) =>
receptiveWordRegistry[id]?.zh ?? coreItemMeaning(id);
/// The unit that teaches [id], in either layer; empty when nothing does.
String wordUnit(String id) =>
receptiveWordRegistry[id]?.unit ?? coreItemRegistry[id]?.unit ?? '';
/// The receptive words a unit teaches, in pack order.
List<String> receptiveWordsOfUnit(String unit) => [
for (final entry in receptiveWordRegistry.entries)
if (entry.value.unit == unit) entry.key,
];
/// Wrong answers for a receptive word's recognition question, as word ids,
/// from the same unit first, then the same level (learning engine 3.5:
/// distractors come from the same unit or from near meanings). The caller
/// takes the meaning or the spelling off each, depending on which way round
/// it is asking.
///
/// Words whose meaning overlaps the target's are skipped, and no two options
/// share a meaning, so a question never has two right answers. That matters
/// most when the choice is between spellings: the meaning on screen is then
/// the only thing to go on.
///
/// A word saved from lookup can be the only one of its level, so anything else
/// in the registry is taken rather than leaving the word unaskable.
List<String> receptiveDistractors(String id, {int count = 2}) {
final target = receptiveWordRegistry[id];
if (target == null) return const [];
final sameUnit = <String>[];
final sameLevel = <String>[];
final rest = <String>[];
final taken = <String>{};
for (final entry in receptiveWordRegistry.entries) {
if (entry.key == id) continue;
final option = entry.value.zh.trim();
if (option.isEmpty || entry.value.en.trim().isEmpty) continue;
if (option == target.zh.trim()) continue;
if (option.contains(target.zh) || target.zh.contains(option)) continue;
if (!taken.add(option)) continue;
if (entry.value.unit == target.unit) {
sameUnit.add(entry.key);
} else if (entry.value.level == target.level) {
sameLevel.add(entry.key);
} else if (rest.length < count) {
rest.add(entry.key);
}
}
return [...sameUnit, ...sameLevel, ...rest].take(count).toList();
}
/// Which way round the word is asked.
///
/// Learning engine 3.5 counts 「听辨或阅读」 as recognition, and a word that
/// can only be recognised in writing is half learned — spoken English arrives
/// as sound. [recall] goes the other way, from the meaning to the word; it
/// stays a choice among spellings rather than something to write, so the word
/// is still only ever recognised and never enters the production ladder.
enum WordAskMode { read, listen, recall }
/// A 5–10 second recognition question for a receptive word (learning engine
/// 3.5).
///
/// [shown] is the prompt: the English for [WordAskMode.read], the Chinese for
/// [WordAskMode.recall]. A [WordAskMode.listen] question carries the English
/// too — it is what the synthesiser speaks and what the feedback shows — and
/// only hides it while the question is open. The English is always available
/// from the id through [wordEnglish], which is what the page speaks.
typedef WordQuestion = ({
String id,
String shown,
String answer,
List<String> options,
WordAskMode mode,
});
/// Null when the word has no distractors to offer, which means it cannot be
/// asked as a choice yet.
WordQuestion? receptiveQuestion(
String id, {
int variant = 0,
WordAskMode mode = WordAskMode.read,
}) {
final word = receptiveWordRegistry[id];
if (word == null || word.en.isEmpty || word.zh.isEmpty) return null;
final wrong = receptiveDistractors(id, count: 3);
if (wrong.isEmpty) return null;
// A different variant asks the same word against other meanings, which is
// what 「换题、换语境」 means for a word this small.
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
.take(2)
.toList();
// Asked from the meaning, the options are spellings and the prompt is the
// Chinese; asked either other way, it is the other way round.
final asksSpelling = mode == WordAskMode.recall;
String optionOf(String other) => asksSpelling
? receptiveWordRegistry[other]!.en
: receptiveWordRegistry[other]!.zh;
final answer = asksSpelling ? word.en : word.zh;
final options = [answer, ...rotated.map(optionOf)];
final offset = (id.hashCode.abs() + variant) % options.length;
return (
id: id,
shown: asksSpelling ? word.zh : word.en,
answer: answer,
options: [...options.skip(offset), ...options.take(offset)],
mode: mode,
);
}
class CoreReviewTemplate {
const CoreReviewTemplate({
required this.prompt,
@@ -43,9 +234,23 @@ final Map<String, String> coreReviewHints = <String, String>{};
final Map<String, DictationSentence> coreDictationSentences =
<String, DictationSentence>{};
/// Registers [item], taught at [level], with its review data.
void registerCoreItem(CoreItem item, {required String level}) {
coreItemRegistry[item.id] = (level: level, en: item.en, zh: item.zh);
/// How to use each core item that needs explaining, straight from the pack's
/// `usage`. Items whose meaning is the whole story (`book`, `Monday`) have no
/// entry.
final Map<String, CoreUsage> coreUsages = <String, CoreUsage>{};
/// Registers [item], taught in [unit] at [level], with its review data.
void registerCoreItem(
CoreItem item, {
required String level,
String unit = '',
}) {
coreItemRegistry[item.id] = (
level: level,
unit: unit,
en: item.en,
zh: item.zh,
);
coreItemMatch[item.id] = item.match;
coreReviewTemplates[item.id] = CoreReviewTemplate(
prompt: item.reviewPrompt.isNotEmpty
@@ -55,6 +260,7 @@ void registerCoreItem(CoreItem item, {required String level}) {
skill: item.type == 'word' ? '词汇回忆' : '回忆表达',
);
if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint;
if (!item.usage.isEmpty) coreUsages[item.id] = item.usage;
if (item.dictationSentence.isNotEmpty) {
coreDictationSentences[item.id] = (
sentence: item.dictationSentence,
@@ -70,6 +276,8 @@ void clearCoreItems() {
coreReviewTemplates.clear();
coreReviewHints.clear();
coreDictationSentences.clear();
coreUsages.clear();
receptiveWordRegistry.clear();
}
/// The label a learner sees for a core item, falling back to its id. A0
@@ -85,6 +293,12 @@ String coreItemLabel(String id) {
/// English use this.
String coreItemEnglish(String id) => coreItemRegistry[id]?.en ?? id;
/// The item as a learner would say it. A pattern is registered with its slot
/// showing — `I'm [state].` — which is right for a matching rule and wrong to
/// read, so the pack's own example sentence stands in when there is one.
String coreItemSpoken(String id) =>
coreDictationSentences[id]?.sentence ?? coreItemEnglish(id);
/// Whether [id] is a produced core item the course tracks for mastery.
bool isCoreItem(String id) => coreItemRegistry.containsKey(id);
@@ -121,6 +335,138 @@ CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
const dictationSkill = '听写';
const spokenRecallSkill = '口头回忆';
/// The skill of the recognition question asked before the first checkpoint
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
const recognitionGateSkill = '听辨识别';
/// The skill of a contrast question: a situation is described and the learner
/// picks which taught sentence it calls for. It shows the learner knows *when*
/// to say something, which is not the same as being able to produce it, so it
/// counts as recognition evidence and never advances a checkpoint on its own.
const contrastSkill = '情境辨析';
/// Whether two core items are taught in the same unit.
bool _sameUnit(String a, String b) {
final unit = coreItemRegistry[a]?.unit ?? '';
return unit.isNotEmpty && unit == coreItemRegistry[b]?.unit;
}
/// The confusables of [id] worth showing a learner now. A pack may pair an
/// item with one taught several units later — `What's your name?` against
/// `Where are you from?` — and naming a sentence the learner has never seen
/// explains nothing, so a pairing only appears once the other item is in the
/// same unit or has been taught. Passing no [isTaught] keeps them all, which
/// is what content checks want.
List<Confusable> visibleConfusables(
String id, {
bool Function(String id)? isTaught,
}) => [
for (final other in coreUsages[id]?.confuse ?? const <Confusable>[])
if (coreItemRegistry.containsKey(other.id) &&
(isTaught == null || _sameUnit(id, other.id) || isTaught(other.id)))
other,
];
/// How to use [id], or null when the pack explains nothing about it.
CoreUsage? coreUsage(String id) => coreUsages[id];
/// A contrast question built from an item's `confuse` list: the situation from
/// its own `usage.when`, the taught sentences as options, and the note that
/// explains the difference after an answer.
typedef ContrastQuestion = ({
String situation,
String answer,
List<String> options,
String note,
});
ContrastQuestion? coreContrastQuestion(
String id, {
bool Function(String id)? isTaught,
}) {
final usage = coreUsages[id];
if (usage == null || usage.when.isEmpty) return null;
final answer = coreItemSpoken(id);
final notes = <String>[];
final options = <String>{answer};
for (final other in visibleConfusables(id, isTaught: isTaught)) {
// Two wrong options is already a real choice; a longer list turns telling
// two sentences apart into a reading exercise.
if (options.length >= 3) break;
final english = coreItemSpoken(other.id);
if (english.isEmpty || english == answer) continue;
options.add(english);
if (other.note.isNotEmpty) notes.add(other.note);
}
if (options.length < 2) return null;
final ordered = options.toList();
final offset = id.hashCode.abs() % ordered.length;
return (
situation: usage.when,
answer: answer,
options: [...ordered.skip(offset), ...ordered.take(offset)],
note: notes.join('\n'),
);
}
/// What the item means, as a learner would read it: the Chinese form when the
/// pack has one, otherwise the reviewed situation the phrase is used in.
String coreItemMeaning(String id) {
final zh = coreItemRegistry[id]?.zh.trim() ?? '';
if (zh.isNotEmpty) return zh;
final prompt = coreReviewTemplates[id]?.prompt.trim() ?? '';
return prompt.isNotEmpty ? prompt : coreItemEnglish(id);
}
/// A recognition question: what the learner hears or reads, and what the
/// right option says. Learning engine 3.5 asks for recognition inside a
/// sentence or a situation, so the item's dictation sentence is used whenever
/// the pack has one. That also keeps near-synonyms apart: `hello` and `hi`
/// both mean 你好 on their own, but `Hello, how are you?` and
/// `Hi, what's your name?` do not.
typedef RecognitionQuestion = ({String shown, String answer});
RecognitionQuestion coreRecognitionQuestion(String id) {
final dictation = coreDictationSentences[id];
if (dictation != null && dictation.meaning.trim().isNotEmpty) {
return (shown: dictation.sentence, answer: dictation.meaning);
}
return (shown: coreItemEnglish(id), answer: coreItemMeaning(id));
}
/// Wrong options for a recognition question, taken from other core items in
/// the same unit (learning engine 3.5: distractors come from the same unit or
/// from near meanings, never from obviously unrelated words). Options are
/// asked in the same form as the answer — sentence against sentence — so the
/// right one cannot be spotted by its length alone.
List<String> recognitionDistractors(String id, {int count = 2}) {
final target = coreItemRegistry[id];
if (target == null) return const [];
final question = coreRecognitionQuestion(id);
final sentenceForm = coreDictationSentences[id] != null;
final sameUnit = <String>[];
final sameLevel = <String>[];
for (final entry in coreItemRegistry.entries) {
if (entry.key == id) continue;
if ((coreDictationSentences[entry.key] != null) != sentenceForm) continue;
final option = coreRecognitionQuestion(entry.key).answer;
if (option.isEmpty) continue;
// A distractor that contains the answer, or is contained by it, would be
// right too — `嗨,你叫什么名字?` against `你叫什么名字?`.
if (option.contains(question.answer) || question.answer.contains(option)) {
continue;
}
if (target.unit.isNotEmpty && entry.value.unit == target.unit) {
sameUnit.add(option);
} else if (entry.value.level == target.level) {
sameLevel.add(option);
}
}
final options = <String>{...sameUnit, ...sameLevel};
return options.take(count).toList();
}
/// A concrete sentence built only from taught language, used to play a core
/// item in context and to check dictation.
typedef DictationSentence = ({String sentence, String meaning});
@@ -313,6 +313,56 @@ class PackSegment {
}
}
/// One taught item that is easy to mistake for another, with the difference
/// spelled out. `A0-P02` (`What's your name?`) lists `A0-P05`
/// (`How are you?`), because knowing both sentences is not the same as
/// knowing which one a situation calls for.
class Confusable {
const Confusable({required this.id, required this.note});
final String id;
final String note;
factory Confusable.fromJson(Map<String, dynamic> json) => Confusable(
id: json['id'] as String? ?? '',
note: json['note'] as String? ?? '',
);
}
/// How a pattern is used, as opposed to what it means: the situation it
/// belongs to, the reply it invites, an alternative wording, and the taught
/// items it is confused with. The course JSON carries this; nothing here is
/// generated at runtime.
class CoreUsage {
const CoreUsage({
this.when = '',
this.reply = '',
this.swap = '',
this.confuse = const [],
});
/// The situation the item is used in, in one line.
final String when;
/// What the other person usually answers, or how to answer it.
final String reply;
/// Another taught way to say the same thing.
final String swap;
final List<Confusable> confuse;
bool get isEmpty =>
when.isEmpty && reply.isEmpty && swap.isEmpty && confuse.isEmpty;
factory CoreUsage.fromJson(Map<String, dynamic> json) => CoreUsage(
when: json['when'] as String? ?? '',
reply: json['reply'] as String? ?? '',
swap: json['swap'] as String? ?? '',
confuse: _objectList(json['confuse']).map(Confusable.fromJson).toList(),
);
}
/// A produced core item (word / phrase / pattern) with its acceptance rule.
class CoreItem {
const CoreItem({
@@ -327,6 +377,7 @@ class CoreItem {
this.reviewHint = '',
this.dictationSentence = '',
this.dictationMeaning = '',
this.usage = const CoreUsage(),
});
final String id;
@@ -348,6 +399,9 @@ class CoreItem {
final String dictationSentence;
final String dictationMeaning;
/// When to use the item, empty for anything that needs no explaining.
final CoreUsage usage;
factory CoreItem.fromJson(Map<String, dynamic> json) {
final example = _objectOrEmpty(json['example']);
final review = _objectOrEmpty(json['review']);
@@ -364,6 +418,7 @@ class CoreItem {
reviewHint: review['hint'] as String? ?? '',
dictationSentence: dictation['sentence'] as String? ?? '',
dictationMeaning: dictation['meaning'] as String? ?? '',
usage: CoreUsage.fromJson(_objectOrEmpty(json['usage'])),
);
}
}
@@ -176,7 +176,10 @@ class CourseRepository {
// Core items: labels, acceptance rules and offline review prompts.
for (final item in pack.coreItems) {
registerCoreItem(item, level: pack.level);
registerCoreItem(item, level: pack.level, unit: pack.id);
}
for (final word in pack.receptiveWords) {
registerReceptiveWord(word, level: pack.level, unit: pack.id);
}
final segments = <LessonSegment>[];
@@ -9,3 +9,4 @@ export 'core_items.dart';
export 'course_catalog.dart';
export 'course_models.dart';
export 'option_order.dart';
export 'word_bank.dart';
@@ -0,0 +1,80 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'core_items.dart';
/// A bank word used in a sentence, with the sentence's Chinese.
typedef WordExample = ({String en, String zh});
/// The standalone word bank (`assets/words/wordbank.json`).
///
/// It is deliberately independent of the course packs: the words a learner can
/// study must not be capped by how many units they have finished, which was
/// the whole problem with growing vocabulary out of `receptiveWords` alone.
/// CEFR-J decides which words and at what level; the bank only carries the
/// IPA and the Chinese gloss.
class WordBank {
WordBank._();
static final instance = WordBank._();
static const _asset = 'assets/words/wordbank.json';
/// The unit a bank word is filed under, e.g. `bank-A1`.
static String unitOf(String level) => 'bank-$level';
static bool isBankUnit(String unit) => unit.startsWith('bank-');
static String levelOfUnit(String unit) => unit.substring('bank-'.length);
bool _loaded = false;
final Map<String, String> _extraSenses = {};
final Map<String, WordExample> _examples = {};
final Map<String, int> _ranks = {};
/// Senses beyond the one used as the quiz answer; empty when there are none.
String moreSenses(String id) => _extraSenses[id] ?? '';
/// The word in a sentence, which is what the meaning is shown against once
/// the answer is out. Null when the bank has none for this word.
WordExample? exampleOf(String id) => _examples[id];
/// How common the word is in spoken English, 0 being the most common. New
/// words are handed out in this order so the bank does not start with a
/// whole week of words beginning with `a`.
static const unranked = 99999;
int rankOf(String id) => _ranks[id] ?? unranked;
Future<void> load({AssetBundle? bundle}) async {
if (_loaded) return;
final raw = await (bundle ?? rootBundle).loadString(_asset);
final data = jsonDecode(raw) as Map<String, dynamic>;
for (final entry in (data['words'] as List<dynamic>)) {
final word = entry as Map<String, dynamic>;
final id = word['id'] as String;
final level = word['level'] as String? ?? 'A1';
registerBankWord(
id,
en: word['en'] as String? ?? '',
zh: word['zh'] as String? ?? '',
ipa: word['ipa'] as String? ?? '',
level: level,
unit: unitOf(level),
);
final more = word['more'] as String? ?? '';
if (more.isNotEmpty) _extraSenses[id] = more;
_ranks[id] = word['rank'] as int? ?? unranked;
final en = word['ex'] as String? ?? '';
if (en.isNotEmpty) {
_examples[id] = (en: en, zh: word['exZh'] as String? ?? '');
}
}
_loaded = true;
}
/// Test seam: drops what was loaded so another bundle can be loaded.
void resetForTest() {
_loaded = false;
_extraSenses.clear();
_examples.clear();
_ranks.clear();
}
}