63dfc5e41a
Major changes: - Textbook UI: chapter list, reader, and interactive exercise view (keyboard + Apple Pencil) surfaced under the Course tab. 30 chapters, 251 exercises. - Stem-change conjugation toggle on Week 4 flashcard decks (E-IE, E-I, O-UE). Uses existing VerbForm + IrregularSpan data to render highlighted present tense conjugations inline. - Deterministic on-device answer grader with partial credit (correct / close for accent-stripped or single-char-typo / wrong). 11 unit tests cover it. - SharedModels: TextbookChapter (local), TextbookExerciseAttempt (cloud- synced), AnswerGrader helpers. Bumped schema. - DataLoader: textbook seeder (version 8) + refresh helpers that preserve LanGo course decks when textbook data is re-seeded. - Local extraction pipeline in Conjuga/Scripts/textbook/ — XHTML chapter parser, answer-key parser, macOS Vision image OCR + PDF page OCR, merger, NSSpellChecker validator, language-aware auto-fixer, and repair pass that re-pairs quarantined vocab rows using bounding-box coordinates. - UI test target (ConjugaUITests) with three tests: end-to-end textbook flow, all-chapters screenshot audit, and stem-change toggle verification. Generated textbook content (textbook_data.json, textbook_vocab.json) and third-party source files are gitignored — re-run Scripts/textbook/run_pipeline.sh locally to regenerate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
2.5 KiB
Swift
87 lines
2.5 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// One chapter of the textbook. Ordered content blocks are stored as JSON in `bodyJSON`
|
|
/// (encoded [TextbookBlock]) since SwiftData @Model doesn't support heterogeneous arrays.
|
|
@Model
|
|
public final class TextbookChapter {
|
|
@Attribute(.unique) public var id: String = ""
|
|
public var number: Int = 0
|
|
public var title: String = ""
|
|
public var part: Int = 0 // 0 = no part assignment
|
|
public var courseName: String = ""
|
|
public var bodyJSON: Data = Data()
|
|
public var exerciseCount: Int = 0
|
|
public var vocabTableCount: Int = 0
|
|
|
|
public init(
|
|
id: String,
|
|
number: Int,
|
|
title: String,
|
|
part: Int,
|
|
courseName: String,
|
|
bodyJSON: Data,
|
|
exerciseCount: Int,
|
|
vocabTableCount: Int
|
|
) {
|
|
self.id = id
|
|
self.number = number
|
|
self.title = title
|
|
self.part = part
|
|
self.courseName = courseName
|
|
self.bodyJSON = bodyJSON
|
|
self.exerciseCount = exerciseCount
|
|
self.vocabTableCount = vocabTableCount
|
|
}
|
|
|
|
public func blocks() -> [TextbookBlock] {
|
|
(try? JSONDecoder().decode([TextbookBlock].self, from: bodyJSON)) ?? []
|
|
}
|
|
}
|
|
|
|
/// One content block within a chapter. Polymorphic via `kind`.
|
|
public struct TextbookBlock: Codable, Identifiable, Sendable {
|
|
public enum Kind: String, Codable, Sendable {
|
|
case heading
|
|
case paragraph
|
|
case keyVocabHeader = "key_vocab_header"
|
|
case vocabTable = "vocab_table"
|
|
case exercise
|
|
}
|
|
|
|
public var id: String { "\(kind.rawValue):\(index)" }
|
|
public var index: Int
|
|
public var kind: Kind
|
|
|
|
// heading
|
|
public var level: Int?
|
|
// heading / paragraph
|
|
public var text: String?
|
|
|
|
// vocab_table
|
|
public var sourceImage: String?
|
|
public var ocrLines: [String]?
|
|
public var ocrConfidence: Double?
|
|
public var cards: [TextbookVocabPair]?
|
|
|
|
// exercise
|
|
public var exerciseId: String?
|
|
public var instruction: String?
|
|
public var extra: [String]?
|
|
public var prompts: [String]?
|
|
public var answerItems: [TextbookAnswerItem]?
|
|
public var freeform: Bool?
|
|
}
|
|
|
|
public struct TextbookVocabPair: Codable, Sendable {
|
|
public var front: String
|
|
public var back: String
|
|
}
|
|
|
|
public struct TextbookAnswerItem: Codable, Sendable {
|
|
public var label: String? // A/B/C subpart label or nil
|
|
public var number: Int
|
|
public var answer: String
|
|
public var alternates: [String]
|
|
}
|