Vocab Practice crash — defensive Dictionary init + correct tense count
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
// 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] }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {}
|
||||
if generatingExampleForVerbId == verbId {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user