d0582c4ce7
randomFullTablePrompt now takes the previous prompt's tense and verb ending and picks the next one to avoid an immediate repeat: when more than one tense is selected the just-shown tense is excluded, and when both -ar and -er/-ir verbs are available the next verb switches family. Both constraints are best-effort — if honouring one would leave no eligible fully-regular combo it is dropped, and the exhaustive "anything eligible at all" guarantee is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
426 lines
16 KiB
Swift
426 lines
16 KiB
Swift
import Foundation
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
struct PracticeSettings: Sendable {
|
|
let selectedLevel: String
|
|
let selectedLevels: Set<String>
|
|
let enabledTenses: Set<String>
|
|
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
|
let showVosotros: Bool
|
|
let showReflexiveVerbsOnly: Bool
|
|
let reflexiveBaseInfinitives: Set<String>
|
|
|
|
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
|
|
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
|
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
|
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
|
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
|
|
self.enabledTenses = Set(resolvedTenses)
|
|
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
|
self.showVosotros = progress?.showVosotros ?? true
|
|
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
|
|
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
|
}
|
|
|
|
var selectionTenseIDs: [String] {
|
|
enabledTenses.isEmpty ? TenseID.defaultPracticeIDs : Array(enabledTenses)
|
|
}
|
|
}
|
|
|
|
struct PracticeCardLoad {
|
|
let verb: Verb
|
|
let form: VerbForm
|
|
let spans: [IrregularSpan]
|
|
let tenseInfo: TenseInfo?
|
|
let person: String
|
|
}
|
|
|
|
struct FullTablePrompt {
|
|
let verb: Verb
|
|
let tenseInfo: TenseInfo
|
|
let forms: [VerbForm]
|
|
}
|
|
|
|
struct PracticeSessionService {
|
|
let localContext: ModelContext
|
|
let cloudContext: ModelContext
|
|
let reflexiveBaseInfinitives: Set<String>
|
|
private let referenceStore: ReferenceStore
|
|
|
|
init(
|
|
localContext: ModelContext,
|
|
cloudContext: ModelContext,
|
|
reflexiveBaseInfinitives: Set<String> = []
|
|
) {
|
|
self.localContext = localContext
|
|
self.cloudContext = cloudContext
|
|
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
|
self.referenceStore = ReferenceStore(context: localContext)
|
|
}
|
|
|
|
func settings() -> PracticeSettings {
|
|
PracticeSettings(
|
|
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
|
|
reflexiveBaseInfinitives: reflexiveBaseInfinitives
|
|
)
|
|
}
|
|
|
|
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
|
switch focusMode {
|
|
case .weakVerbs:
|
|
if let form = pickWeakForm() {
|
|
return loadCard(from: form)
|
|
}
|
|
case .irregularity(let filter):
|
|
if let form = pickIrregularForm(filter: filter) {
|
|
return loadCard(from: form)
|
|
}
|
|
case .commonTenses:
|
|
if let form = pickCommonTenseForm() {
|
|
return loadCard(from: form)
|
|
}
|
|
case .none:
|
|
break
|
|
}
|
|
|
|
if let dueCard = fetchDueCard(excluding: lastVerbId) {
|
|
return loadCard(from: dueCard)
|
|
}
|
|
|
|
if let form = pickRandomForm() {
|
|
return loadCard(from: form)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
|
|
/// the prompt just shown; when possible the next prompt avoids repeating the
|
|
/// same tense back-to-back and switches the verb's ending family
|
|
/// (-ar ⇄ -er/-ir) so consecutive rounds feel varied. Both are best-effort:
|
|
/// if honouring them would leave no eligible combo, the constraint is
|
|
/// dropped rather than dead-ending.
|
|
func randomFullTablePrompt(
|
|
previousTenseId: String? = nil,
|
|
previousEnding: String? = nil
|
|
) -> FullTablePrompt? {
|
|
let settings = settings()
|
|
// Full Table is testing the user's grasp of regular conjugation patterns,
|
|
// not vocabulary recognition. Level filter is intentionally bypassed so
|
|
// we draw from the entire verb pool — being able to conjugate `hablar`
|
|
// regularly transfers to any other regular verb regardless of "level".
|
|
// Irregular-category and tense filters still apply via downstream checks.
|
|
let verbs = applyReflexiveFilter(
|
|
to: referenceStore.fetchVerbs(),
|
|
settings: settings
|
|
)
|
|
guard !verbs.isEmpty else { return nil }
|
|
|
|
let candidateTenseIds = settings.selectionTenseIDs
|
|
guard !candidateTenseIds.isEmpty else { return nil }
|
|
|
|
let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
|
|
let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
|
|
let isConstrained = tenseChoices.count != candidateTenseIds.count
|
|
|| verbChoices.count != verbs.count
|
|
|
|
// Best-effort: random-sample the constrained pool first so consecutive
|
|
// prompts vary the tense and the -ar/-er-ir family.
|
|
if isConstrained,
|
|
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
|
|
return prompt
|
|
}
|
|
|
|
// No constraint, or honouring it found nothing quickly — sample the
|
|
// full pool.
|
|
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
|
|
return prompt
|
|
}
|
|
|
|
// Guarantee: if any eligible (verb, tense) combo exists in the data we
|
|
// return one. Only return nil when the user's settings genuinely produce
|
|
// an empty pool (so the UI can show an error state instead of a blank).
|
|
for verb in verbs.shuffled() {
|
|
for tenseId in candidateTenseIds.shuffled() {
|
|
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
|
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
|
return prompt
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
|
|
/// With ~1750 verbs and several hundred eligible combos this almost always
|
|
/// succeeds within a handful of attempts.
|
|
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
|
|
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
|
|
for _ in 0..<40 {
|
|
guard let verb = verbs.randomElement(),
|
|
let tenseId = tenseIds.randomElement(),
|
|
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
|
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
|
return prompt
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Tense ids with the previous one removed — only when more than one tense
|
|
/// is selected and removing it still leaves a choice.
|
|
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
|
|
guard ids.count > 1, let previous else { return ids }
|
|
let filtered = ids.filter { $0 != previous }
|
|
return filtered.isEmpty ? ids : filtered
|
|
}
|
|
|
|
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
|
|
/// verb's — only when both families are actually present in the pool.
|
|
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
|
|
guard let previousEnding else { return verbs }
|
|
let previousIsAr = (previousEnding == "ar")
|
|
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
|
|
return switched.isEmpty ? verbs : switched
|
|
}
|
|
|
|
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
|
|
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
|
|
private func makePromptIfFullyRegular(
|
|
verb: Verb,
|
|
tenseId: String,
|
|
tenseInfo: TenseInfo
|
|
) -> FullTablePrompt? {
|
|
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
|
guard !forms.isEmpty else { return nil }
|
|
// Forms must arrive in personIndex order so the regularity array lines
|
|
// up. `fetchForms` already sorts them, but assert for safety.
|
|
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
|
|
let regularities = sorted.map { $0.regularity }
|
|
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
|
|
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
|
|
}
|
|
|
|
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
|
ReviewStore.recordReview(
|
|
verbId: verbId,
|
|
tenseId: tenseId,
|
|
personIndex: personIndex,
|
|
quality: quality,
|
|
context: cloudContext,
|
|
referenceContext: localContext
|
|
)
|
|
}
|
|
|
|
func recordFullTableReview(verbId: Int, tenseId: String, results: [Int: Bool]) -> [Badge] {
|
|
ReviewStore.recordFullTableReview(
|
|
verbId: verbId,
|
|
tenseId: tenseId,
|
|
results: results,
|
|
context: cloudContext,
|
|
referenceContext: localContext
|
|
)
|
|
}
|
|
|
|
func loadCard(from reviewCard: ReviewCard) -> PracticeCardLoad? {
|
|
guard let verb = referenceStore.fetchVerb(id: reviewCard.verbId),
|
|
let form = referenceStore.fetchForm(
|
|
verbId: reviewCard.verbId,
|
|
tenseId: reviewCard.tenseId,
|
|
personIndex: reviewCard.personIndex
|
|
) else { return nil }
|
|
return buildCardLoad(verb: verb, form: form)
|
|
}
|
|
|
|
func loadCard(from form: VerbForm) -> PracticeCardLoad? {
|
|
guard let verb = referenceStore.fetchVerb(id: form.verbId) else { return nil }
|
|
return buildCardLoad(verb: verb, form: form)
|
|
}
|
|
|
|
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
|
|
/// verb-id set to IDs whose infinitive is in the curated list.
|
|
/// No-op otherwise.
|
|
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
|
|
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
|
return ids
|
|
}
|
|
let matching = ids.filter { id in
|
|
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
|
|
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
|
|
}
|
|
return matching
|
|
}
|
|
|
|
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
|
|
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
|
return verbs
|
|
}
|
|
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
|
|
}
|
|
|
|
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
|
let spans = referenceStore.fetchSpans(
|
|
verbId: form.verbId,
|
|
tenseId: form.tenseId,
|
|
personIndex: form.personIndex
|
|
)
|
|
let person = TenseInfo.persons.indices.contains(form.personIndex)
|
|
? TenseInfo.persons[form.personIndex]
|
|
: ""
|
|
|
|
return PracticeCardLoad(
|
|
verb: verb,
|
|
form: form,
|
|
spans: spans,
|
|
tenseInfo: TenseInfo.find(form.tenseId),
|
|
person: person
|
|
)
|
|
}
|
|
|
|
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
|
let settings = settings()
|
|
let allowedVerbIds = applyReflexiveFilter(
|
|
to: referenceStore.allowedVerbIDs(
|
|
selectedLevels: settings.selectedLevels,
|
|
irregularCategories: settings.enabledIrregularCategories
|
|
),
|
|
settings: settings
|
|
)
|
|
let now = Date()
|
|
var descriptor = FetchDescriptor<ReviewCard>(
|
|
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
|
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
|
|
)
|
|
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
|
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
|
|
|
let eligible = cards.filter { card in
|
|
allowedVerbIds.contains(card.verbId) &&
|
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
|
(settings.showVosotros || card.personIndex != 4)
|
|
}
|
|
|
|
// Prefer a card from a different verb than the last one shown.
|
|
// Fall back to the same verb only if it's the sole due card.
|
|
if let lastVerbId {
|
|
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
|
|
return different
|
|
}
|
|
}
|
|
return eligible.first
|
|
}
|
|
|
|
private func pickWeakForm() -> VerbForm? {
|
|
let settings = settings()
|
|
let allowedVerbIds = applyReflexiveFilter(
|
|
to: referenceStore.allowedVerbIDs(
|
|
selectedLevels: settings.selectedLevels,
|
|
irregularCategories: settings.enabledIrregularCategories
|
|
),
|
|
settings: settings
|
|
)
|
|
|
|
let descriptor = FetchDescriptor<ReviewCard>(
|
|
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
|
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
|
|
)
|
|
let cards = ((try? cloudContext.fetch(descriptor)) ?? []).filter { card in
|
|
allowedVerbIds.contains(card.verbId) &&
|
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
|
(settings.showVosotros || card.personIndex != 4)
|
|
}
|
|
|
|
guard let card = cards.randomElement() else { return nil }
|
|
return referenceStore.fetchForm(
|
|
verbId: card.verbId,
|
|
tenseId: card.tenseId,
|
|
personIndex: card.personIndex
|
|
)
|
|
}
|
|
|
|
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
|
let settings = settings()
|
|
// Focus mode explicitly selects one irregular category, so the user's
|
|
// settings-level irregular filter is deliberately skipped here.
|
|
let allowedVerbIds = applyReflexiveFilter(
|
|
to: referenceStore.allowedVerbIDs(
|
|
selectedLevels: settings.selectedLevels,
|
|
irregularCategories: []
|
|
),
|
|
settings: settings
|
|
)
|
|
let typeRange: ClosedRange<Int>
|
|
|
|
switch filter {
|
|
case .spelling:
|
|
typeRange = 100...199
|
|
case .stemChange:
|
|
typeRange = 200...299
|
|
case .uniqueIrregular:
|
|
typeRange = 300...399
|
|
}
|
|
|
|
var descriptor = FetchDescriptor<IrregularSpan>(
|
|
predicate: #Predicate<IrregularSpan> { span in
|
|
span.spanType >= typeRange.lowerBound &&
|
|
span.spanType <= typeRange.upperBound
|
|
}
|
|
)
|
|
descriptor.fetchLimit = 500
|
|
|
|
let spans = ((try? localContext.fetch(descriptor)) ?? []).filter { span in
|
|
allowedVerbIds.contains(span.verbId) &&
|
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
|
|
(settings.showVosotros || span.personIndex != 4)
|
|
}
|
|
|
|
guard let span = spans.randomElement() else { return nil }
|
|
return referenceStore.fetchForm(
|
|
verbId: span.verbId,
|
|
tenseId: span.tenseId,
|
|
personIndex: span.personIndex
|
|
)
|
|
}
|
|
|
|
private func pickCommonTenseForm() -> VerbForm? {
|
|
let settings = settings()
|
|
let coreTenseIDs = TenseID.coreTenseIDs
|
|
let verbs = applyReflexiveFilter(
|
|
to: referenceStore.fetchVerbs(
|
|
selectedLevels: settings.selectedLevels,
|
|
irregularCategories: settings.enabledIrregularCategories
|
|
),
|
|
settings: settings
|
|
)
|
|
guard let verb = verbs.randomElement() else { return nil }
|
|
|
|
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
|
coreTenseIDs.contains(form.tenseId) &&
|
|
(settings.showVosotros || form.personIndex != 4)
|
|
}
|
|
|
|
return forms.randomElement()
|
|
}
|
|
|
|
private func pickRandomForm() -> VerbForm? {
|
|
let settings = settings()
|
|
let verbs = applyReflexiveFilter(
|
|
to: referenceStore.fetchVerbs(
|
|
selectedLevels: settings.selectedLevels,
|
|
irregularCategories: settings.enabledIrregularCategories
|
|
),
|
|
settings: settings
|
|
)
|
|
guard let verb = verbs.randomElement() else { return nil }
|
|
|
|
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(form.tenseId)) &&
|
|
(settings.showVosotros || form.personIndex != 4)
|
|
}
|
|
|
|
return forms.randomElement()
|
|
}
|
|
}
|