diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index 1ef23b9..84e2872 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -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, ); diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart index 5fcc328..ee1af97 100644 --- a/kouyu_english/lib/core/app_state.dart +++ b/kouyu_english/lib/core/app_state.dart @@ -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 completedLessonIds = {}; final Set completedSegmentIds = {}; final Set 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(); diff --git a/kouyu_english/lib/core/app_state_assessment.dart b/kouyu_english/lib/core/app_state_assessment.dart index 0c95012..e69e404 100644 --- a/kouyu_english/lib/core/app_state_assessment.dart +++ b/kouyu_english/lib/core/app_state_assessment.dart @@ -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; diff --git a/kouyu_english/lib/core/app_state_lesson.dart b/kouyu_english/lib/core/app_state_lesson.dart index 755dba5..33b1ac4 100644 --- a/kouyu_english/lib/core/app_state_lesson.dart +++ b/kouyu_english/lib/core/app_state_lesson.dart @@ -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 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 _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); } } diff --git a/kouyu_english/lib/core/app_state_review.dart b/kouyu_english/lib/core/app_state_review.dart index 540d3dc..6732150 100644 --- a/kouyu_english/lib/core/app_state_review.dart +++ b/kouyu_english/lib/core/app_state_review.dart @@ -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 = {}; 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 = []; + 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; } diff --git a/kouyu_english/lib/core/app_state_snapshot.dart b/kouyu_english/lib/core/app_state_snapshot.dart index 5395be7..9d4c7ab 100644 --- a/kouyu_english/lib/core/app_state_snapshot.dart +++ b/kouyu_english/lib/core/app_state_snapshot.dart @@ -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(), diff --git a/kouyu_english/lib/core/local_store.dart b/kouyu_english/lib/core/local_store.dart index fbd5368..ff41386 100644 --- a/kouyu_english/lib/core/local_store.dart +++ b/kouyu_english/lib/core/local_store.dart @@ -429,6 +429,7 @@ class LocalSnapshotStore { 'previewIndex', 'completedLessons', 'activeLessonId', + 'placementStartLessonId', 'completedLessonIds', 'completedSegmentIds', 'activeSegmentIndexes', diff --git a/kouyu_english/lib/core/review_feedback.dart b/kouyu_english/lib/core/review_feedback.dart index 2ce932a..21833c5 100644 --- a/kouyu_english/lib/core/review_feedback.dart +++ b/kouyu_english/lib/core/review_feedback.dart @@ -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 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 _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 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, + }; +} diff --git a/kouyu_english/lib/core/seed_courses.dart b/kouyu_english/lib/core/seed_courses.dart index 52e2278..08748a3 100644 --- a/kouyu_english/lib/core/seed_courses.dart +++ b/kouyu_english/lib/core/seed_courses.dart @@ -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 = >>{ + // 介绍你的名字并回应 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'(? + a0Scenes.where((scene) => scene.id == id).firstOrNull ?? a0Scenes.first; + const a0Dialogues = { 'a0-01': LessonDialogue( goal: '问候、介绍姓名并回应见面问候', @@ -1341,7 +1542,12 @@ const a0Dialogues = { ['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 = { ['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 = { ], 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 = { 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: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'], diff --git a/kouyu_english/lib/core/writing_feedback.dart b/kouyu_english/lib/core/writing_feedback.dart index c4c275d..f6906b1 100644 --- a/kouyu_english/lib/core/writing_feedback.dart +++ b/kouyu_english/lib/core/writing_feedback.dart @@ -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。', diff --git a/kouyu_english/lib/features/dialogue/dialogue_flow.dart b/kouyu_english/lib/features/dialogue/dialogue_flow.dart index 2536895..1437318 100644 --- a/kouyu_english/lib/features/dialogue/dialogue_flow.dart +++ b/kouyu_english/lib/features/dialogue/dialogue_flow.dart @@ -11,54 +11,93 @@ import '../../widgets/lexicon_lookup.dart'; import '../../widgets/voice_answer.dart'; class DialogueScenePage extends StatelessWidget { - const DialogueScenePage({super.key, required this.onStart, this.onBack}); - final VoidCallback onStart; + const DialogueScenePage({ + super.key, + required this.state, + required this.onStart, + this.onBack, + }); + final AppState state; + final ValueChanged onStart; final VoidCallback? onBack; @override - Widget build(BuildContext context) => AppPage( - appBar: onBack != null - ? AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: "返回", - onPressed: onBack, - ), - title: const Text("AI 情境对话"), - ) - : null, + Widget build(BuildContext context) { + final recommended = state.recommendedScene; + return AppPage( + appBar: onBack != null + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: "返回", + onPressed: onBack, + ), + title: const Text("AI 情境对话"), + ) + : null, + child: SpacedColumn( + children: [ + const Eyebrow('按当前水平推荐'), + Text( + '选一个场景,开口练习。', + style: Theme.of(context).textTheme.headlineMedium, + ), + Text( + '每轮 4 次回答,完成明确任务后结束。', + style: Theme.of(context).textTheme.bodyMedium, + ), + for (final scene in a0Scenes) + if (state.isSceneUnlocked(scene)) + _OpenScene( + scene: scene, + recommended: scene.id == recommended.id, + onStart: () => onStart(scene.id), + ) + else + _LockedScene( + title: scene.title, + note: + 'A0 · 学完第 ${lessonById(scene.unlockAfterLessonId!).number} 课后开放', + ), + const _LockedScene(title: '咖啡店点单', note: 'A1 · 后续版本开放'), + ], + ), + ); + } +} + +class _OpenScene extends StatelessWidget { + const _OpenScene({ + required this.scene, + required this.recommended, + required this.onStart, + }); + final DialogueScene scene; + final bool recommended; + final VoidCallback onStart; + + @override + Widget build(BuildContext context) => SectionCard( + tint: recommended ? AppColors.softGreen : null, child: SpacedColumn( children: [ - const Eyebrow('按当前水平推荐'), - Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium), - Text( - '每轮 4 次回答,完成明确任务后结束。', - style: Theme.of(context).textTheme.bodyMedium, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + scene.title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + Text( + recommended ? 'A0 · 推荐' : 'A0', + style: const TextStyle(color: AppColors.green), + ), + ], ), - SectionCard( - tint: AppColors.softGreen, - child: SpacedColumn( - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '初次见面', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - Text('A0', style: TextStyle(color: AppColors.green)), - ], - ), - Text( - '介绍姓名、地点、状态或喜好,并反问对方', - style: Theme.of(context).textTheme.bodyMedium, - ), - PrimaryButton(label: '开始对话', onPressed: onStart), - ], - ), - ), - const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'), - const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'), + Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium), + recommended + ? PrimaryButton(label: '开始对话', onPressed: onStart) + : SecondaryButton(label: '开始对话', onPressed: onStart), ], ), ); @@ -94,10 +133,14 @@ class DialoguePage extends StatefulWidget { required this.state, required this.onFinished, this.isLessonDialogue = false, + this.sceneId = 'a0-meet', }); final AppState state; final ValueChanged onFinished; final bool isLessonDialogue; + + /// The free scene to run when this is not a lesson dialogue. + final String sceneId; @override State createState() => _DialoguePageState(); } @@ -124,8 +167,18 @@ class _DialoguePageState extends State /// the dialogue ends: the spec forbids interrupting a beginner turn by turn. String? latestFeedback; - /// The free scene stores its draft under its own id. - static const _sceneDraftId = 'scene-a0-meet'; + DialogueScene get _scene => sceneById(widget.sceneId); + + /// A free scene may use everything the learner has finished so far. + String get _furthestCompletedLessonId => a0SeedLessons + .lastWhere( + (lesson) => widget.state.completedLessonIds.contains(lesson.id), + orElse: () => a0SeedLessons.first, + ) + .id; + + /// Each free scene stores its draft under its own id. + String get _sceneDraftId => 'scene-${_scene.id}'; /// Closing line for the turn after the last scripted prompt. It stays inside /// taught A0 language instead of the old "Wonderful — nice meeting you!". @@ -143,7 +196,7 @@ class _DialoguePageState extends State .id, widget.state.activeLessonId, ) - : a0MeetDialogue; + : _scene.script; /// Only exact sentence matches are trusted. The old positional fallback /// attached `script.translations[stage]` to whatever the AI happened to say, @@ -286,8 +339,10 @@ class _DialoguePageState extends State return; } widget.state.recordDialogueAttempt( - taskId: - 'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage', + taskId: widget.isLessonDialogue + ? 'dialogue-$_lessonSegmentId-$stage' + : 'dialogue-scene-${_scene.id}-$stage', + sceneId: widget.isLessonDialogue ? null : _scene.id, rawAnswer: text, assisted: usedHelp, spoken: usedVoice && !transcriptEdited, @@ -320,7 +375,10 @@ class _DialoguePageState extends State : 'nothing more, the conversation is finished', allowedLanguage: widget.isLessonDialogue ? taughtLanguageUpTo(widget.state.activeLessonId) - : allTaughtLanguage, + : _scene.unlockAfterLessonId == null + // The always-open scene mixes every A0 topic, as its script does. + ? allTaughtLanguage + : taughtLanguageUpTo(_furthestCompletedLessonId), history: turns .map( (turn) => { @@ -409,7 +467,7 @@ class _DialoguePageState extends State final personalSentence = learnerTurns.isEmpty ? 'My name is …' : learnerTurns.first.text; - widget.state.addDialogueRecap(personalSentence); + widget.state.addDialogueRecap(personalSentence, sceneId: _scene.id); widget.onFinished( DialogueSummaryData( // Only the turns the learner actually passed are reported. @@ -584,7 +642,7 @@ class _DialoguePageState extends State onPressed: () => widget.onFinished(null), ), title: Text( - '${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages', + '${widget.isLessonDialogue ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages', ), ), child: SpacedColumn( diff --git a/kouyu_english/lib/features/home/home_page.dart b/kouyu_english/lib/features/home/home_page.dart index 70a59d0..3382657 100644 --- a/kouyu_english/lib/features/home/home_page.dart +++ b/kouyu_english/lib/features/home/home_page.dart @@ -12,12 +12,16 @@ class HomePage extends StatelessWidget { required this.onStartPrimaryTask, required this.onOpenDialogue, required this.onResumeLessonDialogue, + required this.onStartReinforcement, + required this.onOpenAssessment, }); final AppState state; final VoidCallback onStartPrimaryTask; final VoidCallback onOpenDialogue; final VoidCallback onResumeLessonDialogue; + final VoidCallback onStartReinforcement; + final ValueChanged onOpenAssessment; @override Widget build(BuildContext context) { @@ -26,16 +30,21 @@ class HomePage extends StatelessWidget { final activeLesson = lessonById(state.activeLessonId); final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1; final resumeDialogue = state.hasResumableLessonDialogue; + final stageStep = !resumeDialogue && !isReview && state.allLessonsComplete + ? _stageStep() + : null; final primaryTitle = resumeDialogue ? '继续第 ${activeLesson.number} 课的课程对话' : isReview ? '先复习 $count 项' - : '第 ${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length} 段' : ''}'; + : stageStep?.title ?? + '第 ${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length} 段' : ''}'; final primaryNote = resumeDialogue ? '已保留你的对话进度和提示状态。' : isReview ? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。') - : '预热词汇 → 听说读写 → 对话 → 独立尝试'; + : stageStep?.note ?? '预热词汇 → 听说读写 → 对话 → 独立尝试'; + final scene = state.recommendedScene; return AppPage( child: SpacedColumn( @@ -80,7 +89,9 @@ class HomePage extends StatelessWidget { ), ), Text( - isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟', + isReview + ? '${(count * 2).clamp(2, 10)} 分钟' + : stageStep?.minutes ?? '12 分钟', style: const TextStyle(color: AppColors.green), ), ], @@ -94,10 +105,10 @@ class HomePage extends StatelessWidget { ? '继续对话' : isReview ? '开始复习' - : '开始今天的学习', + : stageStep?.action ?? '开始今天的学习', onPressed: resumeDialogue ? onResumeLessonDialogue - : onStartPrimaryTask, + : stageStep?.onPressed ?? onStartPrimaryTask, ), ], ), @@ -110,7 +121,7 @@ class HomePage extends StatelessWidget { style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), Text( - '初次见面 · 4 个任务 · 文字或语音输入', + '${scene.title} · ${scene.script.prompts.length} 个任务 · 文字或语音输入', style: Theme.of(context).textTheme.bodyMedium, ), SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue), @@ -122,6 +133,56 @@ class HomePage extends StatelessWidget { ), ); } + + /// What comes after the last A0 lesson: consolidation until the core items + /// are ready, then the stage assessment, then waiting for A1 content. + _StageStep _stageStep() { + final usable = + '可使用 ${state.coreUsableCount}/48 · 已掌握 ${state.coreMasteredCount}/30'; + if (state.a0Passed) { + return _StageStep( + title: 'A0 已通过', + note: 'A1 课程准备中。先用巩固练习和情境对话保持手感。', + action: '安排一题巩固练习', + minutes: '3 分钟', + onPressed: onStartReinforcement, + ); + } + final packId = state.nextAssessmentPackId; + if (state.a0AssessmentReady && packId != null) { + return _StageStep( + title: 'A0 阶段评估 · $packId', + note: '$usable。可以开始阶段评估,两套评估间隔至少 24 小时。', + action: '开始阶段评估', + minutes: '15 分钟', + onPressed: () => onOpenAssessment(packId), + ); + } + return _StageStep( + title: state.a0AssessmentReady ? '第二套评估 24 小时后开放' : 'A0 课程已学完', + note: state.a0AssessmentReady + ? '第一套评估已通过。等待期间继续巩固。' + : '$usable。继续按时复习、做情境对话,达到要求后开放阶段评估。', + action: '安排一题巩固练习', + minutes: '3 分钟', + onPressed: onStartReinforcement, + ); + } +} + +class _StageStep { + const _StageStep({ + required this.title, + required this.note, + required this.action, + required this.minutes, + required this.onPressed, + }); + final String title; + final String note; + final String action; + final String minutes; + final VoidCallback onPressed; } class _FrameworkNote extends StatelessWidget { diff --git a/kouyu_english/lib/features/lesson/lesson_flow.dart b/kouyu_english/lib/features/lesson/lesson_flow.dart index bbd6860..75181b1 100644 --- a/kouyu_english/lib/features/lesson/lesson_flow.dart +++ b/kouyu_english/lib/features/lesson/lesson_flow.dart @@ -38,6 +38,10 @@ class _LessonFlowState extends State { final writingController = TextEditingController(); final independentController = TextEditingController(); int selectedAnswer = -1; + + /// Set once a wrong meaning is picked, so only a first-try choice counts as + /// recognition evidence. + bool listeningMissed = false; int previewIndex = 0; bool listeningAudioPlayed = false; bool showWritingHelp = true; @@ -108,7 +112,12 @@ class _LessonFlowState extends State { correctAnswer: activity.answers.first, selectedAnswer: selectedAnswer, audioPlayed: listeningAudioPlayed, - onSelected: (value) => setState(() => selectedAnswer = value), + onSelected: (value) => setState(() { + selectedAnswer = value; + if (listeningOptions[value] != activity.answers.first) { + listeningMissed = true; + } + }), onPlayed: () => setState(() => listeningAudioPlayed = true), onLookup: () => showLexiconLookup( context, @@ -118,7 +127,8 @@ class _LessonFlowState extends State { onContinue: selectedAnswer >= 0 && listeningOptions[selectedAnswer] == activity.answers.first - ? widget.state.completeListening + ? () => + widget.state.completeListening(recognized: !listeningMissed) : null, ); case LessonStep.speaking: @@ -126,7 +136,7 @@ class _LessonFlowState extends State { state: widget.state, text: activity.speaking, keepRecording: widget.state.keepRecordings, - onContinue: () => widget.state.completeSpeaking(assisted: true), + onContinue: widget.state.completeSpeaking, ); case LessonStep.reading: content = _ReadingStep( diff --git a/kouyu_english/lib/features/onboarding/onboarding_pages.dart b/kouyu_english/lib/features/onboarding/onboarding_pages.dart index fe13d35..9c26329 100644 --- a/kouyu_english/lib/features/onboarding/onboarding_pages.dart +++ b/kouyu_english/lib/features/onboarding/onboarding_pages.dart @@ -3,7 +3,11 @@ import 'package:flutter/material.dart'; import '../../core/app_state.dart'; import '../../core/app_theme.dart'; import '../../core/models.dart'; +import '../../core/review_feedback.dart'; +import '../../core/seed_courses.dart'; +import '../../core/voice_service.dart'; import '../../widgets/app_widgets.dart'; +import '../../widgets/voice_answer.dart'; class WelcomePage extends StatefulWidget { const WelcomePage({super.key, required this.state, required this.onContinue}); @@ -16,13 +20,11 @@ class WelcomePage extends StatefulWidget { } class _WelcomePageState extends State { - late LearningGoal selectedGoal; late int selectedMinutes; @override void initState() { super.initState(); - selectedGoal = widget.state.goal; selectedMinutes = widget.state.dailyMinutes; } @@ -72,16 +74,6 @@ class _WelcomePageState extends State { style: Theme.of(context).textTheme.headlineMedium, ), const Text('从真实生活场景开始,先做到听得懂、说得清。'), - _ChoiceGroup( - title: '你的主要目标', - value: selectedGoal, - options: const { - LearningGoal.dailyLife: '日常生活', - LearningGoal.travel: '旅行', - LearningGoal.workStarter: '工作起步', - }, - onChanged: (value) => setState(() => selectedGoal = value), - ), _ChoiceGroup( title: '每天学习多久?', value: selectedMinutes, @@ -91,9 +83,7 @@ class _WelcomePageState extends State { PrimaryButton( label: '继续', onPressed: () { - widget.state - ..setGoal(selectedGoal) - ..setDailyMinutes(selectedMinutes); + widget.state.setDailyMinutes(selectedMinutes); widget.onContinue(); }, ), @@ -119,8 +109,25 @@ class PlacementPage extends StatefulWidget { State createState() => _PlacementPageState(); } -class _PlacementPageState extends State { +enum _PlacementPhase { selfReport, listen, readAloud, answer, result } + +class _PlacementPageState extends State + with VoiceAnswerMixin { + static const _listeningText = 'Hello. My name is Mia. Nice to meet you.'; + static const _listeningOptions = [ + '你好,我叫 Mia,很高兴认识你。', + '你好,我来自 Mia,今天很开心。', + '再见,Mia,明天见。', + ]; + static const _readAloudText = 'Hello. My name is ...'; + late PlacementLevel selected; + var phase = _PlacementPhase.selfReport; + int listeningChoice = -1; + final answerController = TextEditingController(); + + @override + AppState get voiceState => widget.state; @override void initState() { @@ -128,52 +135,287 @@ class _PlacementPageState extends State { selected = widget.state.placement; } + @override + void dispose() { + VoiceService.instance.stopSpeaking(); + disposeVoiceAnswer(keepRecording: false); + answerController.dispose(); + super.dispose(); + } + + bool get _understoodGreeting => listeningChoice == 0; + + bool get _answeredName => + coreItemUsedIn('A0-P01', answerController.text.trim()); + + /// Placement only picks an A0 starting point until A1 content is frozen, and + /// it never marks a lesson as passed. + String get _suggestedLessonId => + _understoodGreeting && _answeredName ? 'a0-02' : 'a0-01'; + + String get _reason => _understoodGreeting && _answeredName + ? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。' + : _understoodGreeting + ? '你能听懂问候,先把自我介绍说完整会更稳。' + : '先从问候和介绍名字开始,打好第一句的基础。'; + + void _back() { + VoiceService.instance.stopSpeaking(); + switch (phase) { + case _PlacementPhase.selfReport: + widget.onBack?.call(); + case _PlacementPhase.listen: + setState(() => phase = _PlacementPhase.selfReport); + case _PlacementPhase.readAloud: + setState(() => phase = _PlacementPhase.listen); + case _PlacementPhase.answer: + setState(() => phase = _PlacementPhase.readAloud); + case _PlacementPhase.result: + setState(() => phase = _PlacementPhase.answer); + } + } + + void _startFrom(String lessonId) { + widget.state.setPlacementStartLesson(lessonId); + widget.onStart(); + } + + Future _toggleVoiceAnswer() async { + if (aiVoiceRecording) { + await finishVoiceInput( + keepAudio: false, + onTranscript: (text) => answerController.text = text, + ); + return; + } + await startVoiceInput(); + } + @override Widget build(BuildContext context) { + final showBack = + widget.onBack != null || phase != _PlacementPhase.selfReport; + return AppPage( + appBar: showBack + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: "返回", + onPressed: _back, + ), + title: const Text("基础定位"), + ) + : null, + child: switch (phase) { + _PlacementPhase.selfReport => _selfReport(context), + _PlacementPhase.listen => _listen(context), + _PlacementPhase.readAloud => _readAloud(context), + _PlacementPhase.answer => _answer(context), + _PlacementPhase.result => _result(context), + }, + ); + } + + Widget _selfReport(BuildContext context) { const labels = { PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'), PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'), PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'), }; - return AppPage( - appBar: widget.onBack != null - ? AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: "返回", - onPressed: widget.onBack, - ), - title: const Text("基础定位"), - ) - : null, - child: SpacedColumn( - spacing: 14, - children: [ - const Eyebrow('第一步 · 约 3 分钟'), - Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium), - const Text('选择最接近的状态,之后随时能调整。'), - for (final option in PlacementLevel.values) - _PlacementChoice( - option: option, - labels: labels[option]!, - isSelected: selected == option, - onTap: () => setState(() => selected = option), - ), - PrimaryButton( - label: '开始 3 分钟定位', - onPressed: () { - widget.state.setPlacement(selected); - widget.onStart(); - }, + return SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('第一步 · 约 3 分钟'), + Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium), + const Text('选择最接近的状态,之后随时能调整。'), + for (final option in PlacementLevel.values) + _PlacementChoice( + option: option, + labels: labels[option]!, + isSelected: selected == option, + onTap: () => setState(() => selected = option), ), + PrimaryButton( + label: '开始 3 分钟定位', + onPressed: () { + widget.state.setPlacement(selected); + setState(() => phase = _PlacementPhase.listen); + }, + ), + Center( + child: TextButton( + onPressed: () => _startFrom('a0-01'), + child: const Text('直接从第一课开始'), + ), + ), + ], + ); + } + + Widget _listen(BuildContext context) => SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('定位 · 1 / 3'), + Text('听一听,选出意思。', style: Theme.of(context).textTheme.headlineMedium), + SecondaryButton( + label: '播放句子', + onPressed: () => VoiceService.instance.speak(_listeningText), + ), + for (var index = 0; index < _listeningOptions.length; index++) + SectionCard( + tint: listeningChoice == index ? AppColors.softGreen : null, + onTap: () => setState(() => listeningChoice = index), + child: Row( + children: [ + Icon( + listeningChoice == index + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: listeningChoice == index + ? AppColors.green + : AppColors.muted, + ), + const SizedBox(width: 10), + Expanded(child: Text(_listeningOptions[index])), + ], + ), + ), + PrimaryButton( + label: '下一题', + onPressed: listeningChoice < 0 + ? null + : () => setState(() => phase = _PlacementPhase.readAloud), + ), + Center( + child: TextButton( + onPressed: () => setState(() { + listeningChoice = -1; + phase = _PlacementPhase.readAloud; + }), + child: const Text('听不懂,下一题'), + ), + ), + ], + ); + + Widget _readAloud(BuildContext context) => SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('定位 · 2 / 3'), + Text('跟着读一句。', style: Theme.of(context).textTheme.headlineMedium), + const Text( + _readAloudText, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600), + ), + SecondaryButton( + label: '播放示范音', + onPressed: () => VoiceService.instance.speak(_readAloudText), + ), + const SectionCard( + tint: AppColors.surfaceMuted, + child: Text('这一题只是开口热身,不录音、不打分,可以跳过。'), + ), + PrimaryButton( + label: '我读完了', + onPressed: () => setState(() => phase = _PlacementPhase.answer), + ), + Center( + child: TextButton( + onPressed: () => setState(() => phase = _PlacementPhase.answer), + child: const Text('现在不方便开口,跳过'), + ), + ), + ], + ); + + Widget _answer(BuildContext context) => SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('定位 · 3 / 3'), + Text('回答这个问题。', style: Theme.of(context).textTheme.headlineMedium), + SectionCard( + tint: AppColors.surfaceMuted, + child: Row( + children: [ + const Expanded( + child: Text( + 'What is your name?', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + ), + IconButton( + tooltip: '播放问题', + icon: const Icon(Icons.volume_up_outlined), + onPressed: () => + VoiceService.instance.speak('What is your name?'), + ), + ], + ), + ), + TextField( + controller: answerController, + decoration: const InputDecoration(hintText: '用英语输入你的回答'), + onChanged: (_) => setState(() {}), + ), + SecondaryButton( + label: transcribing ? '正在识别…' : (aiVoiceRecording ? '说完了,识别' : '用语音回答'), + onPressed: transcribing ? null : _toggleVoiceAnswer, + ), + PrimaryButton( + label: '查看建议', + onPressed: aiVoiceRecording || transcribing + ? null + : () => setState(() => phase = _PlacementPhase.result), + ), + Center( + child: TextButton( + onPressed: () => setState(() { + answerController.clear(); + phase = _PlacementPhase.result; + }), + child: const Text('还不会说,查看建议'), + ), + ), + ], + ); + + Widget _result(BuildContext context) { + final lesson = lessonById(_suggestedLessonId); + return SpacedColumn( + spacing: 14, + children: [ + const Eyebrow('定位结果'), + Text( + '建议从 A0 第 ${lesson.number} 课开始', + style: Theme.of(context).textTheme.headlineMedium, + ), + SectionCard( + tint: AppColors.softGreen, + child: SpacedColumn( + spacing: 6, + children: [ + Text( + '第 ${lesson.number} 课 · ${lesson.title}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + Text(_reason), + ], + ), + ), + const Text('前面的课程随时可以回去学,定位不会跳过任何复习。'), + PrimaryButton( + label: '开始今天学习', + onPressed: () => _startFrom(_suggestedLessonId), + ), + if (_suggestedLessonId != 'a0-01') Center( child: TextButton( - onPressed: widget.onStart, - child: const Text('直接从第一课开始'), + onPressed: () => _startFrom('a0-01'), + child: const Text('从更简单内容开始'), ), ), - ], - ), + ], ); } } diff --git a/kouyu_english/lib/features/shell/learning_shell.dart b/kouyu_english/lib/features/shell/learning_shell.dart index 4e1610b..e71462f 100644 --- a/kouyu_english/lib/features/shell/learning_shell.dart +++ b/kouyu_english/lib/features/shell/learning_shell.dart @@ -26,6 +26,7 @@ class _LearningShellState extends State { var route = _ShellRoute.tab; _ShellRoute? previousRoute; bool dialogueInLesson = false; + String sceneId = 'a0-meet'; AssessmentPack? assessmentPack; DialogueSummaryData? dialogueSummary; @@ -45,9 +46,10 @@ class _LearningShellState extends State { route = _ShellRoute.scene; }); - void showDialogue({bool inLesson = false}) => setState(() { + void showDialogue({bool inLesson = false, String? scene}) => setState(() { previousRoute = route; dialogueInLesson = inLesson; + if (scene != null) sceneId = scene; route = _ShellRoute.dialogue; }); @@ -125,13 +127,16 @@ class _LearningShellState extends State { ); case _ShellRoute.scene: body = DialogueScenePage( - onStart: showDialogue, + state: widget.state, + onStart: (id) => showDialogue(scene: id), onBack: () => showTab(tab), ); case _ShellRoute.dialogue: body = DialoguePage( + key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'), state: widget.state, isLessonDialogue: dialogueInLesson, + sceneId: sceneId, onFinished: (summary) { if (dialogueInLesson) { showLesson(); @@ -147,7 +152,7 @@ class _LearningShellState extends State { summary: dialogueSummary!, onHome: () => showTab(AppTab.home), onLesson: showLesson, - onRetry: showDialogue, + onRetry: () => showDialogue(scene: sceneId), onBack: () => showTab(tab), ); case _ShellRoute.settings: @@ -194,7 +199,8 @@ class _LearningShellState extends State { selectedIndex: tab.index, height: 70, indicatorColor: AppColors.softGreen, - onDestinationSelected: (index) => showTab(AppTab.values[index]), + onDestinationSelected: (index) => + showTab(AppTab.values[index]), destinations: const [ NavigationDestination( icon: Icon(Icons.home_outlined), @@ -243,6 +249,16 @@ class _LearningShellState extends State { }, onOpenDialogue: showDialogueScene, onResumeLessonDialogue: () => showDialogue(inLesson: true), + onStartReinforcement: () { + widget.state.scheduleA0Reinforcement(); + showTab(AppTab.review); + }, + onOpenAssessment: (packId) { + final pack = a0AssessmentPacks + .where((item) => item.id == packId) + .firstOrNull; + if (pack != null) showAssessment(pack); + }, ); case AppTab.learn: return _LearningMap( @@ -258,7 +274,10 @@ class _LearningShellState extends State { state: widget.state, ); case AppTab.dialogue: - return DialogueScenePage(onStart: showDialogue); + return DialogueScenePage( + state: widget.state, + onStart: (id) => showDialogue(scene: id), + ); case AppTab.review: return ReviewPage( state: widget.state, diff --git a/kouyu_english/test/app_state_test.dart b/kouyu_english/test/app_state_test.dart index 57b0947..aff0f1d 100644 --- a/kouyu_english/test/app_state_test.dart +++ b/kouyu_english/test/app_state_test.dart @@ -123,6 +123,17 @@ void main() { final state = AppState(); state.reviewQueue.add(dueItem()); final item = state.reviewQueue.first; + state.attemptEvidence.add( + AttemptEvidence( + id: 'recognized', + itemId: item.id, + taskId: 'lesson-a0-06-a-listening-check', + skill: '听辨识别', + inputMode: 'choice', + outcome: EvidenceKind.independentSuccess, + createdAt: DateTime.now().subtract(const Duration(days: 20)), + ), + ); for (var index = 0; index < 4; index++) { final current = state.reviewQueue.firstWhere( @@ -141,6 +152,13 @@ void main() { expect(state.mastery[item.id]!.checkpoint, 4); expect(state.mastery[item.id]!.status, MasteryStatus.master); + // Later checks rotate to other reviewed situations. + expect( + state.reviewQueue + .firstWhere((candidate) => candidate.id == item.id) + .variantIndex, + 4, + ); expect( state.reviewQueue .firstWhere((candidate) => candidate.id == item.id) @@ -153,6 +171,17 @@ void main() { test('mastery can be rebuilt from spaced review evidence', () { final state = AppState(); final base = DateTime.utc(2026, 9, 1); + state.attemptEvidence.add( + AttemptEvidence( + id: 'recognized', + itemId: 'A0-P12', + taskId: 'lesson-a0-06-a-listening-check', + skill: '听辨识别', + inputMode: 'choice', + outcome: EvidenceKind.independentSuccess, + createdAt: base, + ), + ); for (var index = 0; index < 4; index++) { state.attemptEvidence.add( AttemptEvidence( @@ -163,14 +192,75 @@ void main() { inputMode: 'text', outcome: EvidenceKind.independentSuccess, createdAt: base.add(Duration(days: index + 1)), + variantIndex: index, + ), + ); + } + + state.rebuildMasteryFromEvidence(); + + expect(state.mastery['A0-P12']!.checkpoint, 4); + expect(state.mastery['A0-P12']!.status, MasteryStatus.master); + }); + + test('four checkpoints without every kind of evidence are not mastery', () { + final state = AppState(); + final base = DateTime.utc(2026, 9, 1); + for (var index = 0; index < 4; index++) { + state.attemptEvidence.add( + AttemptEvidence( + id: 'same-context-$index', + itemId: 'A0-P12', + taskId: 'review-A0-P12', + skill: '口语表达', + inputMode: 'text', + outcome: EvidenceKind.independentSuccess, + createdAt: base.add(Duration(days: index + 1)), ), ); } state.rebuildMasteryFromEvidence(); + // No recognition success and never used outside the original situation. expect(state.mastery['A0-P12']!.checkpoint, 4); - expect(state.mastery['A0-P12']!.status, MasteryStatus.master); + expect(state.mastery['A0-P12']!.status, MasteryStatus.recall); + }); + + test('a review success never lowers an existing status', () { + final state = AppState(); + state.mastery['A0-P12'] = const MasteryItem( + id: 'A0-P12', + label: "I'm from [place].", + status: MasteryStatus.use, + evidence: [], + checkpoint: 1, + ); + state.reviewQueue.add(dueItem()); + + state.completeReview(state.reviewQueue.single, assisted: false); + + expect(state.mastery['A0-P12']!.status, MasteryStatus.use); + }); + + test('review checkpoints follow days 1, 3, 7 and 14', () { + final state = AppState(); + state.reviewQueue.add(dueItem()); + final gaps = []; + for (var index = 0; index < 3; index++) { + final current = state.reviewQueue.single; + state.reviewQueue[0] = current.copyWith( + dueAt: DateTime.now(), + lastProgressedAt: DateTime.now().subtract(const Duration(days: 1)), + ); + state.completeReview(state.reviewQueue.single, assisted: false); + gaps.add( + state.reviewQueue.single.dueAt.difference(DateTime.now()).inHours ~/ + 24 + + 1, + ); + } + expect(gaps, [2, 4, 7]); }); test( @@ -265,7 +355,7 @@ void main() { final state = AppState(); state.completePreview(); state.completeListening(); - state.completeSpeaking(assisted: true); + state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); @@ -553,7 +643,9 @@ void main() { correct: true, ); expect(state.attemptEvidence, hasLength(1)); - expect(state.mastery['A0-P12']!.status, MasteryStatus.recognize); + // An unaided adaptive answer is recall in a situation other than the + // lesson, which is what usable means. + expect(state.mastery['A0-P12']!.status, MasteryStatus.use); }); test( @@ -640,9 +732,51 @@ void main() { final speaking = state.attemptEvidence.where( (entry) => entry.taskId == 'lesson-$segmentId-speaking', ); - expect(speaking, hasLength(1)); - expect(speaking.single.itemId, targets.last); - expect(speaking.single.outcome, EvidenceKind.independentSuccess); + // Follow-reading is assisted practice for every target, never + // independent evidence for one of them. + expect(speaking.map((entry) => entry.itemId), targets); + expect( + speaking.every((entry) => entry.outcome == EvidenceKind.assisted), + isTrue, + ); + + state.completeReading(); + state.completeWriting(rawAnswer: 'Hello. My name is Mia.'); + final writing = state.attemptEvidence.where( + (entry) => entry.taskId == 'lesson-$segmentId-writing', + ); + expect(writing.map((entry) => entry.itemId).toSet(), {'A0-W36', 'A0-P01'}); + }); + + test('a first-try listening choice is recognition evidence', () { + final state = AppState(); + state.completePreview(); + state.completeListening(recognized: true); + + for (final id in lessonById('a0-01').targetItemIds) { + expect(state.mastery[id]!.status, MasteryStatus.recognize); + } + }); + + test('scene dialogue credits taught items the learner actually used', () { + final state = AppState(); + state.completePreview(); + state.completeListening(); + + state.recordDialogueAttempt( + taskId: 'dialogue-scene-a0-meet-0', + rawAnswer: 'Hi! My name is Mia.', + assisted: false, + sceneId: 'a0-meet', + ); + + final used = state.attemptEvidence + .where((entry) => entry.taskId == 'dialogue-scene-a0-meet-0') + .map((entry) => entry.itemId) + .toSet(); + expect(used, {'A0-W37', 'A0-P01'}); + expect(state.mastery['A0-P01']!.status, MasteryStatus.use); + expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem); }); test( @@ -659,13 +793,17 @@ void main() { assisted: false, rawAnswer: 'Hello. I am Mia.', ); - final primary = lessonById('a0-01').targetItemIds.last; + const introduced = 'A0-P01'; state.mastery.clear(); state.rebuildMasteryFromEvidence(); - expect(state.mastery[primary]!.firstTaughtAt, isNotNull); - expect(state.mastery[primary]!.status, MasteryStatus.use); + expect(state.mastery[introduced]!.firstTaughtAt, isNotNull); + // Recalled inside the lesson, not yet used in another situation. + expect(state.mastery[introduced]!.status, MasteryStatus.recall); + // The answer never said "Nice to meet you", so that target gets no + // production evidence from it. + expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem); expect( state.mastery[lessonById('a0-01').targetItemIds.first]!.firstTaughtAt, isNotNull, @@ -770,6 +908,79 @@ void main() { expect(matchesSegmentIndependent('a0-08-b', 'It is Monday.'), isFalse); }); + test('single-segment independent attempts reject off-task answers', () { + const offTask = 'abc'; + for (final lessonId in [ + 'a0-01', + 'a0-02', + 'a0-03', + 'a0-05', + 'a0-06', + 'a0-07', + 'a0-09', + 'a0-10', + ]) { + expect( + matchesSegmentIndependent('$lessonId-a', offTask), + isFalse, + reason: lessonId, + ); + } + expect( + matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia. Nice to meet you."), + isTrue, + ); + expect(matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia."), isFalse); + expect(matchesSegmentIndependent('a0-05-a', "It's a pen."), isTrue); + expect( + matchesSegmentIndependent( + 'a0-06-a', + "I'm from Guangzhou. Where are you from?", + ), + isTrue, + ); + expect( + matchesSegmentIndependent('a0-06-a', "I'm from Guangzhou."), + isFalse, + ); + expect( + matchesSegmentIndependent( + 'a0-10-a', + "I'm Mia. I'm from Guangzhou. I like tea. Do you like tea?", + ), + isTrue, + ); + }); + + test('each free scene keeps its own daily recap', () { + final state = AppState(); + + state.addDialogueRecap('My name is Alex.'); + state.addDialogueRecap('It is Monday.', sceneId: 'a0-plan'); + + expect( + state.reviewQueue.where( + (item) => item.id.startsWith('dialogue-a0-meet-'), + ), + hasLength(1), + ); + final plan = state.reviewQueue.singleWhere( + (item) => item.id.startsWith('dialogue-a0-plan-'), + ); + expect(plan.prompt, sceneById('a0-plan').recapPrompt); + }); + + test('scenes open with their lessons and rotate the recommendation', () { + final state = AppState(); + expect(a0Scenes.where(state.isSceneUnlocked).map((scene) => scene.id), [ + 'a0-meet', + ]); + state.completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']); + expect(state.recommendedScene.id, 'a0-number'); + state.addDialogueRecap('My number is one two three.', sceneId: 'a0-number'); + expect(state.recommendedScene.id, 'a0-meet'); + }); + test('completed independent dialogue creates one daily non-core recap', () { final state = AppState(); @@ -797,7 +1008,7 @@ void main() { for (var segment = 0; segment < lesson.segments.length; segment++) { state.completePreview(); state.completeListening(); - state.completeSpeaking(assisted: true); + state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); state.completeLessonDialogue(); @@ -893,10 +1104,7 @@ void main() { checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'), isTrue, ); - expect( - checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), - isTrue, - ); + expect(checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), isTrue); }); test('finishLesson advances activeLessonId to next available lesson', () { @@ -904,7 +1112,7 @@ void main() { expect(state.activeLessonId, 'a0-01'); state.completePreview(); state.completeListening(); - state.completeSpeaking(assisted: false); + state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: false); state.completeLessonDialogue(); @@ -916,6 +1124,68 @@ void main() { expect(state.activeLessonId, 'a0-02'); }); + void finishActiveLesson(AppState state) { + state.completePreview(); + state.completeListening(); + state.completeSpeaking(); + state.completeReading(); + state.completeWriting(assisted: true); + state.completeLessonDialogue(); + state.completeIndependentAttempt(assisted: true); + state.finishLesson(); + } + + test('switching lessons clears step flags from the previous lesson', () { + final state = AppState(); + state.completePreview(); + state.completeListening(); + state.completeSpeaking(); + state.completeReading(); + state.completeWriting(assisted: true); + state.completeLessonDialogue(); + state.completeIndependentAttempt(assisted: true); + state.completedLessonIds.add('a0-01'); + + state.openLesson('a0-02'); + + expect(state.lessonCanComplete, isFalse); + expect(state.lessonListeningComplete, isFalse); + }); + + test('placement opens its start lesson without passing earlier ones', () { + final state = AppState(); + state.setPlacementStartLesson('a0-02'); + + expect(state.activeLessonId, 'a0-02'); + expect(state.isLessonUnlocked('a0-01'), isTrue); + expect(state.isLessonUnlocked('a0-02'), isTrue); + expect(state.isLessonUnlocked('a0-03'), isFalse); + expect(state.completedLessonIds, isEmpty); + + finishActiveLesson(state); + expect(state.activeLessonId, 'a0-03'); + }); + + test('after the last lesson the learner is sent to unfinished lessons', () { + final state = AppState(); + state.setPlacementStartLesson('a0-02'); + for (var index = 1; index < a0SeedLessons.length; index++) { + final lesson = a0SeedLessons[index]; + state.openLesson(lesson.id); + for (var segment = 0; segment < lesson.segments.length; segment++) { + state.completeSegment(lesson.id, segment); + } + finishActiveLesson(state); + } + expect(state.activeLessonId, 'a0-01'); + expect(state.allLessonsComplete, isFalse); + + finishActiveLesson(state); + expect(state.allLessonsComplete, isTrue); + expect(state.a0AssessmentReady, isFalse); + expect(state.nextAssessmentPackId, 'A0-E1'); + }); + test( 'a passed assessment skill remains valid during the seven-day retest window', () { diff --git a/kouyu_english/test/deepseek_thinking_test.dart b/kouyu_english/test/deepseek_thinking_test.dart index d1e9dcc..0845520 100644 --- a/kouyu_english/test/deepseek_thinking_test.dart +++ b/kouyu_english/test/deepseek_thinking_test.dart @@ -43,6 +43,7 @@ void main() { final body = await captureBody('https://api.deepseek.com'); expect(body['thinking'], {'type': 'disabled'}); + expect(body['response_format'], {'type': 'json_object'}); expect(body.containsKey('reasoning_effort'), isFalse); expect(body['max_tokens'], 200); }); @@ -51,6 +52,9 @@ void main() { final body = await captureBody('https://api.deepseek.com/v1/responses'); expect(body['reasoning'], {'effort': 'none'}); + expect(body['text'], { + 'format': {'type': 'json_object'}, + }); expect(body.containsKey('reasoning_effort'), isFalse); expect(body.containsKey('thinking'), isFalse); }); @@ -59,6 +63,48 @@ void main() { final body = await captureBody('https://example.test/v1'); expect(body.containsKey('thinking'), isFalse); + expect(body.containsKey('response_format'), isFalse); expect(body['reasoning_effort'], 'low'); }); + + test('dialogue history replays earlier AI lines as JSON', () async { + AiService.instance.setFallbackApiKey('test-key'); + List? messages; + final reply = await http.runWithClient( + () => AiService.instance.dialogueReply( + provider: AiProviderType.compatible, + endpoint: 'https://api.deepseek.com', + model: 'deepseek-flash', + aiGoal: 'Ask where the learner is from.', + learnerTask: 'say where they are from', + history: const [ + {'role': 'assistant', 'content': "Hi! What's your name?"}, + {'role': 'user', 'content': 'My name is Alex.'}, + ], + ), + () => MockClient((request) async { + final body = jsonDecode(request.body) as Map; + messages = body['messages'] as List; + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'content': '{"reply":"Where are you from?"}'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + + expect(reply?.reply, 'Where are you from?'); + expect(messages, hasLength(3)); + expect(messages![0]['role'], 'system'); + expect(jsonDecode(messages![1]['content'] as String), { + 'reply': "Hi! What's your name?", + }); + expect(messages![2], {'role': 'user', 'content': 'My name is Alex.'}); + }); } diff --git a/kouyu_english/test/dialogue_task_test.dart b/kouyu_english/test/dialogue_task_test.dart index 0b2bf4f..d04510f 100644 --- a/kouyu_english/test/dialogue_task_test.dart +++ b/kouyu_english/test/dialogue_task_test.dart @@ -28,7 +28,10 @@ void main() { ); } expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length); - expect(a0MeetDialogue.requiredTerms.length, a0MeetDialogue.prompts.length); + expect( + a0MeetDialogue.requiredTerms.length, + a0MeetDialogue.prompts.length, + ); }); test('示范答案能通过对应回合的校验', () { @@ -46,37 +49,101 @@ void main() { test('跑题回答不再因为关键词沾边而通过', () { // 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。 - expect(matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), isFalse); - expect(matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), isFalse); - expect(matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), isFalse); + expect( + matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), + isFalse, + ); + expect( + matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), + isFalse, + ); + expect( + matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), + isFalse, + ); expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse); expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse); }); test('自由场景接受同样正确的其他说法', () { // 旧正则只认 good/okay/tired,也不认 my name's。 - expect(matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), isTrue); + expect( + matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), + isTrue, + ); expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue); - expect(matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), isTrue); + expect( + matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), + isTrue, + ); expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue); - expect(matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), isTrue); - expect(matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), isTrue); + expect( + matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), + isTrue, + ); + expect( + matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), + isTrue, + ); }); test('只写半句不算完成任务', () { expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse); - expect(matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), isFalse); expect( - matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is one-three-eight.'), + matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), + isFalse, + ); + expect( + matchesDialogueStage( + a0Dialogues['a0-04']!, + 0, + 'My number is one-three-eight.', + ), isTrue, ); }); test('拼读和数字这类结构化要求可以识别', () { expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue); - expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'Shen'), isFalse); - expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), isTrue); - expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), isFalse); + }); + + test('拼读忽略大小写和连字符', () { + final spelling = a0Dialogues['a0-02']!; + for (final answer in [ + 'A-A-A', + 'aaa', + 'AAA', + 'a-a-a', + 'A - A - A', + 'a a a', + 'Shen', + 'S-H-E-N.', + 'She-n', + 'L-I', + 'Li', + 'My name is Alex. A-L-E-X.', + ]) { + expect( + matchesDialogueStage(spelling, 1, answer), + isTrue, + reason: answer, + ); + } + for (final answer in ['a', 'My name is Alex.', '123', '---']) { + expect( + matchesDialogueStage(spelling, 1, answer), + isFalse, + reason: answer, + ); + } + expect( + matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), + isTrue, + ); + expect( + matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), + isFalse, + ); }); test('任务标签可用于提示和总结', () { @@ -86,7 +153,11 @@ void main() { test('输入校验忽略多余空格与大小写', () { expect( - matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "), + matchesDialogueStage( + a0MeetDialogue, + 0, + " MY NAME IS SHEN ", + ), isTrue, ); expect( @@ -94,11 +165,19 @@ void main() { isTrue, ); expect( - matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"), + matchesDialogueStage( + a0Dialogues["a0-04"]!, + 0, + "MY NUMBER IS ONE-THREE-EIGHT", + ), isTrue, ); expect( - matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"), + matchesDialogueStage( + a0Dialogues["a0-08"]!, + 2, + "IT'S THREE O'CLOCK", + ), isTrue, ); }); @@ -110,7 +189,10 @@ void main() { expect(later.length, greaterThan(first.length)); expect(first.every(later.contains), isTrue); expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length)); - expect(first.any((word) => word.toLowerCase().contains('coffee')), isFalse); + expect( + first.any((word) => word.toLowerCase().contains('coffee')), + isFalse, + ); }); }); } diff --git a/kouyu_english/test/placement_and_scene_test.dart b/kouyu_english/test/placement_and_scene_test.dart new file mode 100644 index 0000000..e1880fb --- /dev/null +++ b/kouyu_english/test/placement_and_scene_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/app_state.dart'; +import 'package:kouyu_english/features/dialogue/dialogue_flow.dart'; +import 'package:kouyu_english/features/onboarding/onboarding_pages.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('three-question placement suggests a starting lesson', ( + tester, + ) async { + final state = AppState(); + var started = false; + await tester.pumpWidget( + MaterialApp( + home: PlacementPage(state: state, onStart: () => started = true), + ), + ); + + await tester.tap(find.text('开始 3 分钟定位')); + await tester.pumpAndSettle(); + expect(find.text('定位 · 1 / 3'), findsOneWidget); + await tester.tap(find.text('你好,我叫 Mia,很高兴认识你。')); + await tester.pump(); + await tester.tap(find.text('下一题')); + await tester.pumpAndSettle(); + + expect(find.text('定位 · 2 / 3'), findsOneWidget); + await tester.tap(find.text('现在不方便开口,跳过')); + await tester.pumpAndSettle(); + + expect(find.text('定位 · 3 / 3'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'My name is Mia.'); + await tester.tap(find.text('查看建议')); + await tester.pumpAndSettle(); + + expect(find.text('建议从 A0 第 2 课开始'), findsOneWidget); + expect(find.text('从更简单内容开始'), findsOneWidget); + await tester.tap(find.text('开始今天学习')); + await tester.pump(); + + expect(started, isTrue); + expect(state.activeLessonId, 'a0-02'); + expect(state.placementStartLessonId, 'a0-02'); + expect(state.completedLessonIds, isEmpty); + }); + + testWidgets('an unanswered placement starts from lesson one', (tester) async { + final state = AppState(); + await tester.pumpWidget( + MaterialApp( + home: PlacementPage(state: state, onStart: () {}), + ), + ); + + await tester.tap(find.text('开始 3 分钟定位')); + await tester.pumpAndSettle(); + await tester.tap(find.text('听不懂,下一题')); + await tester.pumpAndSettle(); + await tester.tap(find.text('我读完了')); + await tester.pumpAndSettle(); + await tester.tap(find.text('还不会说,查看建议')); + await tester.pumpAndSettle(); + + expect(find.text('建议从 A0 第 1 课开始'), findsOneWidget); + expect(find.text('从更简单内容开始'), findsNothing); + }); + + testWidgets('scene page lists open and locked scenes', (tester) async { + final state = AppState() + ..completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']); + String? startedScene; + await tester.pumpWidget( + MaterialApp( + home: DialogueScenePage( + state: state, + onStart: (id) => startedScene = id, + ), + ), + ); + + expect(find.text('初次见面'), findsOneWidget); + expect(find.text('留个电话'), findsOneWidget); + expect(find.text('A0 · 学完第 7 课后开放'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '开始对话').first); + expect(startedScene, isNotNull); + }); +}