spb/focale Public
Swift 100%
1//2// IndexPipeline.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Walks the system photo library newest → oldest (people search the last9// two years), runs stage 1 on everything, stage 2 on candidates only,10// and checkpoints so an interruption never restarts from zero.11//1213import CoreGraphics14import Foundation15import Photos16import SwiftData17import UIKit1819struct IndexProgress: Sendable {20 var indexed: Int21 var total: Int2223 /// Honest progress line for the UI (CLAUDE.md §5), in French.24 var statusLine: String {25 guard total > 0 else { return "Photothèque vide" }26 if indexed >= total { return "\(total) photos indexées" }27 return "\(indexed.formatted()) / \(total.formatted()) photos indexées — les plus récentes sont déjà cherchables"28 }29}3031actor IndexPipeline {3233 private let container: ModelContainer34 private let vision = VisionIndexer()35 private let semantic = SemanticIndexer()36 private var isSuspendedForCapture = false37 private var isBackfilling = false38 private var isForegroundIndexing = false3940 init(container: ModelContainer) {41 self.container = container42 }4344 // MARK: - Capture priority (CLAUDE.md §9: viewfinder first, always)4546 func suspendForCapture() {47 isSuspendedForCapture = true48 }4950 func resumeAfterCapture() {51 isSuspendedForCapture = false52 }5354 // MARK: - Photos taken in Focale: already understood5556 /// Zero-inference ingestion: the context alone makes the photo findable.57 /// Stage 1 + stage 2 follow immediately — a fresh Focale photo is the58 /// best candidate there is (context present, cost minimal).59 func ingestCapturedPhoto(localIdentifier: String, context: CaptureContext) async {60 let modelContext = ModelContext(container)61 let record = PhotoRecord(localIdentifier: localIdentifier, captureDate: .now)62 record.captureContext = context63 modelContext.insert(record)64 try? modelContext.save()6566 await indexAsset(localIdentifier: localIdentifier, runSemantic: true)67 }6869 func updateSubjectHint(localIdentifier: String, hint: String) {70 let modelContext = ModelContext(container)71 guard let record = fetchRecord(localIdentifier, in: modelContext) else { return }72 record.subjectHint = hint73 if var context = record.captureContext {74 context.subjectHint = hint75 record.captureContext = context76 }77 try? modelContext.save()78 }7980 // MARK: - Progress (honest, always)8182 func progress() -> IndexProgress {83 let total = PHAsset.fetchAssets(with: .image, options: nil).count84 let modelContext = ModelContext(container)85 let indexed = (try? modelContext.fetchCount(86 FetchDescriptor<PhotoRecord>(predicate: #Predicate { $0.visionIndexedAt != nil })87 )) ?? 088 return IndexProgress(indexed: indexed, total: total)89 }9091 /// Opportunistic stage-1 pass over the most recent photos while the app92 /// is open — search must be useful immediately, not after the first93 /// night on the charger (CLAUDE.md §5). Yields to the viewfinder and94 /// backs off on heat; the deep backfill stays with BGProcessingTask.95 func indexRecentInForeground(limit: Int = 400) async {96 guard !isForegroundIndexing else { return }97 isForegroundIndexing = true98 defer { isForegroundIndexing = false }99100 let options = PHFetchOptions()101 options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]102 options.fetchLimit = limit103 let assets = PHAsset.fetchAssets(with: .image, options: options)104105 for index in 0..<assets.count {106 guard !isSuspendedForCapture, !Task.isCancelled else { return }107 let thermal = ProcessInfo.processInfo.thermalState108 guard thermal == .nominal || thermal == .fair else { return }109 await indexAsset(asset: assets.object(at: index), runSemantic: false)110 await Task.yield()111 }112 }113114 // MARK: - Backfill (newest → oldest, resumable)115116 /// Runs until done, cancelled, or `shouldContinue` says stop117 /// (BGTask expiration, thermal state, capture opening).118 func backfill(shouldContinue: @escaping @Sendable () -> Bool) async {119 guard !isBackfilling else { return }120 isBackfilling = true121 defer { isBackfilling = false }122123 let options = PHFetchOptions()124 options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]125 if let checkpoint = loadCheckpointDate() {126 options.predicate = NSPredicate(format: "creationDate < %@", checkpoint as NSDate)127 }128 let assets = PHAsset.fetchAssets(with: .image, options: options)129130 for index in 0..<assets.count {131 guard shouldContinue(), !isSuspendedForCapture, !Task.isCancelled else { return }132 // Stage 2 suspends at .fair; stage 1 keeps going until .serious.133 if ProcessInfo.processInfo.thermalState == .serious ||134 ProcessInfo.processInfo.thermalState == .critical { return }135136 let asset = assets.object(at: index)137 await indexAsset(asset: asset, runSemantic: false)138 saveCheckpointDate(asset.creationDate)139 }140 }141142 // MARK: - Single-asset indexing143144 func indexAsset(localIdentifier: String, runSemantic: Bool) async {145 let fetched = PHAsset.fetchAssets(146 withLocalIdentifiers: [localIdentifier], options: nil147 )148 guard let asset = fetched.firstObject else { return }149 await indexAsset(asset: asset, runSemantic: runSemantic)150 }151152 private func indexAsset(asset: PHAsset, runSemantic: Bool) async {153 let modelContext = ModelContext(container)154 let identifier = asset.localIdentifier155 let record = fetchRecord(identifier, in: modelContext)156 ?? {157 let new = PhotoRecord(localIdentifier: identifier, captureDate: asset.creationDate)158 modelContext.insert(new)159 return new160 }()161162 if record.visionIndexedAt == nil {163 guard let image = await requestThumbnail(for: asset) else {164 try? modelContext.save()165 return166 }167 guard let signals = try? await vision.index(image) else {168 try? modelContext.save()169 return170 }171 record.ocrText = signals.ocrText172 record.featurePrint = signals.featurePrint173 record.classificationLabels = signals.classificationLabels174 record.faceCount = signals.faceCount175 record.visionIndexedAt = .now176 record.isFavorite = asset.isFavorite177 try? modelContext.save()178 }179180 // Stage 2 gate: candidates only, and never past .fair thermal state.181 let wantsSemantic = runSemantic || record.isFavorite182 if wantsSemantic,183 record.semanticIndexedAt == nil, !record.semanticRefused,184 ProcessInfo.processInfo.thermalState == .nominal185 || ProcessInfo.processInfo.thermalState == .fair {186 await runSemanticStage(on: record, in: modelContext)187 }188 }189190 /// Stage 2 on an explicit candidate set (user request, search deepening).191 func deepen(localIdentifiers: [String], reason: SemanticCandidateReason) async {192 let modelContext = ModelContext(container)193 for identifier in localIdentifiers {194 guard !isSuspendedForCapture else { return }195 guard let record = fetchRecord(identifier, in: modelContext),196 record.semanticIndexedAt == nil, !record.semanticRefused197 else { continue }198 await runSemanticStage(on: record, in: modelContext)199 }200 }201202 private func runSemanticStage(on record: PhotoRecord, in modelContext: ModelContext) async {203 let candidate = SemanticCandidate(204 localIdentifier: record.localIdentifier,205 ocrExcerpt: record.ocrText,206 classificationLabels: record.classificationLabels,207 contextJSON: record.captureContext?.metadataJSON()208 )209 if let semantics = await semantic.analyze(candidate) {210 record.gist = semantics.gist211 record.kindRawValue = semantics.kind.rawValue212 record.entities = semantics.entities213 record.hasActionableInfo = semantics.hasActionableInfo214 record.semanticIndexedAt = .now215 } else if SemanticIndexer.isModelAvailable {216 record.semanticRefused = true // normal state, not an error217 }218 try? modelContext.save()219 }220221 // MARK: - Helpers222223 private func fetchRecord(224 _ localIdentifier: String, in modelContext: ModelContext225 ) -> PhotoRecord? {226 var descriptor = FetchDescriptor<PhotoRecord>(227 predicate: #Predicate { $0.localIdentifier == localIdentifier }228 )229 descriptor.fetchLimit = 1230 return try? modelContext.fetch(descriptor).first231 }232233 /// Bridges PHImageManager's may-fire-twice handler to a single resume.234 /// @unchecked: the handler is delivered serially on the main queue.235 private final class ResumeFlag: @unchecked Sendable {236 var resumed = false237 }238239 /// Thumbnails come from PhotoKit, never stored twice (CLAUDE.md §9).240 /// The result handler is @Sendable so it carries no actor isolation —241 /// PhotoKit calls it on the main queue, not on this actor.242 private func requestThumbnail(for asset: PHAsset, size: CGFloat = 1024) async -> CGImage? {243 let options = PHImageRequestOptions()244 options.deliveryMode = .highQualityFormat245 options.isNetworkAccessAllowed = true // the user's own iCloud photo, inbound only246 options.isSynchronous = false247 let flag = ResumeFlag()248 return await withCheckedContinuation { continuation in249 let handler: @Sendable (UIImage?, [AnyHashable: Any]?) -> Void = { image, info in250 let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false251 guard !degraded, !flag.resumed else { return }252 flag.resumed = true253 continuation.resume(returning: image?.cgImage)254 }255 PHImageManager.default().requestImage(256 for: asset,257 targetSize: CGSize(width: size, height: size),258 contentMode: .aspectFit,259 options: options,260 resultHandler: handler261 )262 }263 }264265 private func loadCheckpointDate() -> Date? {266 let modelContext = ModelContext(container)267 return (try? modelContext.fetch(FetchDescriptor<IndexCheckpoint>()))?268 .first?.oldestIndexedDate269 }270271 private func saveCheckpointDate(_ date: Date?) {272 guard let date else { return }273 let modelContext = ModelContext(container)274 if let checkpoint = (try? modelContext.fetch(FetchDescriptor<IndexCheckpoint>()))?.first {275 checkpoint.oldestIndexedDate = date276 checkpoint.updatedAt = .now277 } else {278 modelContext.insert(IndexCheckpoint(oldestIndexedDate: date))279 }280 try? modelContext.save()281 }282}283