SPB Git

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%
5.0 KB · 131 lines swift
Raw Blame History
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