// // PageContext.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Normalized, model-ready representation of a web page: clean markdown, key // metadata, the user's current selection, and a heading index for chunking and // citations. Produced by ContentExtractor from the injected AtlasExtractor.js; // chunked by Chunker for long pages / multi-tab reasoning. This is the ONLY // page representation the AI layer sees — WKWebView/DOM guts never leak past it. // import Foundation /// Extraction fidelity, so the UI can be honest when a page couldn't be read /// cleanly (docs/AI-BROWSER-RESEARCH.md §2: extraction failure is AI failure). enum ExtractionQuality: String, Codable { case reader // Readability main-content case rawText // visible structured-text fallback (app pages) } struct PageHeading: Codable, Hashable { let level: Int let title: String /// Character offset into `markdown`. let offset: Int } struct PageSelection: Codable, Hashable { let text: String /// Surrounding block text for grounding "explain this". let context: String? } struct PageContext: Codable, Hashable, Identifiable { var id: String { canonical.isEmpty ? url : canonical } let url: String let canonical: String let title: String let byline: String? let description: String? let siteName: String? let lang: String? let published: String? let favicon: String? /// Canonical model-facing representation. let markdown: String let quality: ExtractionQuality let headings: [PageHeading] let selection: PageSelection? let wordCount: Int let truncated: Bool /// Rough token estimate (chars/4) for the stuff-vs-map-reduce budget rule. var estimatedTokens: Int { markdown.count / 4 } private enum CodingKeys: String, CodingKey { case url, canonical, title, byline, description, siteName, lang case published, favicon, markdown, quality, headings, selection case wordCount, truncated } /// Full initializer (the Decodable init handles the JS bridge separately). init(url: String, canonical: String, title: String, byline: String? = nil, description: String? = nil, siteName: String? = nil, lang: String? = nil, published: String? = nil, favicon: String? = nil, markdown: String, quality: ExtractionQuality = .reader, headings: [PageHeading] = [], selection: PageSelection? = nil, wordCount: Int = 0, truncated: Bool = false) { self.url = url; self.canonical = canonical; self.title = title; self.byline = byline self.description = description; self.siteName = siteName; self.lang = lang self.published = published; self.favicon = favicon; self.markdown = markdown self.quality = quality; self.headings = headings; self.selection = selection self.wordCount = wordCount; self.truncated = truncated } /// Copies another context, replacing the markdown (used by the verifier to /// trim the sweep context so many model calls stay cheap but grounded). init(cloning other: PageContext, markdown: String) { self.init(url: other.url, canonical: other.canonical, title: other.title, byline: other.byline, description: other.description, siteName: other.siteName, lang: other.lang, published: other.published, favicon: other.favicon, markdown: markdown, quality: other.quality, headings: other.headings, selection: other.selection, wordCount: markdown.split(separator: " ").count, truncated: other.truncated) } /// A minimal baked context (verifier fallback if extraction fails entirely). init(bakedTitle: String, url: String, markdown: String) { self.init(url: url, canonical: url, title: bakedTitle, markdown: markdown, quality: .reader, wordCount: markdown.split(separator: " ").count) } // Tolerate a missing/unknown `quality` from JS by defaulting to rawText. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) url = try c.decode(String.self, forKey: .url) canonical = (try? c.decode(String.self, forKey: .canonical)) ?? url title = (try? c.decode(String.self, forKey: .title)) ?? "" byline = try? c.decodeIfPresent(String.self, forKey: .byline) description = try? c.decodeIfPresent(String.self, forKey: .description) siteName = try? c.decodeIfPresent(String.self, forKey: .siteName) lang = try? c.decodeIfPresent(String.self, forKey: .lang) published = try? c.decodeIfPresent(String.self, forKey: .published) favicon = try? c.decodeIfPresent(String.self, forKey: .favicon) markdown = (try? c.decode(String.self, forKey: .markdown)) ?? "" quality = ExtractionQuality( rawValue: (try? c.decode(String.self, forKey: .quality)) ?? "rawText" ) ?? .rawText headings = (try? c.decode([PageHeading].self, forKey: .headings)) ?? [] selection = try? c.decodeIfPresent(PageSelection.self, forKey: .selection) wordCount = (try? c.decode(Int.self, forKey: .wordCount)) ?? 0 truncated = (try? c.decode(Bool.self, forKey: .truncated)) ?? false } }