f0eb75a28a
The example sentences in VerbDetailView (and the new vocab practice modes) frequently used the wrong verb — a "tener" set would show "Él estaba leyendo un libro" (estar), "Nosotros vamos a viajar" (ir), "Tú debes estudiar" (deber). The model drifted off the target verb partway through generating the 6-example batch, and nothing checked the output. Two defenses: Prompt grounding — VerbExampleGenerator.generate now takes a formsByTense map (tenseId → conjugated forms, from the new ReferenceStore.conjugatedForms). Each tense line in the prompt lists the verb's exact conjugated forms and instructs the model to use one of them. The model echoes a real form instead of recalling (and mis-recalling) the conjugation. Output validation — every generated sentence is checked against the conjugation table via accent/case-folded whole-word matching. Any sentence that doesn't contain a real conjugated form of the verb is rejected. Failures trigger one regeneration pass; anything still wrong is dropped rather than displayed. Better to show 4 correct examples than 6 with 2 wrong. Cache invalidation — VerbExampleCache now persists a versioned wrapper (version 2). Pre-fix cached example sets — which may contain wrong-verb sentences — fail the version check and are discarded, so they regenerate cleanly under the new path. Callers updated: VerbDetailView, VocabFlashcardPracticeView, VocabMultipleChoicePracticeView all build formsByTense from ReferenceStore and pass it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
5.0 KiB
Swift
133 lines
5.0 KiB
Swift
import Foundation
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
struct ReferenceStore {
|
|
let context: ModelContext
|
|
|
|
func fetchGuides() -> [TenseGuide] {
|
|
let descriptor = FetchDescriptor<TenseGuide>(sortBy: [SortDescriptor(\TenseGuide.tenseId)])
|
|
return (try? context.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
func fetchGuideMap() -> [String: TenseGuide] {
|
|
Dictionary(uniqueKeysWithValues: fetchGuides().map { ($0.tenseId, $0) })
|
|
}
|
|
|
|
func fetchVerbs() -> [Verb] {
|
|
let descriptor = FetchDescriptor<Verb>(sortBy: [SortDescriptor(\Verb.infinitive)])
|
|
return (try? context.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
func fetchVerbs(selectedLevel: String) -> [Verb] {
|
|
fetchVerbs().filter { VerbLevelGroup.matches($0.level, selectedLevel: selectedLevel) }
|
|
}
|
|
|
|
func allowedVerbIDs(selectedLevel: String) -> Set<Int> {
|
|
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
|
}
|
|
|
|
/// Union of data-levels for all selected user-facing levels.
|
|
/// Empty input produces an empty result — callers decide how to handle that.
|
|
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
|
|
guard !selectedLevels.isEmpty else { return [] }
|
|
let ids = PracticeFilter.verbIDs(
|
|
matchingLevels: selectedLevels,
|
|
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
|
|
)
|
|
return fetchVerbs().filter { ids.contains($0.id) }
|
|
}
|
|
|
|
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
|
|
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
|
|
/// in SharedModels without a ModelContainer (Issue #26).
|
|
func allowedVerbIDs(
|
|
selectedLevels: Set<String>,
|
|
irregularCategories: Set<IrregularSpan.SpanCategory>
|
|
) -> Set<Int> {
|
|
PracticeFilter.allowedVerbIDs(
|
|
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
|
|
spans: allIrregularSlots(),
|
|
selectedLevels: selectedLevels,
|
|
irregularCategories: irregularCategories
|
|
)
|
|
}
|
|
|
|
/// Convenience: full Verb objects passing both filters.
|
|
func fetchVerbs(
|
|
selectedLevels: Set<String>,
|
|
irregularCategories: Set<IrregularSpan.SpanCategory>
|
|
) -> [Verb] {
|
|
let ids = allowedVerbIDs(
|
|
selectedLevels: selectedLevels,
|
|
irregularCategories: irregularCategories
|
|
)
|
|
return fetchVerbs().filter { ids.contains($0.id) }
|
|
}
|
|
|
|
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
|
|
let descriptor = FetchDescriptor<IrregularSpan>()
|
|
let spans = (try? context.fetch(descriptor)) ?? []
|
|
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
|
|
}
|
|
|
|
func fetchVerb(id: Int) -> Verb? {
|
|
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
|
return (try? context.fetch(descriptor))?.first
|
|
}
|
|
|
|
func fetchVerbForms(verbId: Int) -> [VerbForm] {
|
|
let descriptor = FetchDescriptor<VerbForm>(
|
|
predicate: #Predicate<VerbForm> { $0.verbId == verbId },
|
|
sortBy: [SortDescriptor(\VerbForm.tenseId), SortDescriptor(\VerbForm.personIndex)]
|
|
)
|
|
return (try? context.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
func fetchForms(verbId: Int, tenseId: String) -> [VerbForm] {
|
|
let descriptor = FetchDescriptor<VerbForm>(
|
|
predicate: #Predicate<VerbForm> { form in
|
|
form.verbId == verbId && form.tenseId == tenseId
|
|
},
|
|
sortBy: [SortDescriptor(\VerbForm.personIndex)]
|
|
)
|
|
return (try? context.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
/// Map of tenseId → conjugated forms for a verb, used to ground and
|
|
/// validate LLM-generated example sentences.
|
|
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
|
|
var map: [String: [String]] = [:]
|
|
for tenseId in tenseIds {
|
|
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
|
|
.map(\.form)
|
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
if !forms.isEmpty { map[tenseId] = forms }
|
|
}
|
|
return map
|
|
}
|
|
|
|
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
|
|
let descriptor = FetchDescriptor<VerbForm>(
|
|
predicate: #Predicate<VerbForm> { form in
|
|
form.verbId == verbId &&
|
|
form.tenseId == tenseId &&
|
|
form.personIndex == personIndex
|
|
}
|
|
)
|
|
return (try? context.fetch(descriptor))?.first
|
|
}
|
|
|
|
func fetchSpans(verbId: Int, tenseId: String, personIndex: Int) -> [IrregularSpan] {
|
|
let descriptor = FetchDescriptor<IrregularSpan>(
|
|
predicate: #Predicate<IrregularSpan> { span in
|
|
span.verbId == verbId &&
|
|
span.tenseId == tenseId &&
|
|
span.personIndex == personIndex
|
|
},
|
|
sortBy: [SortDescriptor(\IrregularSpan.start)]
|
|
)
|
|
return (try? context.fetch(descriptor)) ?? []
|
|
}
|
|
}
|