// // SemanticIndexer.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Stage 2 — Foundation Models, on candidates only (CLAUDE.md §5). // The math that rules everything: 40 000 photos × 1–3 s = 11–33 h, serial. // So this actor runs one inference at a time, only on photos that earn it. // // Note: direct image input in the prompt arrives with the iOS 27 SDK; // until then stage 2 reasons over stage-1 signals + capture context, // which is already rich for Focale-taken photos. // import Foundation import FoundationModels /// Why a photo earned a stage-2 pass. Anything else stays Vision-only. enum SemanticCandidateReason: Sendable { case capturedInFocale // context already present: minimal cost, maximal value case userRequested // the user opened the photo and asked case searchDeepening // stage-1 search found nothing; deepen a small subset case markedImportant // favorite, shared, frequently opened } /// Inputs handed to the model — stage-1 signals, never the photo library. struct SemanticCandidate: Sendable { var localIdentifier: String var ocrExcerpt: String? var classificationLabels: [String] var contextJSON: String? } actor SemanticIndexer { static var isModelAvailable: Bool { SystemLanguageModel.default.availability == .available } /// Serialized on the Neural Engine anyway (CLAUDE.md §2) — one session, /// one inference at a time. private var session: LanguageModelSession? /// Returns nil on guardrail refusal — a normal state, no error surfaced /// (CLAUDE.md §8). The photo stays indexed by stage 1. func analyze(_ candidate: SemanticCandidate) async -> PhotoSemantics? { guard Self.isModelAvailable else { return nil } let session = self.session ?? LanguageModelSession(instructions: """ Tu analyses les signaux extraits d'une photo pour la rendre cherchable. Réponds uniquement à partir des signaux fournis. Sur incertitude, \ reste vague plutôt que d'inventer. Ne devine jamais un chiffre, \ un montant ou une date : ils viennent de l'OCR, pas de toi. """) self.session = session var prompt = "Signaux de la photo :\n" if let ocr = candidate.ocrExcerpt, !ocr.isEmpty { prompt += "Texte OCR (données réelles) :\n\(String(ocr.prefix(1200)))\n" } if !candidate.classificationLabels.isEmpty { prompt += "Classification : \(candidate.classificationLabels.joined(separator: ", "))\n" } if let context = candidate.contextJSON { prompt += "Contexte de capture : \(context)\n" } do { let response = try await session.respond( to: prompt, generating: PhotoSemantics.self ) return response.content } catch { // Guardrail refusal or transient failure: treat as normal, // reset the session so one bad turn doesn't poison the next. self.session = nil return nil } } }