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:
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user