Files
Spanish/Conjuga/Conjuga/Services/ReferenceStore.swift
T
Trey T f0eb75a28a Fixes #33 — verb examples must actually use the verb
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>
2026-05-15 13:57:57 -05:00

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)) ?? []
}
}