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
32 changes: 32 additions & 0 deletions .squad/agents/fenster/history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Fenster's Project Knowledge

## Learnings

### 2026-05-03: Issue #200 - Cloze ResourceId Decoupling

**Files touched:**
- `src/SentenceStudio.Shared/Services/PlanGeneration/DeterministicPlanBuilder.cs` (Layer 1)
- `src/SentenceStudio.Shared/Services/PlanGeneration/PlanConverter.cs` (Layer 2)
- `src/SentenceStudio.UI/Pages/Index.razor` (Layer 3)
- `src/SentenceStudio.UI/Pages/Cloze.razor` (Layer 4a)
- `src/SentenceStudio.AppLib/Services/ClozureService.cs` (Layer 4b)

**What was tricky:**
- ClozureService refactoring required extracting common AI generation logic into `GenerateSentencesFromWords()` helper to avoid code duplication between resource-driven and vocabulary-driven paths
- Had to mirror VocabQuiz's SRS filtering logic exactly (grace period exclusion, NextReviewDate checks, unseen word handling)
- The 40-word cap for AI prompts is critical for both paths — dynamic resources can return thousands of eligible words

**What surprised me:**
- Persisted plan items bypass PlanConverter entirely — Index.razor reads `item.ResourceId` directly, which is why Layer 3 guard is mandatory even after Layer 1 fix ships
- Layer 4 defense-in-depth (DueOnly check in Cloze.razor) works correctly even when URL has resourceId leaked from persisted plan
- The ClozureService method signature needed a default parameter (`bool dueOnly = false`) to avoid breaking existing callers

**Key pattern:**
When adding vocabulary-driven mode to an activity:
1. Layer 1 prevents NEW plans from carrying ResourceId
2. Layer 2 sets DueOnly route param for plan-initiated launches
3. Layer 3 guards against ResourceId leak from OLD persisted plans
4. Layer 4 implements dual-mode loading (DueOnly=true → global vocab, DueOnly=false → resource-filtered)

**Reusable for:**
Any activity that should load from full user vocab pool when launched from Today's Plan but remain resource-filtered when launched directly from a resource detail page.
37 changes: 37 additions & 0 deletions .squad/agents/keaton/history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Keaton's History

## Learnings

### Issue #200 - Cloze Activity ResourceId Decoupling (2026-05-03)

**Root Cause:** Cloze activity is missing the 4-layer ResourceId Decoupling Pattern that was applied to VocabQuiz (commits 88a0272, c081a63) and VocabMatching (commit 0c8e197).

**Investigation Files:**
- `src/SentenceStudio.Shared/Services/PlanGeneration/DeterministicPlanBuilder.cs:690,755` — Cloze is categorized as an output activity but NOT marked as vocabulary-driven
- `src/SentenceStudio.Shared/Services/PlanGeneration/DeterministicPlanBuilder.cs:502-510` — Cloze activities ARE created with `ResourceId = resource.Id` (resource-driven)
- `src/SentenceStudio.Shared/Services/PlanGeneration/PlanConverter.cs:81-89` — Cloze enum case exists, but NO special handling (unlike VocabReview/VocabGame)
- `src/SentenceStudio.Shared/Services/PlanGeneration/PlanConverter.cs:121-159` — `BuildRouteParameters` has NO Cloze branch; falls through to resource-based default
- `src/SentenceStudio.UI/Pages/Index.razor:974-1012` — `LaunchPlanItem` guard at lines 984-986 ONLY excludes VocabReview + VocabGame; Cloze leaks ResourceId
- `src/SentenceStudio.UI/Pages/Cloze.razor:146-227` — NO `DueOnly` parameter; `LoadSentences` calls `ClozureSvc.GetSentences(resourceId, 8, skillId)` directly
- `src/SentenceStudio.AppLib/Services/ClozureService.cs:43-69` — Hard requires non-empty resourceID + skillID; returns empty list otherwise (line 50-59)

**Pattern Comparison:**

| Layer | VocabQuiz (✅ Fixed) | VocabMatching (✅ Fixed) | Cloze (❌ Missing) |
|-------|---------------------|-------------------------|-------------------|
| **1. Plan Builder** | ResourceId = null (line 460) | ResourceId = null (line 520) | ResourceId = resource.Id (line 505) |
| **2. PlanConverter** | DueOnly = true (line 128) | DueOnly = true (line 134) | No special handling; uses default resource-based params |
| **3. Index.razor guard** | Excluded at line 984 | Excluded at line 985 | NOT excluded; ResourceId leaks to URL |
| **4. Page defense** | DueOnly check line 643 | DueOnly check at VocabMatching.razor | NO DueOnly param; no fallback to full vocab pool |

**Mechanism:**
1. Plan builder stamps `ResourceId = resource.Id` for Cloze (line 505)
2. PlanConverter has no Cloze branch, so `BuildRouteParameters` falls through to default (lines 141-148), passes ResourceId unchanged
3. Index.razor `LaunchPlanItem` has no guard for Cloze, so it appends `resourceId={item.ResourceId}` to URL
4. Cloze.razor reads `ResourceIdParam` and passes it to `ClozureSvc.GetSentences(resourceId, 8, skillId)` (line 203)
5. ClozureService HARD REQUIRES resourceId (line 50-54) and returns empty list if missing or if that resource has no vocab
6. If the resource from the plan has no remaining vocab (all mastered, grace period, or empty), Cloze shows "no sentences available" even when hundreds of due words exist globally

**Discrepancy vs. Hypothesis:** Cloze is NOT vocabulary-driven in the current implementation — it's strictly resource-driven. This is unlike VocabQuiz/VocabMatching which were ALWAYS vocabulary-driven but incorrectly filtered by resource. Cloze genuinely uses the resource's transcript/content to generate contextual sentences via AI. The bug is that TODAY'S PLAN shouldn't restrict Cloze to a single resource when the user has broader vocab needs.

**Files:** DeterministicPlanBuilder.cs, PlanConverter.cs, Index.razor, Cloze.razor, ClozureService.cs
209 changes: 209 additions & 0 deletions .squad/skills/resource-id-decoupling/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# ResourceId Decoupling Pattern for Vocabulary-Driven Activities

## When to Use This Pattern

Apply when an activity is **vocabulary-driven** (should load from full user vocab pool based on SRS/due status) but can be incorrectly filtered by LearningResource scope.

**Trigger symptoms:**
- Activity launched from Today's Plan shows "no words available" error
- Same activity works when launched from resource detail page
- User has many due words globally but activity fails on specific resource
- Insights dashboard shows X due words, but activity claims 0 available

**Vocabulary-driven activities:**
- VocabularyReview (Vocab Quiz)
- VocabularyGame (Vocab Matching)
- Cloze (when launched from plan — generates contextual sentences for due vocab)

**Resource-driven activities (DO NOT apply this pattern):**
- Reading, Listening, VideoWatching, Shadowing, Translation, Writing
- These require resource.Transcript, resource.AudioUrl, etc.

## The 4-Layer Fix

### Layer 1: DeterministicPlanBuilder.cs

Set `ResourceId = null` when creating the plan activity. This prevents NEW plans from carrying ResourceId.

**Example (VocabReview, line 460):**
```csharp
activities.Add(new PlannedActivity
{
ActivityType = "VocabularyReview",
ResourceId = null, // VocabularyReview is vocabulary-driven, NOT resource-scoped
SkillId = vocabReview.SkillId ?? skill?.Id,
// ...
});
```

**Example (VocabGame, line 520):**
```csharp
activities.Add(new PlannedActivity
{
ActivityType = "VocabularyGame",
ResourceId = null, // Vocabulary games use skill context
SkillId = skill.Id,
// ...
});
```

**Example (Cloze, line 505):**
```csharp
activities.Add(new PlannedActivity
{
ActivityType = outputActivity,
ResourceId = outputActivity == "Cloze" ? null : resource.Id, // Cloze is vocabulary-driven when from plan
SkillId = skill?.Id,
// ...
});
```

### Layer 2: PlanConverter.cs

Add activity-specific handling in `BuildRouteParameters` to:
1. Set `DueOnly = true` (SRS filter)
2. Skip passing ResourceId to route params

**Example (VocabReview, lines 126-131):**
```csharp
if (activityType == PlanActivityType.VocabularyReview)
{
parameters["Mode"] = "SRS";
parameters["DueOnly"] = true;
// VocabularyReview is vocabulary-driven, NOT resource-driven
// ResourceId is intentionally NOT passed to allow quiz to load from full user vocab pool
}
```

**Example (VocabGame, lines 133-139):**
```csharp
else if (activityType == PlanActivityType.VocabularyGame)
{
parameters["DueOnly"] = true;
// VocabularyGame is vocabulary-driven, NOT resource-driven (like VocabularyReview)
// ResourceId is intentionally NOT passed to allow matching to load from full user vocab pool
if (!string.IsNullOrEmpty(skillId))
parameters["SkillId"] = skillId;
}
```

**Example (Cloze, lines 140-147):**
```csharp
else if (activityType == PlanActivityType.Cloze)
{
parameters["DueOnly"] = true;
// Cloze is vocabulary-driven when launched from plan, NOT resource-driven
// ResourceId is intentionally NOT passed to allow loading from full user vocab pool
if (!string.IsNullOrEmpty(skillId))
parameters["SkillId"] = skillId;
}
```

### Layer 3: Index.razor LaunchPlanItem Guard

Prevent **persisted old plan items** from leaking ResourceId into the URL. This is defense-in-depth for plans generated before Layer 1 fix.

**Location:** `src/SentenceStudio.UI/Pages/Index.razor` lines 984-992

**Pattern:**
```csharp
// CRITICAL: For vocabulary-driven activities (VocabularyReview, VocabularyGame, Cloze),
// NEVER pass ResourceId (even if persisted on the plan item)
// These activities are vocabulary-driven, NOT resource-driven
if (item.ActivityType != PlanActivityType.VocabularyReview
&& item.ActivityType != PlanActivityType.VocabularyGame
&& item.ActivityType != PlanActivityType.Cloze
&& !string.IsNullOrEmpty(item.ResourceId))
{
if (multiResourceRoutes.Contains(route))
query.Add($"resourceIds={item.ResourceId}");
else
query.Add($"resourceId={item.ResourceId}");
}
```

**Why this matters:** Even after Layer 1 fix ships, DB already contains persisted DailyPlan rows with the old ResourceId. Index.razor reading `item.ResourceId` directly bypasses PlanConverter entirely. The guard stops the leak at the UI boundary.

### Layer 4: Page-Level Defense (DueOnly Check)

Activity page ignores ResourceIds when `DueOnly=true`, even if upstream leak bypassed Layer 3.

**Example (VocabQuiz.razor, line 643):**
```csharp
// DEFENSE IN DEPTH: When DueOnly=true (SRS mode), ignore ResourceIds even if passed
// This ensures VocabularyReview always loads from full user vocabulary pool
var resourceIds = DueOnly ? Array.Empty<string>() : ParseResourceIds();
```

**Example (VocabMatching.razor, similar pattern):**
```csharp
// DEFENSE IN DEPTH: When DueOnly=true (plan-initiated SRS mode), ignore ResourceIds
// and load from full user vocabulary pool, same pattern as VocabQuiz
var resourceIds = DueOnly ? Array.Empty<string>() : ParseResourceIds();
```

**Example (Cloze.razor + ClozureService):**
```csharp
// In Cloze.razor LoadSentences method:
private async Task LoadSentences()
{
// DEFENSE IN DEPTH: When DueOnly=true (plan-initiated SRS mode), ignore ResourceId
// and load from full user vocabulary pool, same pattern as VocabQuiz
var resourceId = DueOnly ? "" : (ResourceIdParam ?? "");
var skillId = SkillIdParam ?? "";
var result = await ClozureSvc.GetSentences(resourceId, 8, skillId, DueOnly);
// ...
}

// In ClozureService.cs:
public async Task<List<Challenge>> GetSentences(string resourceID, int numberOfSentences, string skillID, bool dueOnly = false)
{
// DEFENSE IN DEPTH: When dueOnly=true (plan-initiated), ignore resourceID and load from full vocab pool
if (dueOnly || string.IsNullOrEmpty(resourceID))
{
return await GetSentencesFromDueWords(numberOfSentences, skillID);
}
// ... existing resource-driven path
}

private async Task<List<Challenge>> GetSentencesFromDueWords(int numberOfSentences, string skillID)
{
// Load all user vocabulary (same pattern as VocabQuiz)
var allWords = await _resourceRepository.GetAllVocabularyWordsWithResourcesAsync();
// Filter to due words using SRS logic
var dueWords = allWords.Where(w => {
// Grace period exclusion, NextReviewDate checks, unseen word handling
}).ToList();
// Generate sentences from due vocab pool
return await GenerateSentencesFromWords(dueWords, numberOfSentences, skillID, targetLanguage);
}
```

**Required page changes:**
1. Add query parameter: `[SupplyParameterFromQuery(Name = "DueOnly")] public bool DueOnly { get; set; }`
2. Check DueOnly flag in load method
3. When DueOnly=true: call `GetAllVocabularyWordsWithResourcesAsync()` or `GetDueWordsAsync()` (full user pool)
4. When DueOnly=false: preserve existing resource-filtered behavior (user-initiated path)

## Testing Checklist

After applying all 4 layers:

- [ ] Generate new plan → activity launches successfully from Today's Plan
- [ ] Old persisted plan item → activity still launches successfully (Layer 3 guard prevents leak)
- [ ] Direct resource-filtered launch → still works (DueOnly=false path)
- [ ] Activity loads due words globally when launched from plan
- [ ] Activity respects resource filter when launched from resource detail page
- [ ] No regression for other vocabulary-driven activities

## References

- VocabQuiz decoupling: commits 88a0272 (builder+converter), c081a63 (Index guard + page defense)
- VocabMatching decoupling: commit 0c8e197 (all 4 layers)
- Cloze decoupling: commit b3a2937 (all 4 layers, PR #201)
- Decisions log: `.squad/decisions.md` lines 7-16
- Memory: "DailyPlan items persisted in DB retain their original ResourceId field. UI code that reads item.ResourceId directly (e.g., Index.razor LaunchPlanItem) bypasses PlanConverter and can leak stale resource filters."

## Anti-Pattern: ResourceId Field Removal

**DO NOT** remove the ResourceId field from DailyPlanItem schema. Resource-driven activities (Reading, Shadowing, etc.) legitimately use it. The fix is selective filtering, not schema change.
Loading
Loading