Files
English/kouyu_english/lib/core/models.dart
T
shenleiandClaude Opus 5 6b42b7abc3 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>
2026-09-20 23:54:17 +09:00

598 lines
16 KiB
Dart

enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { learn, dialogue, review, words, profile }
enum LessonStep {
preview,
listening,
speaking,
reading,
material,
writing,
dialogue,
independent,
complete,
}
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
/// Learning engine 3.5: a recognition-only word has its own three states,
/// deliberately shorter than [MasteryStatus]. It is never asked to be spelled
/// or said, and it never counts toward a level's upgrade denominator.
enum WordStatus { newWord, recognized, familiar }
/// What the app knows about one recognition-only word.
class WordKnowledge {
const WordKnowledge({
required this.id,
required this.status,
this.recognizedAt,
this.dueAt,
this.misses = 0,
this.step = 0,
this.startedAt,
});
final String id;
final WordStatus status;
/// When it first became 「认识」. 「熟悉」 needs a second success at least
/// seven days after this, on a different question.
final DateTime? recognizedAt;
/// When it comes up for a check again; null once nothing is scheduled.
final DateTime? dueAt;
/// How often recognition has failed, used to surface the weak ones.
final int misses;
/// Successes in a row, which is the rung of the spacing ladder this word is
/// on. A miss puts it back to 0.
final int step;
/// When the word entered the learner's list, which is what the daily cap on
/// new words is counted against.
final DateTime? startedAt;
WordKnowledge copyWith({
WordStatus? status,
DateTime? recognizedAt,
DateTime? dueAt,
int? misses,
int? step,
DateTime? startedAt,
}) => WordKnowledge(
id: id,
status: status ?? this.status,
recognizedAt: recognizedAt ?? this.recognizedAt,
dueAt: dueAt ?? this.dueAt,
misses: misses ?? this.misses,
step: step ?? this.step,
startedAt: startedAt ?? this.startedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status.name,
'recognizedAt': recognizedAt?.toIso8601String(),
'dueAt': dueAt?.toIso8601String(),
'misses': misses,
'step': step,
'startedAt': startedAt?.toIso8601String(),
};
}
enum EvidenceKind {
exposure,
assisted,
independentSuccess,
languageError,
pending,
}
/// Local dialogue matching is intentionally tri-state. A natural English
/// reply that does not match a strict taught-language rule is unknown, not
/// automatically wrong.
enum LocalDialogueVerdict { accepted, rejected, uncertain }
enum DialogueAiVerdict { accepted, correctable, offTopic, uncertain }
enum DialogueValidationSource {
local,
ai,
localAndAi,
disagreement,
unavailable,
}
enum DialogueSupportLevel {
none,
validationOnly,
hint,
translation,
correction,
skipped,
}
/// The application-owned result of combining local rules with advisory AI.
/// Conversation progress and mastery evidence are deliberately separate.
class DialogueTurnDecision {
const DialogueTurnDecision({
required this.canAdvance,
required this.evidenceOutcome,
required this.validationSource,
required this.supportLevel,
this.feedback,
this.suggestion,
});
final bool canAdvance;
final EvidenceKind evidenceOutcome;
final DialogueValidationSource validationSource;
final DialogueSupportLevel supportLevel;
final String? feedback;
final String? suggestion;
bool get assisted => switch (supportLevel) {
DialogueSupportLevel.hint ||
DialogueSupportLevel.translation ||
DialogueSupportLevel.correction ||
DialogueSupportLevel.skipped => true,
_ => false,
};
}
class AttemptEvidence {
const AttemptEvidence({
required this.id,
required this.itemId,
required this.taskId,
required this.skill,
required this.inputMode,
required this.outcome,
required this.createdAt,
this.rawAnswer,
this.recordingPath,
this.assisted = false,
this.variantIndex = 0,
this.originalTranscript,
this.transcriptConfirmed = false,
this.transcriptEdited = false,
});
final String id;
final String itemId;
final String taskId;
final String skill;
final String inputMode;
final EvidenceKind outcome;
final DateTime createdAt;
final String? rawAnswer;
/// Local-only file reference. It is absent unless the learner opted to keep
/// recordings; no path or audio is sent to an AI provider.
final String? recordingPath;
final bool assisted;
final int variantIndex;
/// The device STT result before the learner edits it. This local-only field
/// distinguishes confirmed spoken input from a subsequent typed rewrite.
final String? originalTranscript;
final bool transcriptConfirmed;
final bool transcriptEdited;
}
enum AiProviderType { mock, gemini, openAi, compatible }
enum AssessmentSkill { listening, speaking, reading, writing }
enum ContentStatus { draft, validated, approved, rejected }
enum ContentSource { builtInOriginal, aiGenerated, importedReference }
/// A validated, bounded suggestion from an AI conversation provider.
/// The client still owns task completion and mastery decisions.
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
this.slots = const {},
this.evidence = const [],
this.suggestsComplete = false,
this.translation,
this.feedback,
});
final String reply;
final String? translation;
final Map<String, String> slots;
final List<String> evidence;
final bool suggestsComplete;
final String? feedback;
}
/// A bounded suggestion only. It never changes completion, mastery, or a
/// stage result; those decisions remain on-device and evidence-based.
class WritingAiFeedback {
const WritingAiFeedback({
required this.verdict,
required this.feedback,
required this.suggestion,
required this.missing,
});
/// `accepted`, `rewrite`, or `uncertain`.
final String verdict;
final String feedback;
final String? suggestion;
final List<String> missing;
}
/// Result of AI intervention during a dialogue turn when preset rules do not match.
class DialogueAiIntervention {
const DialogueAiIntervention({
required this.accepted,
this.suggestion,
required this.explanation,
this.schemaVersion = 'dialogue-intervention-1',
this.turnId,
this.verdict = DialogueAiVerdict.uncertain,
bool? goalSatisfied,
this.reasonCode = 'unspecified',
}) : goalSatisfied = goalSatisfied ?? accepted;
/// Whether the learner's response is semantically acceptable for the turn.
final bool accepted;
/// Inferred or corrected English sentence if there was an ASR slip, typo, or minor mistake.
final String? suggestion;
/// Encouraging, concise Chinese explanation of the situation and recommendation.
final String explanation;
final String schemaVersion;
final String? turnId;
final DialogueAiVerdict verdict;
final bool goalSatisfied;
final String reasonCode;
}
class AssessmentRecord {
const AssessmentRecord({
required this.packId,
required this.completedAt,
required this.results,
this.pendingSkills = const {},
});
final String packId;
final DateTime completedAt;
final Map<AssessmentSkill, bool> results;
final Set<AssessmentSkill> pendingSkills;
bool get passed =>
AssessmentSkill.values.every((skill) => results[skill] == true);
List<AssessmentSkill> get failedSkills => AssessmentSkill.values
.where(
(skill) => results[skill] != true && !pendingSkills.contains(skill),
)
.toList();
}
class AssessmentDraft {
const AssessmentDraft({
required this.packId,
required this.taskIndex,
required this.results,
});
final String packId;
final int taskIndex;
final Map<String, bool> results;
}
class DialogueDraft {
const DialogueDraft({
required this.lessonId,
required this.stage,
required this.turns,
required this.usedHelp,
this.currentTurnHintUsed = false,
this.currentTurnTranslationUsed = false,
this.currentTurnCorrectionUsed = false,
});
final String lessonId;
final int stage;
final List<DialogueTurn> turns;
final bool usedHelp;
final bool currentTurnHintUsed;
final bool currentTurnTranslationUsed;
final bool currentTurnCorrectionUsed;
}
class LessonSummary {
const LessonSummary({
required this.number,
required this.title,
required this.outcome,
this.isComplete = false,
this.isCurrent = false,
});
final int number;
final String title;
final String outcome;
final bool isComplete;
final bool isCurrent;
}
class VocabularyItem {
const VocabularyItem({
required this.id,
required this.word,
required this.meaning,
required this.example,
required this.exampleMeaning,
this.ipa,
});
factory VocabularyItem.fromJson(Map<String, dynamic> json) => VocabularyItem(
id: json['id'] as String,
word: json['word'] as String,
meaning: json['meaning'] as String,
example: json['example'] as String? ?? '',
exampleMeaning: json['exampleMeaning'] as String? ?? '',
ipa: json['ipa'] as String?,
);
final String id;
final String word;
final String meaning;
final String example;
final String exampleMeaning;
final String? ipa;
}
/// A non-core explanation returned by a configured text provider for a word
/// or phrase that is not present in the reviewed course lexicon. These
/// entries deliberately never participate in review scheduling or mastery.
class TemporaryLexiconEntry {
const TemporaryLexiconEntry({
required this.query,
required this.definition,
required this.provider,
required this.model,
required this.createdAt,
});
final String query;
final String definition;
final String provider;
final String model;
final DateTime createdAt;
}
/// Represents an extracted key phrase or vocabulary item within a sentence.
class PhraseBreakdownItem {
const PhraseBreakdownItem({
required this.phrase,
required this.meaning,
this.ipa,
this.usageNote,
});
final String phrase;
final String meaning;
final String? ipa;
final String? usageNote;
Map<String, dynamic> toJson() => {
'phrase': phrase,
'meaning': meaning,
if (ipa != null) 'ipa': ipa,
if (usageNote != null) 'usageNote': usageNote,
};
factory PhraseBreakdownItem.fromJson(Map<String, dynamic> json) =>
PhraseBreakdownItem(
phrase: json['phrase'] as String? ?? '',
meaning: json['meaning'] as String? ?? '',
ipa: json['ipa'] as String?,
usageNote: json['usageNote'] as String?,
);
}
/// Structured multi-dimensional analysis for a sentence or phrase, including
/// translation, sentence pattern, grammar note, pronunciation tips, and extracted phrases.
class SentenceAnalysisResult {
const SentenceAnalysisResult({
required this.originalText,
required this.translation,
this.sentencePattern,
this.grammarNote,
this.pronunciationTips,
this.phrases = const [],
this.provider = 'unknown',
this.model = '',
required this.createdAt,
});
final String originalText;
final String translation;
final String? sentencePattern;
final String? grammarNote;
final String? pronunciationTips;
final List<PhraseBreakdownItem> phrases;
final String provider;
final String model;
final DateTime createdAt;
Map<String, dynamic> toJson() => {
'originalText': originalText,
'translation': translation,
if (sentencePattern != null) 'sentencePattern': sentencePattern,
if (grammarNote != null) 'grammarNote': grammarNote,
if (pronunciationTips != null) 'pronunciationTips': pronunciationTips,
'phrases': phrases.map((p) => p.toJson()).toList(),
'provider': provider,
'model': model,
'createdAt': createdAt.toIso8601String(),
};
factory SentenceAnalysisResult.fromJson(Map<String, dynamic> json) =>
SentenceAnalysisResult(
originalText: json['originalText'] as String? ?? '',
translation: json['translation'] as String? ?? '',
sentencePattern: json['sentencePattern'] as String?,
grammarNote: json['grammarNote'] as String?,
pronunciationTips: json['pronunciationTips'] as String?,
phrases: (json['phrases'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(PhraseBreakdownItem.fromJson)
.toList(),
provider: json['provider'] as String? ?? 'unknown',
model: json['model'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
: DateTime.now(),
);
}
/// What a queued review is for. A `checkpoint` task carries the four spaced
/// checkpoints of learning engine 3.1. A `recap` task is the same-day recall
/// after a segment, or the re-ask of an item missed earlier in the session:
/// it only strengthens encoding, so it never advances a checkpoint and a miss
/// is never counted as a language failure.
enum ReviewKind { checkpoint, recap }
class ReviewItem {
const ReviewItem({
required this.id,
required this.target,
required this.prompt,
required this.hint,
required this.dueAt,
required this.skill,
this.kind = ReviewKind.checkpoint,
this.attempts = 0,
this.successfulReviews = 0,
this.variantIndex = 0,
this.lastProgressedAt,
this.isAiGenerated = false,
});
final String id;
final String target;
final String prompt;
final String hint;
final DateTime dueAt;
final String skill;
final ReviewKind kind;
final int attempts;
final int successfulReviews;
final int variantIndex;
final DateTime? lastProgressedAt;
final bool isAiGenerated;
ReviewItem copyWith({
DateTime? dueAt,
ReviewKind? kind,
int? attempts,
int? successfulReviews,
int? variantIndex,
String? prompt,
String? hint,
String? skill,
DateTime? lastProgressedAt,
bool? isAiGenerated,
}) => ReviewItem(
id: id,
target: target,
prompt: prompt ?? this.prompt,
hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill,
kind: kind ?? this.kind,
attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex,
lastProgressedAt: lastProgressedAt ?? this.lastProgressedAt,
isAiGenerated: isAiGenerated ?? this.isAiGenerated,
);
}
class MasteryItem {
const MasteryItem({
required this.id,
required this.label,
required this.status,
required this.evidence,
this.needsReview = false,
this.checkpoint = 0,
this.firstTaughtAt,
});
final String id;
final String label;
final MasteryStatus status;
final List<EvidenceKind> evidence;
final bool needsReview;
final int checkpoint;
final DateTime? firstTaughtAt;
MasteryItem copyWith({
MasteryStatus? status,
List<EvidenceKind>? evidence,
bool? needsReview,
int? checkpoint,
DateTime? firstTaughtAt,
}) => MasteryItem(
id: id,
label: label,
status: status ?? this.status,
evidence: evidence ?? this.evidence,
needsReview: needsReview ?? this.needsReview,
checkpoint: checkpoint ?? this.checkpoint,
firstTaughtAt: firstTaughtAt ?? this.firstTaughtAt,
);
}
class DialogueTurn {
const DialogueTurn({
required this.text,
required this.isLearner,
this.translation,
});
final String text;
final bool isLearner;
final String? translation;
DialogueTurn copyWith({String? text, bool? isLearner, String? translation}) {
return DialogueTurn(
text: text ?? this.text,
isLearner: isLearner ?? this.isLearner,
translation: translation ?? this.translation,
);
}
}
/// Ephemeral presentation data for a completed controlled dialogue. The
/// related recap review is separately persisted in AppState.
class DialogueSummaryData {
const DialogueSummaryData({
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
this.improvement,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
/// One improvement point collected during the dialogue and shown only at the
/// end, per "对话中不逐句打断" in the conversation spec.
final String? improvement;
}