From 2d8dc4b8b827fcfb6e04a5c254b056a4369728f2 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:30:43 -0400 Subject: [PATCH] Defer audio example resolution out of practice selection; meaning fallback (#7702) --- .../practice-exercises.instructions.md | 40 ++++--- ...cab_audio_practice_exercise_generator.dart | 106 ++++++++++++------ .../vocab_audio_target_generator.dart | 24 ++-- .../vocab_audio_target_selection_test.dart | 72 ++++++++++++ 4 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 test/pangea/analytics_practice/vocab_audio_target_selection_test.dart diff --git a/.github/instructions/practice-exercises.instructions.md b/.github/instructions/practice-exercises.instructions.md index 94930c42ba..79babb0e86 100644 --- a/.github/instructions/practice-exercises.instructions.md +++ b/.github/instructions/practice-exercises.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "lib/pangea/practice_activities/**,lib/pangea/analytics_practice/**,lib/pangea/toolbar/message_practice/**" +applyTo: "lib/routes/chat/toolbar/practice_exercises/**,lib/routes/chat/toolbar/message_practice/**,lib/routes/analytics/construct_analytics/practice/**" --- # Practice Exercises @@ -18,9 +18,9 @@ For **conversation activities**, see [activities.instructions.md](activities.ins | Entry Point | What It Is | Where It Lives | Activity Types Used | |---|---|---|---| -| **Vocab Practice** | Standalone session of ~10 vocab exercises drawn from the user's weakest words | Analytics page → "Practice Vocab" button → [`AnalyticsPractice(type: vocab)`](../../lib/pangea/analytics_practice/analytics_practice_page.dart) | `lemmaMeaning`, `lemmaAudio` | -| **Grammar Practice** | Standalone session of ~10 grammar exercises drawn from recent errors + weak morphology | Analytics page → "Practice Grammar" button → [`AnalyticsPractice(type: morph)`](../../lib/pangea/analytics_practice/analytics_practice_page.dart) | `grammarError`, `grammarCategory` | -| **Message Practice** | Per-message practice accessed from the toolbar; exercises target words in that specific message | Toolbar → 💪 button → [`PracticeController`](../../lib/pangea/toolbar/message_practice/practice_controller.dart) | `wordMeaning`, `wordFocusListening`, `emoji`, `morphId` | +| **Vocab Practice** | Standalone session of ~10 vocab exercises drawn from the user's weakest words | Analytics page → "Practice Vocab" button → [`AnalyticsPracticePage`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_page.dart) (`type: vocab`) | `lemmaMeaning`, `lemmaAudio` | +| **Grammar Practice** | Standalone session of ~10 grammar exercises drawn from recent errors + weak morphology | Analytics page → "Practice Grammar" button → [`AnalyticsPracticePage`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_page.dart) (`type: morph`) | `grammarError`, `grammarCategory` | +| **Message Practice** | Per-message practice accessed from the toolbar; exercises target words in that specific message | Toolbar → 💪 button → [`PracticeController`](../../lib/routes/chat/toolbar/message_practice/practice_controller.dart) | `wordMeaning`, `wordFocusListening`, `emoji`, `morphId` | All three entry points produce the same [`ConstructUseModel`](../../lib/pangea/analytics_misc/constructs_model.dart) records, so practice from any source contributes equally to the user's vocabulary garden and XP. @@ -130,14 +130,14 @@ Each activity type maps to specific [`ConstructUseTypeEnum`](../../lib/pangea/an ### Session Lifecycle -1. [`AnalyticsPracticeSessionRepo.get(type, language)`](../../lib/pangea/analytics_practice/analytics_practice_session_repo.dart) builds a session: - - **Vocab**: fetches the user's weakest lemmas (by spaced-repetition score), splits ~50/50 between `lemmaAudio` (needs example messages with audio) and `lemmaMeaning` targets - - **Grammar**: fetches recent grammar errors first (`grammarError` targets), then fills remaining slots with weak morph features (`grammarCategory` targets) - - Session size: 10 exercises shown, plus a 5-item error buffer (15 targets generated — `targetsToGenerate`; constants in [`AnalyticsPracticeConstants`](../../lib/pangea/analytics_practice/analytics_practice_constants.dart)). The "~10" entry-point sessions above are this same 10 shown. -2. [`AnalyticsPracticeState`](../../lib/pangea/analytics_practice/analytics_practice_page.dart) manages the session UI — progress bar, timer, activity queue, hints -3. For each target, a [`MessageActivityRequest`](../../lib/pangea/practice_activities/message_activity_request.dart) is sent to the appropriate generator -4. The generator returns a [`PracticeActivityModel`](../../lib/pangea/practice_activities/practice_activity_model.dart) subclass with choices and answers -5. On answer, a construct use is recorded and the session advances +1. [`AnalyticsPracticeSessionRepo.get(type, userL1, userL2)`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_session_repo.dart) selects the session's targets (words/constructs + exercise type), NOT their content: + - **Vocab**: picks the user's weakest lemmas (by spaced-repetition score) and splits ~50/50 between `lemmaAudio` and `lemmaMeaning` targets. The split is by **count**; audio candidates are picked by a cheap local check that the lemma has an example-bearing use, and the example message itself is resolved later at generation (see [Loading & Generation Sequencing](#loading--generation-sequencing)). + - **Grammar**: picks recent grammar errors first (`grammarError` targets), then fills remaining slots with weak morph features (`grammarCategory` targets) + - Session size: 10 exercises shown, plus a 5-item error buffer (15 targets generated — `targetsToGenerate`; constants in [`AnalyticsPracticeConstants`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_constants.dart)). The "~10" entry-point sessions above are this same 10 shown. +2. [`PracticeSessionController`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_session_controller.dart) owns the session and the exercise queue; the page widgets show progress bar, timer, hints. +3. For each target, a [`MessagePracticeExerciseRequest`](../../lib/routes/chat/toolbar/practice_exercises/message_practice_exercise_request.dart) is routed by [`PracticeRepo`](../../lib/routes/chat/toolbar/practice_exercises/practice_generation_repo.dart) to the appropriate generator. +4. The generator returns a [`PracticeExerciseModel`](../../lib/routes/chat/toolbar/practice_exercises/practice_exercise_model.dart) subclass with choices and answers. +5. On answer, a construct use is recorded and the session advances. ### Session Completion @@ -146,9 +146,20 @@ When all targets are answered, [`CompletedActivitySessionView`](../../lib/pangea - Time elapsed (with bonus XP if under 60 seconds) - Per-item review +### Loading & Generation Sequencing + +Standalone practice loads in two phases, and only the first sits behind the loading screen: + +1. **Selection** ([`AnalyticsPracticeSessionRepo.get`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_session_repo.dart)) picks the session's targets from aggregated constructs (local analytics). +2. **Content generation** ([`PracticeSessionController.getNextExercise`](../../lib/routes/analytics/construct_analytics/practice/analytics_practice_session_controller.dart)) turns targets into exercises. The **first** exercise is awaited and shown; the moment it resolves, generation of **every** remaining exercise is kicked off eagerly and concurrently (`_fillExerciseQueue`), so later exercises are prefetched and waiting by the time the learner reaches them. + +Selection must stay cheap: it reads aggregated constructs from local analytics and does **not** resolve per-target example messages, audio, or Matrix events. Those resolve during content generation, inside the eager background queue. The goal is not to defer the work — every exercise is still prefetched — but to keep it from gating first paint: resolving example messages *during selection* blocked the first exercise on N serial event fetches, which made practice load slowly and inconsistently (#7702). + +A `lemmaAudio` target whose audio example can't be resolved at generation **falls back to a `lemmaMeaning` exercise for the same lemma**, so a deferred resolution failure never leaves a gap. This is a degradation path only — the audio/meaning mix is set at selection by count, so it doesn't materially shift the mix. + ### Subscription Gate -Standalone practice requires an active subscription. [`UnsubscribedPracticePage`](../../lib/pangea/analytics_practice/unsubscribed_practice_page.dart) is shown if the user isn't subscribed. +Standalone practice requires an active subscription. [`UnsubscribedPracticePage`](../../lib/routes/analytics/construct_analytics/practice/unsubscribed_practice_page.dart) is shown if the user isn't subscribed. --- @@ -207,7 +218,8 @@ All expose a `multipleChoiceContent` (choices + answers) and produce a `Practice ## Key Contracts - **Practice targets are deterministic per message.** For a given eventId + language + token set, the same targets are generated and cached. Don't introduce randomness that would change targets on re-render. -- **Practice never blocks on network.** Selection happens locally from cached token data. Activity content fetches from choreo, but the UI shows shimmer placeholders, never a blocking spinner. +- **Message practice never blocks on network.** Selection is local from cached token data; content fetches from choreo behind shimmer placeholders, never a blocking spinner. +- **Standalone practice gates the UI only on the first exercise.** The loading screen covers target selection (local analytics reads) plus generation of the first exercise; the remaining exercises are prefetched eagerly in the background — their example-message, audio, and event resolution runs concurrently so they're ready when reached — but the UI never waits on the full set. Selection stays cheap and resolves no example messages, audio, or events (see [Loading & Generation Sequencing](#loading--generation-sequencing)). - **Emoji and meaning choices persist beyond the practice session.** They become the user's personal annotation on that lemma, visible in word cards and analytics. - **All practice produces construct uses.** Whether from the toolbar or the standalone page, every answer is recorded as a `ConstructUseModel` that feeds into the analytics system. diff --git a/lib/routes/analytics/construct_analytics/practice/vocab_audio_practice_exercise_generator.dart b/lib/routes/analytics/construct_analytics/practice/vocab_audio_practice_exercise_generator.dart index 33cbe28f98..67c421db39 100644 --- a/lib/routes/analytics/construct_analytics/practice/vocab_audio_practice_exercise_generator.dart +++ b/lib/routes/analytics/construct_analytics/practice/vocab_audio_practice_exercise_generator.dart @@ -1,58 +1,63 @@ import 'package:fluffychat/routes/analytics/construct_analytics/practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/routes/analytics/construct_analytics/practice/example_message_util.dart'; +import 'package:fluffychat/routes/analytics/construct_analytics/practice/vocab_meaning_practice_exercise_generator.dart'; import 'package:fluffychat/routes/chat/events/models/pangea_token_model.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/lemma_practice_exercise_generator.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/message_practice_exercise_request.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/multiple_choice_practice_exercise_model.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_exercise_model.dart'; +import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_exercise_type_enum.dart'; +import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_target.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class VocabAudioPracticeExerciseGenerator { static Future get( MessagePracticeExerciseRequest req, ) async { final token = req.target.tokens.first; - final audioExample = req.audioExampleMessage; - // Find the matching token in the audio example message to get the correct form - PangeaToken targetToken = token; - if (audioExample != null) { - final matchingToken = audioExample.tokens.firstWhere( - (t) => t.lemma.text.toLowerCase() == token.lemma.text.toLowerCase(), - orElse: () => token, - ); - targetToken = matchingToken; + // The audio example message is resolved HERE (in the eager background + // queue), not at selection — resolving it during selection blocked first + // paint on N serial event fetches (#7702). If it can't be resolved, fall + // back to a meaning exercise for the same lemma so the slot isn't lost. + final audioExample = + req.audioExampleMessage ?? await _resolveAudioExample(req); + if (audioExample == null) { + return VocabMeaningPracticeExerciseGenerator.get(_asMeaningRequest(req)); } + // Find the matching token in the audio example message to get the correct form + final matchingToken = audioExample.tokens.firstWhere( + (t) => t.lemma.text.toLowerCase() == token.lemma.text.toLowerCase(), + orElse: () => token, + ); + final PangeaToken targetToken = matchingToken; + final Set answers = {}; final Set wordsInMessage = {}; final Set lemmasInMessage = {}; final List answerTokens = [targetToken]; - if (audioExample != null) { - // Collect all words/lemmas in message and select additional answer words - final List potentialAnswers = []; + // Collect all words/lemmas in message and select additional answer words + final List potentialAnswers = []; - for (final t in audioExample.tokens) { - wordsInMessage.add(t.text.content.toLowerCase()); - lemmasInMessage.add(t.lemma.text.toLowerCase()); + for (final t in audioExample.tokens) { + wordsInMessage.add(t.text.content.toLowerCase()); + lemmasInMessage.add(t.lemma.text.toLowerCase()); - if (t != targetToken && - t.lemma.saveVocab && - t.text.content.trim().isNotEmpty) { - potentialAnswers.add(t); - } + if (t != targetToken && + t.lemma.saveVocab && + t.text.content.trim().isNotEmpty) { + potentialAnswers.add(t); } + } - // Shuffle and select up to 3 additional answer words - potentialAnswers.shuffle(); - final otherAnswerTokens = potentialAnswers.take(3).toList(); + // Shuffle and select up to 3 additional answer words + potentialAnswers.shuffle(); + final otherAnswerTokens = potentialAnswers.take(3).toList(); - answerTokens.addAll(otherAnswerTokens); - answers.addAll(answerTokens.map((t) => t.text.content.toLowerCase())); - } else { - answers.add(targetToken.text.content.toLowerCase()); - wordsInMessage.add(targetToken.text.content.toLowerCase()); - lemmasInMessage.add(targetToken.lemma.text.toLowerCase()); - } + answerTokens.addAll(otherAnswerTokens); + answers.addAll(answerTokens.map((t) => t.text.content.toLowerCase())); // Generate distractors, filtering out anything in the message (by form or lemma) final choices = @@ -83,12 +88,43 @@ class VocabAudioPracticeExerciseGenerator { choices: allChoices.toSet(), answers: answers, ), - roomId: audioExample?.roomId, - eventId: audioExample?.eventId, - exampleMessage: - audioExample?.exampleMessage ?? - const ExampleMessageInfo(exampleMessage: []), + roomId: audioExample.roomId, + eventId: audioExample.eventId, + exampleMessage: audioExample.exampleMessage, ), ); } + + /// Resolve the audio example message for [req]'s lemma at generation time + /// (off the selection critical path, #7702). Looks the construct up from + /// local analytics, then finds an audio-capable example message. Null when + /// no usable example exists — the caller falls back to a meaning exercise. + static Future _resolveAudioExample( + MessagePracticeExerciseRequest req, + ) async { + final id = req.target.tokens.first.vocabConstructID; + final l2 = req.userL2.split('-').first; + final constructs = await MatrixState + .pangeaController + .matrixState + .analyticsDataService + .getConstructUses([id], l2); + final construct = constructs[id]; + if (construct == null) return null; + return ExampleMessageUtil.getAudioExampleMessage(construct, noBold: true); + } + + /// A meaning-exercise request for the same lemma, used when an audio example + /// can't be resolved. Meaning has no example-message dependency. + static MessagePracticeExerciseRequest _asMeaningRequest( + MessagePracticeExerciseRequest req, + ) => MessagePracticeExerciseRequest( + userL1: req.userL1, + userL2: req.userL2, + exerciseQualityFeedback: null, + target: PracticeTarget( + tokens: req.target.tokens, + exerciseType: PracticeExerciseTypeEnum.lemmaMeaning, + ), + ); } diff --git a/lib/routes/analytics/construct_analytics/practice/vocab_audio_target_generator.dart b/lib/routes/analytics/construct_analytics/practice/vocab_audio_target_generator.dart index 5cc960c37a..ce9da524f8 100644 --- a/lib/routes/analytics/construct_analytics/practice/vocab_audio_target_generator.dart +++ b/lib/routes/analytics/construct_analytics/practice/vocab_audio_target_generator.dart @@ -2,7 +2,6 @@ import 'package:fluffychat/features/analytics/construct_use_model.dart'; import 'package:fluffychat/routes/analytics/construct_analytics/practice/analytics_practice_constants.dart'; import 'package:fluffychat/routes/analytics/construct_analytics/practice/analytics_practice_session_model.dart'; import 'package:fluffychat/routes/analytics/construct_analytics/practice/construct_practice_extension.dart'; -import 'package:fluffychat/routes/analytics/construct_analytics/practice/example_message_util.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_exercise_type_enum.dart'; import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_target.dart'; @@ -18,7 +17,6 @@ class VocabAudioTargetGenerator { final sortedConstructs = constructs.practiceSort(exerciseType); final Set seenLemmas = {}; - final Set seenEventIds = {}; final targets = []; @@ -33,22 +31,19 @@ class VocabAudioTargetGenerator { continue; } - // Try to get an audio example message with token data for this lemma - final exampleMessage = await ExampleMessageUtil.getAudioExampleMessage( - construct, - noBold: true, - ); - - if (exampleMessage == null) continue; - final eventId = exampleMessage.eventId; - if (eventId != null && seenEventIds.contains(eventId)) { + // Cheap local check that an audio example is *resolvable* — a use that + // points at a message (eventId + roomId). The example message itself is + // resolved later, at generation, off the critical path (#7702): doing it + // here forced N serial event fetches before the first exercise could + // show. An audio target that fails to resolve at generation falls back + // to a meaning exercise (VocabAudioPracticeExerciseGenerator). + if (!construct.cappedUses.any( + (u) => u.metadata.eventId != null && u.metadata.roomId != null, + )) { continue; } seenLemmas.add(construct.lemma); - if (eventId != null) { - seenEventIds.add(eventId); - } targets.add( AnalyticsPracticeTarget( @@ -56,7 +51,6 @@ class VocabAudioTargetGenerator { tokens: [construct.id.asToken], exerciseType: exerciseType, ), - audioExampleMessage: exampleMessage, ), ); } diff --git a/test/pangea/analytics_practice/vocab_audio_target_selection_test.dart b/test/pangea/analytics_practice/vocab_audio_target_selection_test.dart new file mode 100644 index 0000000000..489c65ba7f --- /dev/null +++ b/test/pangea/analytics_practice/vocab_audio_target_selection_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/features/analytics/construct_type_enum.dart'; +import 'package:fluffychat/features/analytics/construct_use_model.dart'; +import 'package:fluffychat/features/analytics/construct_use_type_enum.dart'; +import 'package:fluffychat/features/analytics/constructs_model.dart'; +import 'package:fluffychat/routes/analytics/construct_analytics/practice/vocab_audio_target_generator.dart'; +import 'package:fluffychat/routes/chat/toolbar/practice_exercises/practice_exercise_type_enum.dart'; + +/// One use, optionally message-bearing (eventId + roomId), aged so it never +/// trips the recent-practice skip. +OneConstructUse _use({ + required String lemma, + String? eventId, + String? roomId, + ConstructUseTypeEnum type = ConstructUseTypeEnum.ta, +}) => OneConstructUse( + useType: type, + lemma: lemma, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: roomId, + eventId: eventId, + timeStamp: DateTime(2020), + ), + category: 'noun', + form: lemma, + xp: type.pointValue, +); + +ConstructUses _construct(String lemma, List uses) => + ConstructUses( + uses: uses, + constructType: ConstructTypeEnum.vocab, + lemma: lemma, + category: 'noun', + ); + +void main() { + group('VocabAudioTargetGenerator selection (#7702)', () { + test('selects a lemma with a message-bearing use, without resolving the ' + 'example message (no MatrixState / network needed)', () async { + final constructs = [ + _construct('gato', [ + _use(lemma: 'gato', eventId: r'$evt1', roomId: '!room1'), + ]), + ]; + + // No MatrixState is initialized in this test — if selection still tried + // to resolve the example message (the #7702 front-load), this would throw. + final targets = await VocabAudioTargetGenerator.get(constructs); + + expect(targets, hasLength(1)); + expect( + targets.single.target.exerciseType, + PracticeExerciseTypeEnum.lemmaAudio, + ); + // The example is resolved later, at generation — never at selection. + expect(targets.single.audioExampleMessage, isNull); + }); + + test('skips a lemma whose uses point at no message (no eventId/roomId), so ' + 'an audio example could never resolve', () async { + final constructs = [ + _construct('perro', [_use(lemma: 'perro')]), + ]; + + final targets = await VocabAudioTargetGenerator.get(constructs); + expect(targets, isEmpty); + }); + }); +}