Vocab Practice — apply code-review fixes for the SRS session work

Addresses the lead-review findings on the new vocab SRS/persistence code:

- Resume a persisted study group only when every stored verb resolves,
  else rebuild fresh — a partial resume desynced learnedCount from a
  shrunken queue and then persisted the loss.
- studyAgain() clears the finished group before building a new one.
- VocabStudyGroupStore.persist() collapses duplicate group records
  (cross-device sync races) down to the newest.
- Learn mode now derives its verb list from the live quiz queue, so it
  browses exactly what's left to learn instead of a stale frozen pool.
- VerbExampleGenerator retries when the first batch throws, not only
  when it returns bad data.
- Per-verb example generation tracks in-flight verbs in a Set, so the
  loading spinner clears reliably and duplicate generations are skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-17 15:59:33 -05:00
parent c794c013f0
commit dd08a09860
5 changed files with 57 additions and 40 deletions
+13 -4
View File
@@ -54,12 +54,21 @@ struct VocabStudyGroupStore {
return (try? context.fetch(descriptor))?.first return (try? context.fetch(descriptor))?.first
} }
/// Write the in-progress group, creating it if needed. /// Write the in-progress group, creating it if needed. If duplicate records
/// exist (two devices created a group before sync settled), the newest is
/// updated and the rest deleted so a single record survives.
func persist(entries: [StoredVocabEntry], learnedCount: Int) { func persist(entries: [StoredVocabEntry], learnedCount: Int) {
let data = (try? JSONEncoder().encode(entries)) ?? Data() let data = (try? JSONEncoder().encode(entries)) ?? Data()
if let group = activeGroup() { let id = VocabStudyGroup.activeID
group.entriesJSON = data let descriptor = FetchDescriptor<VocabStudyGroup>(
group.learnedCount = learnedCount predicate: #Predicate<VocabStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
)
let existing = (try? context.fetch(descriptor)) ?? []
if let newest = existing.first {
newest.entriesJSON = data
newest.learnedCount = learnedCount
for duplicate in existing.dropFirst() { context.delete(duplicate) }
} else { } else {
context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount)) context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
} }
@@ -54,12 +54,15 @@ struct VerbExampleGenerator {
tenseIds: [String], tenseIds: [String],
formsByTense: [String: [String]] formsByTense: [String: [String]]
) async throws -> [VerbExample] { ) async throws -> [VerbExample] {
let firstPass = try await generateBatch( // A thrown error here (model busy, context overflow) shouldn't abort the
// whole generation treat it as a fully-failed batch so the retry below
// still runs, same as the retry path's own `try?`.
let firstPass = (try? await generateBatch(
verbInfinitive: verbInfinitive, verbInfinitive: verbInfinitive,
verbEnglish: verbEnglish, verbEnglish: verbEnglish,
tenseIds: tenseIds, tenseIds: tenseIds,
formsByTense: formsByTense formsByTense: formsByTense
) )) ?? [:]
var valid: [String: VerbExample] = [:] var valid: [String: VerbExample] = [:]
var failedTenses: [String] = [] var failedTenses: [String] = []
@@ -82,6 +82,10 @@ struct VocabSessionQueue {
var entry = queue.removeFirst() var entry = queue.removeFirst()
switch rating { switch rating {
// Again/Hard always (re)set the card to `.learning`. A card already in
// `.review` therefore drops back a step an intentional lapse, matching
// Anki: missing a card you'd previously passed sends it back through the
// learning steps rather than letting it graduate.
case .again: case .again:
entry.state = .learning entry.state = .learning
insert(entry, offset: Int.random(in: 5...8)) insert(entry, offset: Int.random(in: 5...8))
@@ -33,18 +33,22 @@ struct VocabFlashcardPracticeView: View {
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue @AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
/// The fetched session pool (due-first + new, capped). Quiz mode feeds it
/// into `VocabSessionQueue`; Learn mode walks it directly on a loop.
@State private var sessionVerbs: [Verb] = []
@State private var session: VocabSessionQueue? @State private var session: VocabSessionQueue?
@State private var learnIndex: Int = 0 @State private var learnIndex: Int = 0
@State private var revealed: Bool = false @State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:] @State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil @State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService() @State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() } private var cloudContext: ModelContext { cloudModelContextProvider() }
/// The session's un-graduated verbs, derived live from the quiz queue so
/// Learn mode walks exactly what's left to learn it stays in sync as
/// quiz mode graduates cards rather than browsing a stale frozen pool.
private var sessionVerbs: [Verb] {
session?.queue.map(\.verb) ?? []
}
private var mode: Mode { private var mode: Mode {
get { Mode(rawValue: modeRaw) ?? .quiz } get { Mode(rawValue: modeRaw) ?? .quiz }
nonmutating set { modeRaw = newValue.rawValue } nonmutating set { modeRaw = newValue.rawValue }
@@ -370,7 +374,7 @@ struct VocabFlashcardPracticeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id { } else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
Text("Generating example…") Text("Generating example…")
@@ -386,13 +390,13 @@ struct VocabFlashcardPracticeView: View {
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard sessionVerbs.isEmpty else { return } guard session == nil else { return }
switch kind { switch kind {
case .standard: case .standard:
loadStandardSession() loadStandardSession()
case .reviewLearned: case .reviewLearned:
sessionVerbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext) let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: sessionVerbs) session = VocabSessionQueue(verbs: verbs)
} }
primeExampleForCurrent() primeExampleForCurrent()
} }
@@ -409,17 +413,18 @@ struct VocabFlashcardPracticeView: View {
guard let verb = byId[e.verbId] else { return nil } guard let verb = byId[e.verbId] else { return nil }
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new) return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
} }
if !entries.isEmpty { // Resume only if EVERY stored verb resolved. A partial resume
sessionVerbs = entries.map(\.verb) // would desync learnedCount from a shrunken queue and then
// persist that loss fall through to a fresh rebuild instead.
if entries.count == stored.count {
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount) session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
return return
} }
} }
} }
// No active group start fresh and persist immediately so closing // No active group (or it couldn't be fully resolved) start fresh and
// the app right away still resumes this set. // persist immediately so closing the app right away still resumes this set.
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
sessionVerbs = verbs
session = VocabSessionQueue(verbs: verbs) session = VocabSessionQueue(verbs: verbs)
persistGroup() persistGroup()
} }
@@ -449,10 +454,10 @@ struct VocabFlashcardPracticeView: View {
private func studyAgain() { private func studyAgain() {
switch kind { switch kind {
case .standard: case .standard:
// The finished group was already cleared by persistGroup(); // Clear the finished group explicitly before building a fresh one,
// build and persist a fresh set. // so a fresh set is never appended onto a stale group record.
VocabStudyGroupStore(context: cloudContext).clear()
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
sessionVerbs = verbs
session = VocabSessionQueue(verbs: verbs) session = VocabSessionQueue(verbs: verbs)
persistGroup() persistGroup()
case .reviewLearned: case .reviewLearned:
@@ -478,15 +483,15 @@ struct VocabFlashcardPracticeView: View {
private func primeExampleForCurrent() { private func primeExampleForCurrent() {
guard let verb = currentVerb else { return } guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return } let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verb.id)?.first { if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verb.id] = cached exampleByVerbId[verbId] = cached
return return
} }
guard VerbExampleGenerator.isAvailable else { return } guard VerbExampleGenerator.isAvailable else { return }
generatingExampleForVerbId = verb.id generatingVerbIds.insert(verbId)
let verbId = verb.id
let infinitive = verb.infinitive let infinitive = verb.infinitive
let english = verb.english let english = verb.english
let formsByTense = ReferenceStore(context: localContext) let formsByTense = ReferenceStore(context: localContext)
@@ -507,9 +512,7 @@ struct VocabFlashcardPracticeView: View {
} catch { } catch {
// Silent the example block just stays hidden. // Silent the example block just stays hidden.
} }
if generatingExampleForVerbId == verbId { generatingVerbIds.remove(verbId)
generatingExampleForVerbId = nil
}
} }
} }
} }
@@ -17,7 +17,7 @@ struct VocabMultipleChoicePracticeView: View {
@State private var options: [Verb] = [] @State private var options: [Verb] = []
@State private var selectedOption: Verb? = nil @State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:] @State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil @State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService() @State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() } private var cloudContext: ModelContext { cloudModelContextProvider() }
@@ -140,7 +140,7 @@ struct VocabMultipleChoicePracticeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id { } else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
Text("Generating example…") Text("Generating example…")
@@ -264,14 +264,14 @@ struct VocabMultipleChoicePracticeView: View {
private func primeExampleForCurrent() { private func primeExampleForCurrent() {
guard let verb = currentVerb else { return } guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return } let verbId = verb.id
if let cached = exampleCache.examples(for: verb.id)?.first { if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
exampleByVerbId[verb.id] = cached if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return return
} }
guard VerbExampleGenerator.isAvailable else { return } guard VerbExampleGenerator.isAvailable else { return }
generatingExampleForVerbId = verb.id generatingVerbIds.insert(verbId)
let verbId = verb.id
let infinitive = verb.infinitive let infinitive = verb.infinitive
let english = verb.english let english = verb.english
let formsByTense = ReferenceStore(context: localContext) let formsByTense = ReferenceStore(context: localContext)
@@ -290,9 +290,7 @@ struct VocabMultipleChoicePracticeView: View {
exampleByVerbId[verbId] = pick exampleByVerbId[verbId] = pick
} }
} catch {} } catch {}
if generatingExampleForVerbId == verbId { generatingVerbIds.remove(verbId)
generatingExampleForVerbId = nil
}
} }
} }
} }