Files
Spanish/Conjuga/Conjuga/Views/Verbs/VerbDetailView.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

247 lines
9.0 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var speechService = SpeechService()
let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
@State private var examples: [VerbExample] = []
@State private var examplesState: ExamplesState = .idle
private enum ExamplesState: Equatable {
case idle
case loading
case loaded
case unavailable
case failed(String)
}
private static let exampleTenseIds: [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,
]
private var formsForTense: [VerbForm] {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
}
private var reflexiveEntries: [ReflexiveVerb] {
reflexiveStore.entries(for: verb.infinitive)
}
var body: some View {
List {
Section {
LabeledContent("English", value: verb.english)
LabeledContent("Ending", value: "-\(verb.ending)")
LabeledContent("Level", value: VerbLevel(rawValue: verb.level)?.displayName ?? verb.level.capitalized)
if verb.reflexive > 0 {
LabeledContent("Reflexive", value: verb.reflexive == 2 ? "Always" : "Yes")
}
} header: {
Text("Info")
}
if !reflexiveEntries.isEmpty {
reflexiveSection
}
Section {
Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in
Text(tense.english)
.tag(tense)
}
}
.pickerStyle(.menu)
if formsForTense.isEmpty {
ContentUnavailableView("No forms loaded", systemImage: "exclamationmark.triangle")
} else {
ForEach(formsForTense, id: \.personIndex) { form in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(TenseInfo.persons[form.personIndex])
.foregroundStyle(.secondary)
.frame(minWidth: 100, alignment: .leading)
Text(form.form)
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
}
Text(EnglishConjugator.translate(
english: verb.english,
tenseId: selectedTense.id,
personIndex: form.personIndex
))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if formsForTense.contains(where: { $0.regularity != "ordinary" }) {
Label("Red indicates an irregular conjugation", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Conjugation")
}
examplesSection
}
.navigationTitle(verb.infinitive)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
speechService.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
}
.tint(.secondary)
}
}
.task(id: verb.id) {
await loadExamples()
}
}
// MARK: - Reflexive
private var reflexiveSection: some View {
Section {
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.infinitive)
.font(.body.weight(.semibold))
.italic()
if let hint = entry.usageHint, !hint.isEmpty {
Text(hint)
.font(.caption.weight(.medium))
.foregroundStyle(.tint)
}
}
Text(entry.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
} header: {
Text("Reflexive")
} footer: {
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
.font(.caption2)
}
}
}
// MARK: - Examples
@ViewBuilder
private var examplesSection: some View {
Section {
switch examplesState {
case .idle, .loading:
HStack(spacing: 10) {
ProgressView()
Text("Generating examples…")
.font(.caption)
.foregroundStyle(.secondary)
}
case .unavailable:
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.secondary)
case .loaded:
if examples.isEmpty {
Label("No examples available.", systemImage: "text.quote")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
VStack(alignment: .leading, spacing: 4) {
if let info = TenseInfo.find(example.tenseId) {
Text(info.english)
.font(.caption2.weight(.semibold))
.foregroundStyle(.tint)
}
Text(example.spanish)
.font(.body)
.italic()
Text(example.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
} header: {
Text("Examples")
}
}
private func loadExamples() async {
// Reset state when navigating between verbs via NavigationSplitView.
examples = []
examplesState = .idle
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
examples = cached
examplesState = .loaded
return
}
guard VerbExampleGenerator.isAvailable else {
examplesState = .unavailable
return
}
examplesState = .loading
do {
let formsByTense = ReferenceStore(context: modelContext)
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
let generated = try await VerbExampleGenerator.generate(
verbInfinitive: verb.infinitive,
verbEnglish: verb.english,
tenseIds: Self.exampleTenseIds,
formsByTense: formsByTense
)
guard !generated.isEmpty else {
examplesState = .failed("Could not generate examples.")
return
}
exampleCache.setExamples(generated, for: verb.id)
examples = generated
examplesState = .loaded
} catch {
examplesState = .failed("Could not generate examples.")
}
}
}
#Preview {
NavigationStack {
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
}
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
.environment(VerbExampleCache())
.environment(ReflexiveVerbStore())
}