Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions .github/instructions/practice-exercises.instructions.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Comment on lines +138 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Finish updating the Activity Generation section to the new contract.

These lines document the new routes and MessagePracticeExerciseRequest/PracticeExerciseModel, but Lines 197-210 still reference the old lib/pangea/ paths, MessageActivityRequest, and PracticeActivityModel. Update that section to avoid conflicting flow documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/instructions/practice-exercises.instructions.md around lines 138 -
139, Update the Activity Generation section around the remaining old references
to use the new lib/routes paths and the MessagePracticeExerciseRequest and
PracticeExerciseModel contract, matching the flow documented in the numbered
steps. Remove all references to the obsolete lib/pangea paths,
MessageActivityRequest, and PracticeActivityModel so the section is consistent.

5. On answer, a construct use is recorded and the session advances.

### Session Completion

Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MessagePracticeExerciseResponse> 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));
Comment on lines +23 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make resolver errors trigger the meaning fallback.

The fallback handles only null. An exception from getConstructUses or getAudioExampleMessage escapes get, aborting generation despite the documented “never leaves a gap” contract. Convert expected lookup/event-resolution failures to null with logging, then invoke _asMeaningRequest; cover a throwing resolver in tests.

Also applies to: 102-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@lib/routes/analytics/construct_analytics/practice/vocab_audio_practice_exercise_generator.dart`
around lines 23 - 26, Update the audio-example resolution flow in get so
expected failures from getConstructUses or getAudioExampleMessage are caught,
logged, and converted to null before the existing meaning fallback runs. Ensure
_asMeaningRequest is invoked for both null results and resolver exceptions,
while preserving successful audio generation; add coverage for a throwing
resolver.

}

// 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<String> answers = {};
final Set<String> wordsInMessage = {};
final Set<String> lemmasInMessage = {};
final List<PangeaToken> answerTokens = [targetToken];

if (audioExample != null) {
// Collect all words/lemmas in message and select additional answer words
final List<PangeaToken> potentialAnswers = [];
// Collect all words/lemmas in message and select additional answer words
final List<PangeaToken> 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 =
Expand Down Expand Up @@ -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<AudioExampleMessage?> _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,
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,7 +17,6 @@ class VocabAudioTargetGenerator {
final sortedConstructs = constructs.practiceSort(exerciseType);

final Set<String> seenLemmas = {};
final Set<String> seenEventIds = {};

final targets = <AnalyticsPracticeTarget>[];

Expand All @@ -33,30 +31,26 @@ 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(
target: PracticeTarget(
tokens: [construct.id.asToken],
exerciseType: exerciseType,
),
audioExampleMessage: exampleMessage,
),
);
}
Expand Down
Loading
Loading