From fbacc00c978800e11e987e3decfbab4709093713 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Sat, 2 May 2026 20:39:48 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(vocab-quiz):=20UI=20cluster=20=E2=80=94?= =?UTF-8?q?=20anti-cheat=20+=20UX=20(#190=20#192=20#193=20#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream A of the Vocabulary Quiz bug cluster. Single-file Razor changes (plus three resource entries) covering four issues: #190 — Distractor pool now drawn from the full filtered+deduped vocabulary scope, not just the active round. New `distractorScope` field captured in LoadVocabulary; GenerateChoiceOptions samples from it with a defensive vocabItems fallback. Guarantees 4 MC options even when only 1 word remains to learn. Split CreateChoiceOption into CreateChoiceOption(item) + CreateChoiceOptionForWord(word) to support sampling from raw VocabularyWord. #192 — Added explicit Submit button inside the text-entry form. Hidden once feedback is shown in non-correction mode; disabled while userInput is empty/whitespace. Wired through existing @onsubmit="SubmitTypedAnswer". New resource key VocabQuiz_SubmitAnswer (en: "Submit", ko: "제출"). #193 — Prompt audio now follows prompt direction. New helpers GetPromptAudioText / GetPromptAudioLanguage switch on promptUsesNativeLanguage and return the side actually shown as the prompt. PlayWordAudio and audioPromptAvailable now route through these helpers so audio can never leak the answer in native→target prompts. #194 — Removed TargetLanguageTerm and NativeLanguageTerm from the Learning Details info panel (anti-cheat). Panel is opened mid-quiz; showing either side leaks the answer. Status badge moved to its own div; only stats and metadata remain. Closes #190 Closes #192 Closes #193 Closes #194 --- .../Strings/AppResources.Designer.cs | 9 +++ .../Resources/Strings/AppResources.ko.resx | 4 ++ .../Resources/Strings/AppResources.resx | 4 ++ src/SentenceStudio.UI/Pages/VocabQuiz.razor | 65 ++++++++++++++++--- 4 files changed, 72 insertions(+), 10 deletions(-) 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..8bfb1fca 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,10 +406,11 @@ 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
@@ -578,6 +589,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 +680,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 +830,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 +1109,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 +1127,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 +1446,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 +1559,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) From 348306a20eb8b3ab8519328b1be5b2bb81862974 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Sat, 2 May 2026 20:43:54 -0500 Subject: [PATCH 2/3] fix(vocab-quiz): strip legacy field readouts from Learning Details (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Learning Details panel was rendering pre-streak-truth metadata (IsKnown, IsUserDeclared, VerificationState) alongside the current streak-based fields. Per Jayne's service-side repro tests on main, service math is clean — the '2 attempts / 50% accuracy' confusion in issue #189 was a UI-rendering artifact. Restructured the two stat grids into one streak-truth grid showing only the allowlist: TotalAttempts, CorrectAttempts, Accuracy, CurrentStreak, ProductionInStreak, EffectiveStreak, MasteryScore plus the status badge. Schema fields on VocabularyProgress (IsKnown, VerificationState, ProductionAttempts, RecognitionAttempts, etc.) are unchanged for backward compatibility — only the panel rendering is tightened. Audited RecordPendingAttemptAsync call sites (NextItem, override-to- correct, DisposeAsync): no double-invocation. The method is idempotent via 'if (pendingAttempt == null) return;' guard. --- src/SentenceStudio.UI/Pages/VocabQuiz.razor | 45 +++++++++------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/SentenceStudio.UI/Pages/VocabQuiz.razor b/src/SentenceStudio.UI/Pages/VocabQuiz.razor index 8bfb1fca..f698cd64 100644 --- a/src/SentenceStudio.UI/Pages/VocabQuiz.razor +++ b/src/SentenceStudio.UI/Pages/VocabQuiz.razor @@ -413,28 +413,23 @@ else @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") @@ -444,16 +439,12 @@ else @(p?.ProductionInStreak ?? 0) / 2
- TotalAttempts - @(p?.TotalAttempts ?? 0) -
-
- CorrectAttempts - @(p?.CorrectAttempts ?? 0) + EffectiveStreak + @((p?.EffectiveStreak ?? 0f).ToString("F1"))
-
- Accuracy - @((p?.Accuracy ?? 0f).ToString("P0")) +
+ MasteryScore + @((p?.MasteryScore ?? 0f).ToString("P0"))
From 9ae607fffdd17ee7accdaa64ea18ffaf23e28dc5 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Sat, 2 May 2026 20:45:17 -0500 Subject: [PATCH 3/3] chore(squad): log Kaylee's #189 follow-up in history --- .squad/agents/kaylee/history.md | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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.