spb/focale Public
Swift 100%
1//2// SemanticIndexer.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Stage 2 — Foundation Models, on candidates only (CLAUDE.md §5).9// The math that rules everything: 40 000 photos × 1–3 s = 11–33 h, serial.10// So this actor runs one inference at a time, only on photos that earn it.11//12// Note: direct image input in the prompt arrives with the iOS 27 SDK;13// until then stage 2 reasons over stage-1 signals + capture context,14// which is already rich for Focale-taken photos.15//1617import Foundation18import FoundationModels1920/// Why a photo earned a stage-2 pass. Anything else stays Vision-only.21enum SemanticCandidateReason: Sendable {22 case capturedInFocale // context already present: minimal cost, maximal value23 case userRequested // the user opened the photo and asked24 case searchDeepening // stage-1 search found nothing; deepen a small subset25 case markedImportant // favorite, shared, frequently opened26}2728/// Inputs handed to the model — stage-1 signals, never the photo library.29struct SemanticCandidate: Sendable {30 var localIdentifier: String31 var ocrExcerpt: String?32 var classificationLabels: [String]33 var contextJSON: String?34}3536actor SemanticIndexer {3738 static var isModelAvailable: Bool {39 SystemLanguageModel.default.availability == .available40 }4142 /// Serialized on the Neural Engine anyway (CLAUDE.md §2) — one session,43 /// one inference at a time.44 private var session: LanguageModelSession?4546 /// Returns nil on guardrail refusal — a normal state, no error surfaced47 /// (CLAUDE.md §8). The photo stays indexed by stage 1.48 func analyze(_ candidate: SemanticCandidate) async -> PhotoSemantics? {49 guard Self.isModelAvailable else { return nil }5051 let session = self.session ?? LanguageModelSession(instructions: """52 Tu analyses les signaux extraits d'une photo pour la rendre cherchable.53 Réponds uniquement à partir des signaux fournis. Sur incertitude, \54 reste vague plutôt que d'inventer. Ne devine jamais un chiffre, \55 un montant ou une date : ils viennent de l'OCR, pas de toi.56 """)57 self.session = session5859 var prompt = "Signaux de la photo :\n"60 if let ocr = candidate.ocrExcerpt, !ocr.isEmpty {61 prompt += "Texte OCR (données réelles) :\n\(String(ocr.prefix(1200)))\n"62 }63 if !candidate.classificationLabels.isEmpty {64 prompt += "Classification : \(candidate.classificationLabels.joined(separator: ", "))\n"65 }66 if let context = candidate.contextJSON {67 prompt += "Contexte de capture : \(context)\n"68 }6970 do {71 let response = try await session.respond(72 to: prompt,73 generating: PhotoSemantics.self74 )75 return response.content76 } catch {77 // Guardrail refusal or transient failure: treat as normal,78 // reset the session so one bad turn doesn't poison the next.79 self.session = nil80 return nil81 }82 }83}84