Files
Spanish/Conjuga/SharedModels/Sources/SharedModels/TextbookChapter.swift
T
Trey T 63dfc5e41a Add textbook reader, exercise grading, stem-change toggle, extraction pipeline
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>
2026-04-19 15:12:55 -05:00

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]
}