fix: 修正学习流程中的证据、复习与进度问题

- 独立尝试按课程要求校验内容,不再任意输入即算独立成功
- 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境
- 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助
- 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习
- 复习成功不再降低已有状态
- 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估
- 新增 3 题基础定位,按结果设置起始课程
- 新增按课程开放的对话场景,首页推荐最少练习的场景
- 切换课程时清空上一课的步骤进度
- AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-17 18:52:01 +09:00
co-authored by Claude Opus 5
parent efdff769e4
commit 9ab08c25ef
19 changed files with 1645 additions and 302 deletions
+20 -1
View File
@@ -382,12 +382,19 @@ class AiService {
// with a small token cap, so reasoning only adds cost and can exhaust the
// cap before the JSON is written. Other providers may reject these
// DeepSeek-specific switches, so they keep the original payload.
//
// Every prompt asks for JSON, but without JSON Output DeepSeek copies the
// plain-text assistant turns in a dialogue history and answers in plain
// text about half the time, which the app then treats as a failed call.
if (_isDeepSeek(uri)) {
return isResponses
? {
'model': model,
'input': messages,
'reasoning': {'effort': 'none'},
'text': {
'format': {'type': 'json_object'},
},
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
}
@@ -395,6 +402,7 @@ class AiService {
'model': model,
'messages': messages,
'thinking': {'type': 'disabled'},
'response_format': {'type': 'json_object'},
'temperature': ?temperature,
'max_tokens': ?maxTokens,
};
@@ -787,7 +795,18 @@ class AiService {
endpoint: endpoint,
model: model,
system: system,
messages: history,
// Earlier AI lines are stored as plain English. Sent that way they teach
// the model to answer in plain text (or, in JSON mode, with blanks), so
// they are replayed in the JSON shape the system prompt asks for.
messages: [
for (final message in history)
message['role'] == 'assistant'
? {
'role': 'assistant',
'content': jsonEncode({'reply': message['content']}),
}
: message,
],
temperature: 0.3,
maxTokens: 300,
);
+16
View File
@@ -9,6 +9,7 @@ import 'models.dart';
import 'a0_core.dart';
import 'generated_content.dart';
import 'local_store.dart';
import 'review_feedback.dart';
import 'seed_courses.dart';
import 'ai_config.dart';
import 'ai_service.dart';
@@ -32,6 +33,10 @@ abstract class _AppStateData extends ChangeNotifier {
bool keepRecordings = false;
int completedLessons = 0;
String activeLessonId = 'a0-01';
/// Lesson recommended by the placement check; it and earlier lessons are
/// open without finishing the lesson before them.
String placementStartLessonId = 'a0-01';
final Set<String> completedLessonIds = {};
final Set<String> completedSegmentIds = {};
final Set<String> reportedAiVariantKeys = {};
@@ -212,6 +217,16 @@ class AppState extends _AppStateData
notifyListeners();
}
/// Applies the placement result. Only A0 starting points exist until A1
/// content is frozen, and placement never counts as passing a lesson.
void setPlacementStartLesson(String lessonId) {
if (!a0SeedLessons.any((lesson) => lesson.id == lessonId)) return;
placementStartLessonId = lessonId;
activeLessonId = lessonId;
_resetLessonFlow();
notifyListeners();
}
void setAiProvider(AiProviderType value) {
aiProvider = value;
notifyListeners();
@@ -236,6 +251,7 @@ class AppState extends _AppStateData
void clearProgress() {
completedLessons = 0;
activeLessonId = 'a0-01';
placementStartLessonId = 'a0-01';
completedLessonIds.clear();
completedSegmentIds.clear();
reportedAiVariantKeys.clear();
@@ -23,10 +23,22 @@ mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
);
}
bool get a0Passed =>
coreUsableCount >= 48 &&
coreMasteredCount >= 30 &&
hasTwoValidAssessmentPasses;
/// Enough core items are usable and mastered to sit the stage assessment.
bool get a0AssessmentReady =>
coreUsableCount >= 48 && coreMasteredCount >= 30;
bool get a0Passed => a0AssessmentReady && hasTwoValidAssessmentPasses;
/// The first assessment pack the learner can start and has not passed yet.
String? get nextAssessmentPackId {
for (final packId in const ['A0-E1', 'A0-E2']) {
final passed = assessments.any(
(record) => record.packId == packId && record.passed,
);
if (!passed && canStartAssessmentPack(packId)) return packId;
}
return null;
}
bool canStartAssessmentPack(String packId) {
if (packId != 'A0-E2') return true;
+130 -34
View File
@@ -82,14 +82,29 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
notifyListeners();
}
int _lessonIndex(String id) =>
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
bool isLessonUnlocked(String id) {
final index = a0SeedLessons.indexWhere((lesson) => lesson.id == id);
final index = _lessonIndex(id);
if (index < 0) return false;
// The placement start and everything before it are open, so a learner
// placed further ahead can still go back to easier lessons.
return index == 0 ||
(index > 0 && completedLessonIds.contains(a0SeedLessons[index - 1].id));
index <= _lessonIndex(placementStartLessonId) ||
completedLessonIds.contains(a0SeedLessons[index - 1].id);
}
bool get allLessonsComplete =>
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
void openLesson(String id) {
if (!isLessonUnlocked(id)) return;
if (id != activeLessonId) {
// Step flags belong to the lesson they were earned in; carrying them
// over would let another lesson finish without its own tasks.
_resetLessonFlow();
}
activeLessonId = id;
lessonStep = LessonStep.preview;
previewIndex = 0;
@@ -104,24 +119,34 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
notifyListeners();
}
void completeListening() {
/// [recognized] is true when the learner picked the right meaning on the
/// first try, which is recognition evidence for the segment's targets.
void completeListening({bool recognized = false}) {
_introduceLessonTargets();
if (recognized && !lessonListeningComplete) {
_recordLessonTaskEvidence(
targetIds: _activeTargetItemIds,
taskSuffix: 'listening-check',
skill: '听辨识别',
outcome: EvidenceKind.independentSuccess,
);
}
lessonListeningComplete = true;
lessonStep = LessonStep.speaking;
notifyListeners();
}
void completeSpeaking({bool assisted = false}) {
/// Follow-reading repeats a model sentence, so it is always assisted
/// practice and never independent speaking evidence.
void completeSpeaking() {
lessonSpeakingComplete = true;
lessonStep = LessonStep.reading;
_recordLessonTaskEvidence(
targetIds: [_primaryTargetId],
targetIds: _activeTargetItemIds,
taskSuffix: 'speaking',
skill: '口语表达',
outcome: assisted
? EvidenceKind.assisted
: EvidenceKind.independentSuccess,
assisted: assisted,
outcome: EvidenceKind.assisted,
assisted: true,
);
notifyListeners();
}
@@ -130,7 +155,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
lessonReadingComplete = true;
lessonStep = LessonStep.writing;
_recordLessonTaskEvidence(
targetIds: [_primaryTargetId],
targetIds: _activeTargetItemIds,
taskSuffix: 'reading',
skill: '阅读理解',
outcome: EvidenceKind.exposure,
@@ -143,7 +168,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
lessonWritingDraft = '';
lessonStep = LessonStep.dialogue;
_recordLessonTaskEvidence(
targetIds: [_primaryTargetId],
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
taskSuffix: 'writing',
skill: '写作表达',
outcome: assisted
@@ -159,7 +184,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
lessonDialogueComplete = true;
lessonStep = LessonStep.independent;
_recordLessonTaskEvidence(
targetIds: [_primaryTargetId],
targetIds: _activeTargetItemIds,
taskSuffix: 'dialogue',
skill: '受控对话',
outcome: EvidenceKind.assisted,
@@ -180,7 +205,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
independentAttemptSpoken = spoken && !assisted;
lessonStep = LessonStep.complete;
_recordLessonTaskEvidence(
targetIds: [_primaryTargetId],
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
taskSuffix: 'independent',
skill: spoken && !assisted ? '口语表达' : '写作表达',
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
@@ -198,12 +223,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
if (!lessonCanComplete) return;
completedLessonIds.add(activeLessonId);
completedLessons = completedLessonIds.length;
final currentIndex = a0SeedLessons.indexWhere(
(lesson) => lesson.id == activeLessonId,
);
if (currentIndex >= 0 && currentIndex < a0SeedLessons.length - 1) {
activeLessonId = a0SeedLessons[currentIndex + 1].id;
}
final next = _nextIncompleteLessonId();
if (next != null) activeLessonId = next;
_resetLessonFlow();
notifyListeners();
_syncInBackground();
@@ -276,6 +297,22 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
return changed;
}
/// The first unfinished lesson from the placement start onward, falling back
/// to any earlier unfinished lesson. Null once every lesson is finished.
String? _nextIncompleteLessonId() {
final start = _lessonIndex(
placementStartLessonId,
).clamp(0, a0SeedLessons.length);
final ordered = [
...a0SeedLessons.skip(start),
...a0SeedLessons.take(start),
];
return ordered
.where((lesson) => !completedLessonIds.contains(lesson.id))
.firstOrNull
?.id;
}
void _resetLessonFlow() {
lessonStep = LessonStep.preview;
previewIndex = 0;
@@ -305,31 +342,78 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
_syncInBackground();
}
/// Records one dialogue turn.
///
/// A lesson dialogue follows a model, so its turns stay assisted or pending.
/// A free scene ([sceneId] set) is use outside the lesson: every taught core
/// item the learner actually used counts as context use.
void recordDialogueAttempt({
required String taskId,
required String rawAnswer,
required bool assisted,
bool spoken = false,
String? recordingPath,
String? sceneId,
}) {
final now = DateTime.now();
attemptEvidence.add(
AttemptEvidence(
id: 'dialogue-$taskId-${now.microsecondsSinceEpoch}',
itemId: _primaryTargetId,
taskId: taskId,
skill: '受控对话',
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
outcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
createdAt: now,
rawAnswer: rawAnswer,
recordingPath: recordingPath,
assisted: assisted,
),
);
final inScene = sceneId != null;
final itemIds = inScene
? [
for (final id in a0CoreItems.keys)
if (mastery[id]?.firstTaughtAt != null &&
coreItemUsedIn(id, rawAnswer))
id,
]
: [
for (final id in _activeTargetItemIds)
if (coreItemUsedIn(id, rawAnswer)) id,
];
if (itemIds.isEmpty && !inScene) itemIds.add(_primaryTargetId);
final outcome = assisted
? EvidenceKind.assisted
: (inScene ? EvidenceKind.independentSuccess : EvidenceKind.pending);
for (var index = 0; index < itemIds.length; index++) {
final id = itemIds[index];
attemptEvidence.add(
AttemptEvidence(
id: 'dialogue-$taskId-$id-${now.microsecondsSinceEpoch}-$index',
itemId: id,
taskId: taskId,
skill: inScene ? '情境使用' : '受控对话',
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
outcome: outcome,
createdAt: now,
rawAnswer: rawAnswer,
recordingPath: recordingPath,
assisted: assisted,
),
);
if (outcome != EvidenceKind.pending) _recordEvidence(id, outcome);
}
notifyListeners();
}
/// A scene the learner has the language for.
bool isSceneUnlocked(DialogueScene scene) =>
scene.unlockAfterLessonId == null ||
completedLessonIds.contains(scene.unlockAfterLessonId);
/// The open scene practised least recently, judged by its pending recaps.
DialogueScene get recommendedScene {
final open = a0Scenes.where(isSceneUnlocked).toList();
int recaps(DialogueScene scene) => reviewQueue
.where((item) => item.id.startsWith('dialogue-${scene.id}-'))
.length;
open.sort((left, right) {
final byRecaps = recaps(left).compareTo(recaps(right));
// Prefer the newest scene when both have been practised equally.
return byRecaps != 0
? byRecaps
: a0Scenes.indexOf(right).compareTo(a0Scenes.indexOf(left));
});
return open.first;
}
List<String> get _activeTargetItemIds {
final lesson = lessonById(activeLessonId);
return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds;
@@ -337,6 +421,17 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02';
/// An assisted answer is assisted practice for every target; an unaided one
/// only proves the targets it actually uses.
List<String> _answerTargets({required bool assisted, String? rawAnswer}) {
if (assisted) return _activeTargetItemIds;
final answer = rawAnswer ?? '';
return [
for (final id in _activeTargetItemIds)
if (coreItemUsedIn(id, answer)) id,
];
}
/// Records one evidence row for every target actually attached to a local
/// task. A task can be displayed once but must never silently award its
/// result to unrelated core items.
@@ -353,7 +448,6 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
final now = DateTime.now();
for (var index = 0; index < targetIds.length; index++) {
final id = targetIds[index];
_recordEvidence(id, outcome);
attemptEvidence.add(
AttemptEvidence(
id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index',
@@ -368,6 +462,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
assisted: assisted,
),
);
// The row goes in first: status is derived from the attempt history.
_recordEvidence(id, outcome);
}
}
+116 -39
View File
@@ -102,7 +102,10 @@ mixin _ReviewAndMastery on _AppStateData {
final nextSuccesses = canProgress
? current.successfulReviews + 1
: current.successfulReviews;
const intervals = [1, 2, 4];
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
// review is due one day after teaching, then 2, 4 and 7 days apart, and
// every 30 days once all four checkpoints are passed.
const intervals = [2, 4, 7];
final intervalIndex = (nextSuccesses - 1)
.clamp(0, intervals.length - 1)
.toInt();
@@ -115,17 +118,23 @@ mixin _ReviewAndMastery on _AppStateData {
: nextSuccesses >= 4
? 30
: intervals[intervalIndex];
// The next check of a core item uses a different reviewed situation, so
// later successes also show the item works outside the lesson's context.
// An AI variant keeps its index so it can still be reported afterwards.
final nextTemplate =
a0CoreItems.containsKey(current.id) && !current.isAiGenerated
? coreReviewVariant(current.id, current.variantIndex + 1)
: null;
reviewQueue[index] = current.copyWith(
dueAt: DateTime.now().add(Duration(days: days)),
attempts: current.attempts + 1,
successfulReviews: nextSuccesses,
lastProgressedAt: canProgress ? now : current.lastProgressedAt,
prompt: nextTemplate?.prompt,
hint: nextTemplate?.hint,
skill: nextTemplate?.skill,
variantIndex: nextTemplate == null ? null : current.variantIndex + 1,
);
if (assisted) {
_recordEvidence(current.id, EvidenceKind.assisted);
} else {
_recordReviewSuccess(current.id, nextSuccesses);
}
_addAttemptEvidence(
current,
outcome: assisted
@@ -134,6 +143,11 @@ mixin _ReviewAndMastery on _AppStateData {
assisted: assisted,
rawAnswer: rawAnswer,
);
if (assisted) {
_recordEvidence(current.id, EvidenceKind.assisted);
} else {
_recordReviewSuccess(current.id, nextSuccesses);
}
notifyListeners();
_syncInBackground();
}
@@ -156,8 +170,9 @@ mixin _ReviewAndMastery on _AppStateData {
? (existing.checkpoint - 1).clamp(0, 4).toInt()
: existing.checkpoint;
mastery[item.id] = existing.copyWith(
status: secondFailure
? _statusForCheckpoint(nextCheckpoint)
// Mastery needs all four checkpoints; losing one caps the item at use.
status: secondFailure && nextCheckpoint < 4
? _lowerStatus(existing.status, MasteryStatus.use)
: existing.status,
checkpoint: nextCheckpoint,
needsReview: true,
@@ -227,17 +242,17 @@ mixin _ReviewAndMastery on _AppStateData {
/// Adds one low-priority, non-core recap based on a completed independent
/// dialogue. It is deliberately separate from A0 denominator items.
void addDialogueRecap(String sentence) {
void addDialogueRecap(String sentence, {String sceneId = 'a0-meet'}) {
final now = DateTime.now();
final day =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
final id = 'dialogue-a0-meet-$day';
final id = 'dialogue-$sceneId-$day';
if (reviewQueue.any((item) => item.id == id)) return;
reviewQueue.add(
ReviewItem(
id: id,
target: sentence,
prompt: '再用英语介绍一次自己。',
prompt: sceneById(sceneId).recapPrompt,
hint: '试着不用提示,说出你刚才表达的内容。',
dueAt: now.add(const Duration(days: 1)),
skill: '情境复练',
@@ -374,7 +389,6 @@ mixin _ReviewAndMastery on _AppStateData {
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
var checkpoint = 0;
var needsReview = forceReview;
var nonReviewSuccesses = 0;
DateTime? firstTaughtAt;
final progressedDays = <String>{};
for (final event in events) {
@@ -384,10 +398,6 @@ mixin _ReviewAndMastery on _AppStateData {
(firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) {
firstTaughtAt = event.createdAt;
}
if (!isReview && event.outcome == EvidenceKind.independentSuccess) {
nonReviewSuccesses++;
continue;
}
if (!isReview) continue;
if (event.outcome == EvidenceKind.independentSuccess) {
final day =
@@ -399,12 +409,10 @@ mixin _ReviewAndMastery on _AppStateData {
needsReview = true;
}
}
final independentLevel = nonReviewSuccesses.clamp(0, 3);
final level = checkpoint > independentLevel ? checkpoint : independentLevel;
mastery[id] = MasteryItem(
id: id,
label: existing?.label ?? a0CoreItems[id] ?? id,
status: _statusForCheckpoint(level),
status: _statusFromEvidence(id, checkpoint),
checkpoint: checkpoint,
needsReview: needsReview,
evidence: events.map((event) => event.outcome).toList(),
@@ -417,22 +425,17 @@ mixin _ReviewAndMastery on _AppStateData {
mastery[id] ??
MasteryItem(
id: id,
label: id,
label: a0CoreItems[id] ?? id,
status: MasteryStatus.newItem,
evidence: const [],
);
final allEvidence = [...existing.evidence, evidence];
MasteryStatus next = existing.status;
if (evidence == EvidenceKind.independentSuccess) {
next = switch (existing.status) {
MasteryStatus.newItem => MasteryStatus.recognize,
MasteryStatus.recognize => MasteryStatus.recall,
MasteryStatus.recall => MasteryStatus.use,
MasteryStatus.use || MasteryStatus.master => existing.status,
MasteryStatus.needsReview => MasteryStatus.recall,
};
}
mastery[id] = existing.copyWith(status: next, evidence: allEvidence);
mastery[id] = existing.copyWith(
status: _higherStatus(
existing.status,
_statusFromEvidence(id, existing.checkpoint),
),
evidence: [...existing.evidence, evidence],
);
}
void _recordReviewSuccess(String id, int successes) {
@@ -445,19 +448,93 @@ mixin _ReviewAndMastery on _AppStateData {
evidence: const [],
);
final checkpoint = successes.clamp(0, 4).toInt();
// A success never lowers what earlier evidence already showed.
mastery[id] = existing.copyWith(
status: _statusForCheckpoint(checkpoint),
status: _higherStatus(
existing.status,
_statusFromEvidence(id, checkpoint),
),
checkpoint: checkpoint,
needsReview: false,
evidence: [...existing.evidence, EvidenceKind.independentSuccess],
);
}
MasteryStatus _statusForCheckpoint(int checkpoint) => switch (checkpoint) {
0 => MasteryStatus.newItem,
1 => MasteryStatus.recognize,
2 => MasteryStatus.recall,
3 => MasteryStatus.use,
_ => MasteryStatus.master,
/// Skills whose success only shows the learner recognises the item.
static const _recognitionSkills = {'听辨识别', '阅读识别', '听力理解', '阅读理解'};
/// Controlled lesson steps (follow-reading, scripted dialogue) are practice,
/// not attempts to recall, so they never count as a valid answer.
static bool _isControlledPractice(AttemptEvidence entry) =>
entry.taskId.startsWith('lesson-') &&
(entry.taskId.endsWith('-speaking') ||
entry.taskId.endsWith('-dialogue'));
/// Use outside the lesson it was taught in: a free scene dialogue, an AI
/// adaptive task, or a review answered in a changed situation.
static bool _isContextUse(AttemptEvidence entry) =>
entry.taskId.startsWith('dialogue-scene-') ||
entry.id.startsWith('adaptive-') ||
(entry.taskId == 'review-${entry.itemId}' && entry.variantIndex >= 1);
/// Learning engine 3.1: recognise = a recognition success; recall = an
/// unaided spoken or written success; use = unaided use in a different
/// situation; master = four checkpoints, all three kinds of evidence, and
/// the latest two valid answers both unaided.
MasteryStatus _statusFromEvidence(String id, int checkpoint) {
var recognized = false;
var recalled = false;
var usedInContext = false;
final validAnswers = <EvidenceKind>[];
final events = attemptEvidence.where((entry) => entry.itemId == id).toList()
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
for (final event in events) {
final recognition = _recognitionSkills.contains(event.skill);
if (event.outcome == EvidenceKind.independentSuccess) {
if (recognition) {
recognized = true;
} else {
recalled = true;
if (_isContextUse(event)) usedInContext = true;
}
}
if (!recognition &&
!_isControlledPractice(event) &&
(event.outcome == EvidenceKind.independentSuccess ||
event.outcome == EvidenceKind.assisted ||
event.outcome == EvidenceKind.languageError)) {
validAnswers.add(event.outcome);
}
}
final latestTwoUnaided =
validAnswers.length >= 2 &&
validAnswers
.skip(validAnswers.length - 2)
.every((outcome) => outcome == EvidenceKind.independentSuccess);
if (checkpoint >= 4 &&
recognized &&
recalled &&
usedInContext &&
latestTwoUnaided) {
return MasteryStatus.master;
}
if (recalled && usedInContext) return MasteryStatus.use;
if (recalled) return MasteryStatus.recall;
if (recognized) return MasteryStatus.recognize;
return MasteryStatus.newItem;
}
static int _statusRank(MasteryStatus status) => switch (status) {
MasteryStatus.newItem => 0,
MasteryStatus.recognize => 1,
MasteryStatus.recall || MasteryStatus.needsReview => 2,
MasteryStatus.use => 3,
MasteryStatus.master => 4,
};
static MasteryStatus _higherStatus(MasteryStatus a, MasteryStatus b) =>
_statusRank(b) > _statusRank(a) ? b : a;
static MasteryStatus _lowerStatus(MasteryStatus a, MasteryStatus b) =>
_statusRank(b) < _statusRank(a) ? b : a;
}
@@ -107,6 +107,8 @@ extension _AppStateSnapshot on AppState {
previewIndex = data['previewIndex'] as int? ?? previewIndex;
completedLessons = data['completedLessons'] as int? ?? completedLessons;
activeLessonId = data['activeLessonId'] as String? ?? activeLessonId;
placementStartLessonId =
data['placementStartLessonId'] as String? ?? placementStartLessonId;
completedLessonIds
..clear()
..addAll(
@@ -327,6 +329,7 @@ extension _AppStateSnapshot on AppState {
'previewIndex': previewIndex,
'completedLessons': completedLessons,
'activeLessonId': activeLessonId,
'placementStartLessonId': placementStartLessonId,
'completedLessonIds': completedLessonIds.toList(),
'completedSegmentIds': completedSegmentIds.toList(),
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
+1
View File
@@ -429,6 +429,7 @@ class LocalSnapshotStore {
'previewIndex',
'completedLessons',
'activeLessonId',
'placementStartLessonId',
'completedLessonIds',
'completedSegmentIds',
'activeSegmentIndexes',
+72 -58
View File
@@ -1,3 +1,4 @@
import 'a0_core.dart';
import 'models.dart';
class ReviewCheckResult {
@@ -21,70 +22,20 @@ class ReviewFeedback {
.trim();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = _normalize(input);
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(_normalize(token));
bool phrase(String value) => text.contains(_normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final complete = switch (item.id) {
'A0-P01' => introduction && tokens.length >= 2,
'A0-P02' => (phrase("what's your name") || phrase('what is your name')),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") ||
phrase('what is your phone number'),
'A0-P08' =>
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') =>
tokens.contains(_normalize(item.target)) || phrase(item.target),
_ => tokens.length >= 2,
};
final complete = a0CoreItems.containsKey(item.id)
// A word review checks the word it actually shows.
? coreItemUsedIn(item.id, input, word: item.target)
: _tokens(_normalize(input)).length >= 2;
return ReviewCheckResult(
complete: complete,
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
);
}
static Set<String> _tokens(String text) => RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
static String _hint(String id) => switch (id) {
'A0-P01' => '用 I’m / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:What’s your name?',
@@ -97,3 +48,66 @@ class ReviewFeedback {
_ => '再补充本次目标中的关键英文词或句型。',
};
}
/// Whether [input] actually contains A0 core item [id]. Lessons, reviews and
/// dialogues use the same rule, so evidence only goes to the items a learner
/// really produced, never to every target a task happens to be attached to.
bool coreItemUsedIn(String id, String input, {String? word}) {
final text = ReviewFeedback._normalize(input);
final tokens = ReviewFeedback._tokens(text);
bool has(String token) => tokens.contains(ReviewFeedback._normalize(token));
bool phrase(String value) => text.contains(ReviewFeedback._normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
const weekdays = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
return switch (id) {
// "I'm from …" and "I'm good" are other items, not a name.
'A0-P01' =>
phrase('my name is') ||
RegExp(
r"\bi(?:'m| am) (?!from\b|good\b|okay\b|ok\b|fine\b|great\b|tired\b)[a-z]",
).hasMatch(text),
'A0-P02' => phrase("what's your name") || phrase('what is your name'),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") || phrase('what is your phone number'),
'A0-P08' => numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key', 'phone']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' => itIs && hasAny(weekdays),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when a0CoreItems.containsKey(id) && id.startsWith('A0-W') =>
has(word ?? a0CoreItems[id]!) || phrase(word ?? a0CoreItems[id]!),
_ => false,
};
}
+244 -19
View File
@@ -1116,7 +1116,16 @@ bool matchesSegmentIndependent(String segmentId, String response) {
.expand((segments) => segments)
.where((item) => item.id == segmentId)
.firstOrNull;
if (segment == null) return true;
if (segment == null) {
// Single-segment lessons: every group of the lesson's independent task
// has to be present. Unknown segments never pass.
final lessonId = segmentId.replaceFirst(RegExp(r'-[a-z]$'), '');
final groups = a0IndependentRequiredTerms[lessonId];
if (groups == null) return false;
return groups.every(
(group) => group.any((term) => _containsTerm(text, term)),
);
}
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
final count = segment.independentRequiredTerms
.where((term) => text.contains(_normalizeDialogueInput(term)))
@@ -1129,10 +1138,64 @@ bool matchesSegmentIndependent(String segmentId, String response) {
.length >=
3;
}
return segment.independentRequiredTerms
.any((term) => text.contains(_normalizeDialogueInput(term)));
return segment.independentRequiredTerms.any(
(term) => text.contains(_normalizeDialogueInput(term)),
);
}
const _introductionTerms = [
"i'm + #word",
'i am + #word',
'my name is',
"my name's",
];
/// What the independent attempt of each single-segment lesson has to contain.
/// Each inner list is one part of the task; a part passes when any of its
/// terms appears. Terms follow the same syntax as [LessonDialogue.requiredTerms].
const a0IndependentRequiredTerms = <String, List<List<String>>>{
// 介绍你的名字并回应 Nice to meet you
'a0-01': [
_introductionTerms,
['nice to meet you'],
],
// 介绍名字并拼读它
'a0-02': [
_introductionTerms,
['#spelling'],
],
// 问候并说出你的状态
'a0-03': [
['hello', 'hi', 'hey', 'how are you'],
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
],
// 说出一个身边物品
'a0-05': [
["it's a", 'it is a', "it's an", 'it is an'],
],
// 说来自哪里并反问对方
'a0-06': [
["i'm from", 'i am from'],
['where are you from', '#question'],
],
// 介绍一位家人或朋友
'a0-07': [
['this is my'],
],
// 说一个喜好并反问
'a0-09': [
['i like', 'i love'],
['do you like', '#question'],
],
// 完成姓名、地点、喜好和反问
'a0-10': [
_introductionTerms,
["i'm from", 'i am from', 'from + #word'],
['i like', 'i love'],
['#question'],
],
};
class LessonDialogue {
const LessonDialogue({
required this.goal,
@@ -1172,8 +1235,17 @@ bool _containsTerm(String text, String term) {
}
bool _matchesStructure(String text, String token) => switch (token) {
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
// Letters said one by one ("S-H-E-N", "s h e n", "L-I"), or the spelled
// name typed as one word ("Shen", "aaa"). Case and hyphens are ignored.
'#spelling' =>
RegExp(
r'(?:^|[^a-z])[a-z](?:\s*[-\u2010-\u2015]\s*[a-z]|\s[a-z])+(?![a-z])',
).hasMatch(text) ||
RegExp(r'^[a-z]{2,}$').hasMatch(
text
.replaceAll(RegExp(r'[-\u2010-\u2015]'), '')
.replaceAll(RegExp(r'^[^a-z]+|[^a-z]+$'), ''),
),
// Any spoken or written digit.
'#digit' => RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
@@ -1232,21 +1304,150 @@ const a0MeetDialogue = LessonDialogue(
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
["i'm from", 'i am from', 'from + #word'],
[
'good',
'okay',
'ok',
'fine',
'great',
'tired',
'i like',
'i love',
],
['good', 'okay', 'ok', 'fine', 'great', 'tired', 'i like', 'i love'],
['#question'],
],
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
);
/// A free practice scene. It opens once [unlockAfterLessonId] is complete
/// (always open when null) and only uses language taught up to that lesson.
class DialogueScene {
const DialogueScene({
required this.id,
required this.title,
required this.summary,
required this.script,
required this.recapPrompt,
this.unlockAfterLessonId,
});
final String id;
final String title;
final String summary;
final LessonDialogue script;
/// The follow-up review prompt added after the scene is finished.
final String recapPrompt;
final String? unlockAfterLessonId;
}
const a0Scenes = [
DialogueScene(
id: 'a0-meet',
title: '初次见面',
summary: '介绍姓名、地点、状态或喜好,并反问对方',
script: a0MeetDialogue,
recapPrompt: '再用英语介绍一次自己。',
),
DialogueScene(
id: 'a0-number',
title: '留个电话',
summary: '问候、报出号码、再说一遍,并反问对方',
unlockAfterLessonId: 'a0-04',
recapPrompt: '用英语报一次你的虚拟电话号码。',
script: LessonDialogue(
goal: '问候并交换一个虚拟电话号码',
prompts: [
'Hi! How are you today?',
'Good. What\u2019s your phone number? Use a fake number.',
'Sorry, please say it again.',
'Thanks! Now ask me one question.',
],
hints: [
'I\u2019m good, thanks.',
'My number is one-three-eight.',
'One-three-eight.',
'What\u2019s your phone number?',
],
translations: [
'嗨!你今天好吗?',
'好的。你的电话号码是多少?用一个虚拟号码。',
'抱歉,请再说一遍。',
'谢谢!现在问我一个问题。',
],
requiredTerms: [
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['#digit'],
['#digit'],
['#question'],
],
taskLabels: ['说出你今天的状态', '报出一个号码', '再说一次号码', '反问对方'],
),
),
DialogueScene(
id: 'a0-photo',
title: '看照片聊天',
summary: '介绍照片里的人和东西,说喜好,并反问对方',
unlockAfterLessonId: 'a0-07',
recapPrompt: '用英语介绍一位家人或朋友。',
script: LessonDialogue(
goal: '介绍照片里的人和物品',
prompts: [
'Nice photo! Who is this?',
'And what\u2019s this in the photo?',
'Where are you from?',
'Cool! Now ask me one question.',
],
hints: [
'This is my sister.',
'It\u2019s a bag.',
'I\u2019m from Hong Kong.',
'Who is this? / What\u2019s this?',
],
translations: ['照片真好!这是谁?', '照片里这个是什么?', '你来自哪里?', '真酷!现在问我一个问题。'],
requiredTerms: [
['this is my', 'this is'],
["it's a", 'it is a', "it's an", 'it is an'],
["i'm from", 'i am from', 'from + #word'],
['#question'],
],
taskLabels: ['介绍照片里的人', '说出照片里的物品', '说出你来自哪里', '反问对方'],
),
),
DialogueScene(
id: 'a0-plan',
title: '约个时间',
summary: '说星期和整点,回答喜好,并反问对方',
unlockAfterLessonId: 'a0-09',
recapPrompt: '用英语说今天星期几和现在几点。',
script: LessonDialogue(
goal: '说明星期、时间和喜好',
prompts: [
'Hi! What day is it today?',
'What time is it now?',
'Do you like coffee or tea?',
'Me too! Now ask me one question.',
],
hints: [
'It\u2019s Friday.',
'It\u2019s three o\u2019clock.',
'I like tea.',
'Do you like music?',
],
translations: ['嗨!今天星期几?', '现在几点?', '你喜欢咖啡还是茶?', '我也是!现在问我一个问题。'],
requiredTerms: [
[
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
],
["o'clock", 'oclock', "it's + #digit", 'it is + #digit'],
['i like', 'i love', 'yes', 'no', "i don't"],
['#question'],
],
taskLabels: ['说出今天星期几', '说出现在几点', '说出你的喜好', '反问对方'],
),
),
];
DialogueScene sceneById(String id) =>
a0Scenes.where((scene) => scene.id == id).firstOrNull ?? a0Scenes.first;
const a0Dialogues = <String, LessonDialogue>{
'a0-01': LessonDialogue(
goal: '问候、介绍姓名并回应见面问候',
@@ -1341,7 +1542,12 @@ const a0Dialogues = <String, LessonDialogue>{
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
['yes', 'no', "that's right", 'right', 'correct'],
['#digit'],
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
[
"what's your phone number",
'what is your phone number',
'your phone number',
'your number',
],
],
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
),
@@ -1422,7 +1628,12 @@ const a0Dialogues = <String, LessonDialogue>{
['who is this', "who's this"],
['this is my', 'this is'],
],
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
taskLabels: [
'介绍一位家人或朋友',
'完整说出 This is my … 句型',
'反问 Who is this',
'再介绍一个人',
],
),
'a0-08': LessonDialogue(
goal: '说明星期或整点',
@@ -1440,7 +1651,15 @@ const a0Dialogues = <String, LessonDialogue>{
],
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
requiredTerms: [
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
[
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
],
["o'clock", 'oclock', "#digit + o'clock"],
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
['what day is it', 'what day', '#question'],
@@ -1488,7 +1707,13 @@ const a0Dialogues = <String, LessonDialogue>{
requiredTerms: [
['say that again', 'again', 'pardon'],
['slowly', 'slow'],
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
[
"what's your name",
'what is your name',
'where are you from',
'your name',
'#question',
],
['i like', 'i love'],
],
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
+1 -1
View File
@@ -105,7 +105,7 @@ class WritingFeedback {
'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。',
'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。',
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
'a0-02' => '写出姓名,并把至少三个字母用空格或连字符拼出来。',
'a0-02' => '写出姓名并拼写一遍,例如 My name is Shen. S-H-E-N.',
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',