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>
247 lines
9.0 KiB
Swift
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())
|
|
}
|