spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// Distiller.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import Foundation9import FoundationModels1011/// HTML → compact structure (CLAUDE.md §4, the heart of the project).12/// Steps 1–2 (extraction, typed blocks) happen in the page via extractor.js.13/// This actor performs step 3 (token budgeting), a deterministic form of14/// step 4 (condensation), and step 5 (guided generation). Hierarchical15/// LLM condensation of low-priority sections comes later — the budgeter16/// currently trims deterministically, never mid-sentence.17actor Distiller {18 enum DistillerError: LocalizedError {19 case emptyPage20 case overBudget2122 var errorDescription: String? {23 switch self {24 case .emptyPage: "La page ne contient pas assez de texte lisible."25 case .overBudget: "La page dépasse la capacité du modèle local."26 }27 }28 }2930 private let cache = DigestCache()3132 /// Fraction of the context window reserved for the model's response —33 /// a prompt that fills the window leaves no room to answer (§2).34 private static let responseReserve = 0.303536 func digest(37 _ content: ExtractedContent,38 queue: InferenceQueue,39 priority: InferencePriority40 ) async throws -> (PageDigest, [ContentBlock]) {41 guard !content.blocks.isEmpty else { throw DistillerError.emptyPage }4243 let key = DigestCache.key(for: content)44 if let hit = await cache.lookup(key) {45 return hit46 }4748 let model = SystemLanguageModel.default49 let instructions = """50 You are the reading engine of a browser. You receive a numbered list \51 of text blocks extracted from one web page. Produce the requested \52 structured digest using ONLY the provided text — never outside \53 knowledge, never invented facts. If the page does not support a \54 field, keep it minimal. Write summaries in the language of the page. \55 Every sourceBlock must be the index of a block from the list.56 """5758 let (prompt, keptBlocks) = try await budgetedPrompt(59 content: content,60 model: model,61 instructionsCost: try await model.tokenCount(for: instructions)62 )6364 let digest = try await queue.run(priority) {65 let session = LanguageModelSession(model: model, instructions: instructions)66 let response = try await session.respond(to: prompt, generating: PageDigest.self)67 return response.content68 }6970 await cache.store(digest, blocks: keptBlocks, key: key)71 return (digest, keptBlocks)72 }7374 /// Step 3: measure and allocate. Headings are always kept (they are the75 /// skeleton); body blocks are added by document order until the budget76 /// is spent, long blocks trimmed at a sentence boundary.77 private func budgetedPrompt(78 content: ExtractedContent,79 model: SystemLanguageModel,80 instructionsCost: Int81 ) async throws -> (String, [ContentBlock]) {82 let contextSize = try await model.contextSize83 let budget = Int(Double(contextSize) * (1 - Self.responseReserve)) - instructionsCost - 20084 guard budget > 300 else { throw DistillerError.overBudget }8586 var lines: [String] = ["Page title: \(content.title)", "Blocks:"]87 var kept: [ContentBlock] = []88 var spent = try await model.tokenCount(for: lines.joined(separator: "\n"))8990 for block in content.blocks {91 var text = block.text92 if text.count > 600 {93 text = trimToSentence(text, limit: 600)94 }95 let line = "[\(kept.count)] (\(block.kind.rawValue)) \(text)"96 let cost = try await model.tokenCount(for: line)97 if spent + cost > budget {98 if block.kind == .heading {99 // Headings squeeze in with a shorter form when possible.100 let short = "[\(kept.count)] (heading) \(String(text.prefix(80)))"101 let shortCost = try await model.tokenCount(for: short)102 guard spent + shortCost <= budget else { break }103 lines.append(short)104 kept.append(block)105 spent += shortCost106 continue107 }108 continue109 }110 lines.append(line)111 kept.append(block)112 spent += cost113 }114115 guard !kept.isEmpty else { throw DistillerError.overBudget }116 return (lines.joined(separator: "\n"), kept)117 }118119 private func trimToSentence(_ text: String, limit: Int) -> String {120 guard text.count > limit else { return text }121 let head = String(text.prefix(limit))122 if let cut = head.lastIndex(where: { ".!?".contains($0) }) {123 return String(head[...cut])124 }125 if let space = head.lastIndex(of: " ") {126 return String(head[..<space]) + "…"127 }128 return head + "…"129 }130}131