From 0af8e648fed0a951e64fb824c77b1571a3e42761 Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 14 May 2026 10:02:34 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20Practice=20crash=20=E2=80=94=20defensiv?= =?UTF-8?q?e=20Dictionary=20init=20+=20correct=20tense=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values for key: 'imp_tú', triggered the first time a user rated a vocab card and the in-flight example generation tried to materialise. Root cause: VerbExampleGenerator.generate() builds a [tenseId: example] dictionary from the model's output via Dictionary(uniqueKeysWithValues:), which traps on duplicates. The generator's @Generable schema declares @Guide(.count(6)) on the examples array, so the LLM is forced to return exactly 6. The new Vocab Flashcards / Multiple Choice views called generate(... tenseIds: ["ind_presente"]) — only one tense — which left the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and the dictionary init trapped. Three fixes: Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:) with first-wins so the generator can never crash regardless of caller shape. Views/Practice/Vocab/VocabFlashcardPracticeView.swift and Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass the canonical 6-tense set (VocabExampleTenseIds.canonical, same as VerbDetailView uses), then pick the ind_presente example for the card. Caches all six in VerbExampleCache as a side effect. Views/Verbs/VerbListView.swift — replace empty-string systemImage on the Level menu Labels with "circle" so the device console isn't spammed with "No symbol named '' found in system symbol set" every time the user opens the filter menu. The crash analysis I gave earlier (CloudKit schema migration of VerbReviewCard) was wrong — device console shows the real culprit. No CloudKit-side changes needed; the new model stays in the cloud container. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/VerbExampleGenerator.swift | 13 +++++++-- .../Vocab/VocabFlashcardPracticeView.swift | 29 +++++++++++++++---- .../VocabMultipleChoicePracticeView.swift | 11 ++++--- .../Conjuga/Views/Verbs/VerbListView.swift | 6 ++-- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/Conjuga/Conjuga/Services/VerbExampleGenerator.swift b/Conjuga/Conjuga/Services/VerbExampleGenerator.swift index 1a87187..7ca3c7a 100644 --- a/Conjuga/Conjuga/Services/VerbExampleGenerator.swift +++ b/Conjuga/Conjuga/Services/VerbExampleGenerator.swift @@ -66,9 +66,16 @@ struct VerbExampleGenerator { // Map by tenseId and return in the caller's requested order so the UI // renders a predictable sequence even if the model shuffles its output. - let byTense = Dictionary(uniqueKeysWithValues: response.content.examples.map { - ($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english)) - }) + // Use `uniquingKeysWith` defensively — the @Generable schema requires + // exactly 6 examples, but if the model duplicates a tenseId (it does + // happen when the caller passes fewer than 6 distinct tenses), the + // strict `uniqueKeysWithValues:` initializer would trap. + let byTense = Dictionary( + response.content.examples.map { + ($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english)) + }, + uniquingKeysWith: { first, _ in first } + ) return tenseIds.compactMap { byTense[$0] } } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index 04efcd5..9b558c1 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -207,16 +207,19 @@ struct VocabFlashcardPracticeView: View { let english = verb.english Task { do { + // The generator's @Generable schema requires exactly 6 + // examples; pass the canonical 6-tense set used by + // VerbDetailView, then pick the present-tense one to + // show on the card. let examples = try await VerbExampleGenerator.generate( verbInfinitive: infinitive, verbEnglish: english, - tenseIds: ["ind_presente"] + tenseIds: VocabExampleTenseIds.canonical ) - if let first = examples.first { - exampleCache.setExamples(examples, for: verbId) - if currentVerb?.id == verbId { - exampleByVerbId[verbId] = first - } + exampleCache.setExamples(examples, for: verbId) + let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first + if let pick, currentVerb?.id == verbId { + exampleByVerbId[verbId] = pick } } catch { // Silent — the example block just stays hidden. @@ -238,6 +241,20 @@ struct VocabFlashcardPracticeView: View { } } +/// Canonical 6-tense set used by `VerbExampleGenerator`. Its `@Generable` +/// schema requires exactly 6 examples; callers must pass 6 distinct tense +/// IDs so the model has a unique slot for each generated example. +enum VocabExampleTenseIds { + static let canonical: [String] = [ + TenseID.ind_presente.rawValue, + TenseID.ind_preterito.rawValue, + TenseID.ind_imperfecto.rawValue, + TenseID.ind_futuro.rawValue, + TenseID.subj_presente.rawValue, + TenseID.imp_afirmativo.rawValue, + ] +} + // MARK: - Pool helper /// Shared verb-pool fetch used by both vocab flashcard and vocab MC. diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift index 84abff3..334a726 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -219,13 +219,12 @@ struct VocabMultipleChoicePracticeView: View { let examples = try await VerbExampleGenerator.generate( verbInfinitive: infinitive, verbEnglish: english, - tenseIds: ["ind_presente"] + tenseIds: VocabExampleTenseIds.canonical ) - if let first = examples.first { - exampleCache.setExamples(examples, for: verbId) - if currentVerb?.id == verbId { - exampleByVerbId[verbId] = first - } + exampleCache.setExamples(examples, for: verbId) + let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first + if let pick, currentVerb?.id == verbId { + exampleByVerbId[verbId] = pick } } catch {} if generatingExampleForVerbId == verbId { diff --git a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift index 8c5d4bd..c85c61c 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift @@ -97,13 +97,13 @@ struct VerbListView: View { Button { setAllLevels(enabled: true) } label: { - Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "") + Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle") } ForEach(levels, id: \.self) { level in Button { toggleLevel(level) } label: { - Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "") + Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle") } } } @@ -112,7 +112,7 @@ struct VerbListView: View { Button { selectedIrregularity = nil } label: { - Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "") + Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle") } ForEach(IrregularityCategory.allCases) { category in Button {