SPB Git

spb/focale Public

Swift 100%
11.1 KB · 283 lines swift
Raw Blame History
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