diff --git a/.squad/agents/kaylee/history.md b/.squad/agents/kaylee/history.md index 72bf49e7..fbe729a3 100644 --- a/.squad/agents/kaylee/history.md +++ b/.squad/agents/kaylee/history.md @@ -298,3 +298,64 @@ private async Task OnInitialSyncCompleted() **For future Blazor work:** If you encounter "my JS component initialized on nav-back but not on first-load," check for async mode flags + firstRender gates. This pattern resolves it. + +## Learnings — 2026-05-02 — Vocab Quiz UI cluster (Stream A) — PR #196 + +Shipped four UI fixes in `src/SentenceStudio.UI/Pages/VocabQuiz.razor` as a single PR. +- #190 (distractor scope), #192 (Submit button), #193 (prompt audio direction), #194 (anti-cheat info panel). +- New field: `distractorScope: List`. Populated in `LoadVocabulary` after dedup. Source of truth for MC distractor sampling. +- Helpers added: `GetPromptAudioText` / `GetPromptAudioLanguage` (line ~1559). Switch on `promptUsesNativeLanguage`. They are the canonical way to ask "what should the prompt-side audio say." `GetTargetAudioText` / `GetTargetAudioLanguage` are LEFT in place but are dead code — safe to delete in a follow-up. +- Method split: `CreateChoiceOption(item)` is now a thin wrapper over `CreateChoiceOptionForWord(VocabularyWord)`. The Word-level overload is what allows distractor sampling from raw scope. +- Resource keys live at `src/SentenceStudio.Shared/Resources/Strings/AppResources*.resx`. Key prefix `VocabQuiz_` is the convention. `Designer.cs` MUST be committed alongside resx changes. +- Aspire dev loop gotcha: restarting `webapp-rkmtvzgr` does NOT recompile. To pick up Razor changes, you MUST `dotnet build src/SentenceStudio.WebApp/SentenceStudio.WebApp.csproj` first, then `resource-stop` + `resource-start` the webapp resource. A `resource-restart` while the app is in `Unknown` state throws "Unhandled exception" — use start instead. The `webapp-rebuilder-ntjtbzbg` resource has no commands exposed from the MCP perspective. +- Verifying the right binary is loaded: `strings src/SentenceStudio.WebApp/bin/Debug/net10.0/SentenceStudio.UI.dll | grep -E "GetPromptAudioText|distractorScope"` is a fast sanity check before re-clicking through Playwright. +- Direct nav to `/vocab-quiz?...` after a webapp restart redirects to `/`. Always re-enter through the dashboard's "Vocabulary Review" tile so the planItem context attaches. +- Korean: "Submit" → "제출" is standard (Naver, gov forms). Used directly without a localize agent round-trip. + +### 2026-04-29 — #189 follow-up appended to PR #196 + +Jayne flagged service-side is clean (her repro tests pass on `main`); the +"2 attempts / 50% accuracy" confusion in #189 is purely UI rendering. + +**Panel change (`VocabQuiz.razor` ~line 412–448):** Replaced the two stat +grids with one streak-truth grid. Stripped legacy metadata readouts: +`IsKnown`, `IsUserDeclared`, `VerificationState`. Added `EffectiveStreak` +(was missing from rendering despite being on the model as a computed +prop: `CurrentStreak + ProductionInStreak * 0.5f`). Final allowlist: +TotalAttempts, CorrectAttempts, Accuracy, CurrentStreak, +ProductionInStreak, EffectiveStreak, MasteryScore, status badge. + +Schema fields on `VocabularyProgress` (incl. `RecognitionAttempts`, +`ProductionAttempts`, `IsKnown`, `VerificationState`) untouched — +sync/back-compat preserved. + +**Attempt-recording audit:** Inspected all four call sites in +`VocabQuiz.razor`: +- L980 `RecordAttemptAsync` — sentence-shortcut, intentionally records + one attempt per credited sentence (loop body). Correct. +- L1282 `RecordPendingAttemptAsync` in `NextItem` — records pending + attempt as-is. Correct. +- L1394 `RecordPendingAttemptAsync` in override-to-correct — mutates + `pendingAttempt.WasCorrect = true` then records. Correct. +- L1525 `RecordPendingAttemptAsync` in `DisposeAsync` — final flush. + Correct. + +The method is idempotent: `if (pendingAttempt == null) return;` guard + +`pendingAttempt = null;` after the snapshot. Second call no-ops. The +override-to-correct path then NextItem path is the worst case; second +record is the no-op. **No double-invocation.** + +**Convention learned:** Resource keys can become orphans when fields are +stripped from a panel (`VocabQuiz_IsKnown` is now unused). I noted this +in PR description under "Out of scope" — low-priority cleanup, not worth +churn on resx files. Future janitor pass should grep for unreferenced +`VocabQuiz_*` keys. + +**Build verify only — no e2e re-run.** Justification: the change is +mechanical rearrangement on the same offcanvas that I already screen- +shotted for #194, all rendered values are existing properties on +`p` (the progress reference), and the grid layout is identical bootstrap +class structure. Risk of visual regression is negligible. + +PR #196 body amended via `gh pr edit` to add `Closes #189` and a #189 +section explaining the panel cleanup + audit outcome. diff --git a/src/SentenceStudio.Shared/Resources/Strings/AppResources.Designer.cs b/src/SentenceStudio.Shared/Resources/Strings/AppResources.Designer.cs index fe2e4f52..f4d5e876 100755 --- a/src/SentenceStudio.Shared/Resources/Strings/AppResources.Designer.cs +++ b/src/SentenceStudio.Shared/Resources/Strings/AppResources.Designer.cs @@ -11009,6 +11009,15 @@ internal static string VocabQuiz_SessionSummary { } } + /// + /// Looks up a localized string similar to Submit. + /// + internal static string VocabQuiz_SubmitAnswer { + get { + return ResourceManager.GetString("VocabQuiz_SubmitAnswer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Target-word feedback:. /// diff --git a/src/SentenceStudio.Shared/Resources/Strings/AppResources.ko.resx b/src/SentenceStudio.Shared/Resources/Strings/AppResources.ko.resx index 39e867d0..22d74cb7 100755 --- a/src/SentenceStudio.Shared/Resources/Strings/AppResources.ko.resx +++ b/src/SentenceStudio.Shared/Resources/Strings/AppResources.ko.resx @@ -2778,6 +2778,10 @@ 답을 입력하세요 Input placeholder + + 제출 + Submit button for text-entry quiz answers + 계속하려면 정답을 입력하세요 Retry placeholder diff --git a/src/SentenceStudio.Shared/Resources/Strings/AppResources.resx b/src/SentenceStudio.Shared/Resources/Strings/AppResources.resx index 75b72654..f74dfa0d 100755 --- a/src/SentenceStudio.Shared/Resources/Strings/AppResources.resx +++ b/src/SentenceStudio.Shared/Resources/Strings/AppResources.resx @@ -3022,6 +3022,10 @@ For example: {0} ... Type your answer Input placeholder + + Submit + Submit button for text-entry quiz answers + Type the correct answer to continue Retry placeholder diff --git a/src/SentenceStudio.UI/Pages/VocabQuiz.razor b/src/SentenceStudio.UI/Pages/VocabQuiz.razor index bf46dc86..f698cd64 100644 --- a/src/SentenceStudio.UI/Pages/VocabQuiz.razor +++ b/src/SentenceStudio.UI/Pages/VocabQuiz.razor @@ -338,6 +338,16 @@ else if (currentItem != null) @bind="userInput" disabled="@(showAnswer && !requireCorrectTyping)" placeholder='@(requireCorrectTyping ? Localize["VocabQuiz_TypeCorrect"] : Localize["VocabQuiz_TypeAnswer"])' /> + @* #192 — explicit Submit button for text-entry mode (mirrors Enter key). Hidden once feedback is shown + in non-correction mode; in requireCorrectTyping mode it still drives the submit handler. *@ + @if (!showAnswer || requireCorrectTyping) + { +
+ +
+ } } @@ -396,34 +406,30 @@ else _ => "bg-secondary" }; } + @* #194 anti-cheat: Do NOT render TargetLanguageTerm or NativeLanguageTerm here — + this panel is opened mid-quiz and showing either side leaks the answer. + Only stats, status, and metadata may appear in the Learning Details panel. *@
-
@currentItem.Word.TargetLanguageTerm
- @currentItem.Word.NativeLanguageTerm - @statusText + @statusText
+ @* #189: Render only the streak-based truth fields. Legacy/obsolete metadata + (IsKnown, IsUserDeclared, VerificationState, ProductionAttempts, RecognitionAttempts) + is intentionally NOT shown — those readouts misled users about quiz state. + Schema fields remain on VocabularyProgress for backward compat. *@
-
- @Localize["VocabQuiz_IsKnown"] - @(p?.IsKnown == true ? Localize["VocabQuiz_Yes"] : Localize["VocabQuiz_No"]) -
-
- IsUserDeclared - @(p?.IsUserDeclared == true ? Localize["VocabQuiz_Yes"] : Localize["VocabQuiz_No"]) +
+ TotalAttempts + @(p?.TotalAttempts ?? 0)
-
- VerificationState - @(p?.VerificationState.ToString() ?? "None") +
+ CorrectAttempts + @(p?.CorrectAttempts ?? 0)
-
- MasteryScore - @((p?.MasteryScore ?? 0f).ToString("P0")) +
+ Accuracy + @((p?.Accuracy ?? 0f).ToString("P0"))
-
- -
- -
CurrentStreak @(p?.CurrentStreak.ToString("F1") ?? "0") @@ -433,16 +439,12 @@ else @(p?.ProductionInStreak ?? 0) / 2
- TotalAttempts - @(p?.TotalAttempts ?? 0) + EffectiveStreak + @((p?.EffectiveStreak ?? 0f).ToString("F1"))
-
- CorrectAttempts - @(p?.CorrectAttempts ?? 0) -
-
- Accuracy - @((p?.Accuracy ?? 0f).ToString("P0")) +
+ MasteryScore + @((p?.MasteryScore ?? 0f).ToString("P0"))
@@ -578,6 +580,9 @@ else private List vocabItems = new(); private List sessionItems = new(); private List roundWordOrder = new(); + // #190 — full vocabulary scope used as the distractor pool, so MC always has 4 options + // even when only 1 word remains in the active round. Populated in LoadVocabulary after dedup. + private List distractorScope = new(); private VocabularyQuizItem? currentItem; private int currentTurnInRound; @@ -666,6 +671,11 @@ else .Where(w => !string.IsNullOrWhiteSpace(w.NativeLanguageTerm) && !string.IsNullOrWhiteSpace(w.TargetLanguageTerm)) .GroupBy(w => w.Id).Select(g => g.First()).ToList(); + // #190 — capture the full filtered+deduped vocabulary scope for distractor sampling. + // GenerateChoiceOptions draws from this so we always show 4 MC options regardless of + // how many words remain in the active round. + distractorScope = allWords; + // Get progress var wordIds = allWords.Select(w => w.Id).ToList(); var progressDict = await ProgressService.GetProgressForWordsAsync(wordIds); @@ -811,7 +821,8 @@ else audioAnswerModeAvailable = false; } - audioPromptAvailable = await CanPlayAudioAsync(GetTargetAudioText(currentItem.Word), GetTargetAudioLanguage(currentItem.Word)); + // #193 — availability check must follow the prompt side (native or target), not always target. + audioPromptAvailable = await CanPlayAudioAsync(GetPromptAudioText(currentItem.Word), GetPromptAudioLanguage(currentItem.Word)); ApplyPromptPreferences(); responseTimer.Restart(); @@ -1089,9 +1100,16 @@ else { var correctOption = CreateChoiceOption(item); - var options = vocabItems - .Where(i => i != item) - .Select(CreateChoiceOption) + // #190 — sample distractors from the FULL vocabulary scope (resource-filtered or all user vocab), + // not just the active round. This guarantees a full set of 4 options even when remaining-to-learn + // shrinks to 1. Falls back to vocabItems if the scope hasn't been populated yet (defensive). + var pool = distractorScope.Count > 0 + ? (IEnumerable)distractorScope + : vocabItems.Select(v => v.Word); + + var options = pool + .Where(w => w.Id != item.Word.Id) + .Select(w => CreateChoiceOptionForWord(w)) .Where(option => !string.IsNullOrWhiteSpace(option.Text)) .Where(option => !string.Equals(option.Text, correctOption.Text, StringComparison.OrdinalIgnoreCase)) .GroupBy(option => option.Text, StringComparer.OrdinalIgnoreCase) @@ -1100,15 +1118,22 @@ else .Take(3) .ToList(); + // Edge case: if the full scope has fewer than 4 unique words, we degrade gracefully — + // we ship whatever distinct distractors we have plus the correct answer. This is + // deterministic by construction (no synthetic options injected) and avoids leaking + // "filler" answers that aren't real vocabulary. options.Add(correctOption); return options.OrderBy(_ => Guid.NewGuid()).ToArray(); } private QuizChoiceOption CreateChoiceOption(VocabularyQuizItem item) + => CreateChoiceOptionForWord(item.Word); + + private QuizChoiceOption CreateChoiceOptionForWord(VocabularyWord word) { - var answerText = GetAnswerText(item.Word); + var answerText = GetAnswerText(word); var answerLanguage = promptUsesNativeLanguage - ? item.Word.Language ?? targetLanguage + ? word.Language ?? targetLanguage : nativeLanguage; return new QuizChoiceOption(answerText, answerText, answerLanguage); @@ -1412,7 +1437,8 @@ else { if (currentItem?.Word == null) return; - await PlayAudioTextAsync(GetTargetAudioText(currentItem.Word), GetTargetAudioLanguage(currentItem.Word)); + // #193 — play prompt-side audio so we never leak the answer when prompt direction is native→target. + await PlayAudioTextAsync(GetPromptAudioText(currentItem.Word), GetPromptAudioLanguage(currentItem.Word)); } private Task PlayChoiceAudio(QuizChoiceOption option) => PlayAudioTextAsync(option.AudioText, option.AudioLanguage); @@ -1524,6 +1550,16 @@ else private string GetTargetAudioLanguage(VocabularyWord word) => word.Language ?? targetLanguage; + // #193 anti-cheat: prompt audio MUST follow the prompt direction. When prompt is native (e.g. "to turn"), + // playing target audio (털) leaks the answer. These helpers return the side actually shown as the prompt. + private string GetPromptAudioText(VocabularyWord word) => promptUsesNativeLanguage + ? word.NativeLanguageTerm ?? "" + : word.TargetLanguageTerm ?? ""; + + private string GetPromptAudioLanguage(VocabularyWord word) => promptUsesNativeLanguage + ? nativeLanguage + : word.Language ?? targetLanguage; + private async Task CanUseAudioAnswerModeAsync(IEnumerable options) { if (!promptUsesNativeLanguage || !QuizPrefs.UseAudioPrompt)