// // ReaderModel.swift // Prisme // // Author: Simon-Pierre Boucher // import Foundation import Observation /// Detail level of the semantic zoom (CLAUDE.md §5, the signature feature). /// Pinching moves along this scale; spreading past `.full` returns to the /// raw page — one gesture always brings the real page back (§7). enum ReaderLevel: Int, CaseIterable, Comparable { case full = 0 case condensed case outline case gist var label: String { switch self { case .full: "Texte" case .condensed: "Sections" case .outline: "Plan" case .gist: "Essentiel" } } static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } } /// A run of blocks under one heading. struct ReaderSection: Identifiable { let id: Int let title: String /// Heading depth (1–6); 0 for the leading section before any heading. let level: Int let blockRange: Range /// Deterministic summary (first sentence of the first paragraph) — /// tier `none`, used whenever the model has nothing better. let fallbackSummary: String } /// State behind the reader. Extraction is deterministic and instant (no AI); /// the digest enriches summaries and the gist when the local model delivers. @MainActor @Observable final class ReaderModel { let tab: Tab let intelligence: IntelligenceCenter private(set) var title: String = "" private(set) var blocks: [ContentBlock] = [] private(set) var sections: [ReaderSection] = [] private(set) var extractionFailed = false var level: ReaderLevel = .full init(tab: Tab, intelligence: IntelligenceCenter) { self.tab = tab self.intelligence = intelligence } var digest: PageDigest? { if case .ready(let digest, _) = intelligence.digestState(for: tab) { return digest } return nil } /// Blocks the digest was computed on (budgeted subset). private var digestBlocks: [ContentBlock]? { if case .ready(_, let kept) = intelligence.digestState(for: tab) { return kept } return nil } func load() async { do { let content = try await tab.page.extractContent() title = content.title blocks = content.blocks sections = Self.groupSections(content.blocks, pageTitle: content.title) extractionFailed = content.blocks.isEmpty // Enrichment is optional and arrives when it arrives. intelligence.requestDigest(for: tab) } catch { extractionFailed = true } } /// Summary for a section: the model's if it maps to this section /// (matched through DOM paths, never guessed), else the deterministic /// fallback. `generated` drives the distinct visual treatment (§7). func summary(for section: ReaderSection) -> (text: String, generated: Bool) { if let digest, let kept = digestBlocks { for digestSection in digest.outline { guard kept.indices.contains(digestSection.sourceBlock) else { continue } let path = kept[digestSection.sourceBlock].domPath if let index = blocks.firstIndex(where: { $0.domPath == path }), section.blockRange.contains(index) { return (digestSection.summary, true) } } } return (section.fallbackSummary, false) } var gist: (text: String, generated: Bool) { if let digest { return (digest.gist, true) } if let first = sections.first, !first.fallbackSummary.isEmpty { return (first.fallbackSummary, false) } return (title, false) } // MARK: - Grouping private static func groupSections(_ blocks: [ContentBlock], pageTitle: String) -> [ReaderSection] { var sections: [ReaderSection] = [] var start = 0 var currentTitle = pageTitle var currentLevel = 0 func close(at end: Int) { guard end > start else { return } let range = start..) -> String { guard let paragraph = slice.first(where: { $0.kind == .paragraph })?.text else { return "" } if let end = paragraph.firstIndex(where: { ".!?".contains($0) }) { return String(paragraph[...end]) } return paragraph } }