SPB Git

spb/focale Public

Swift 100%
5.3 KB · 147 lines swift
Raw Blame History
1//2//  SearchEngine.swift3//  Focale4//5//  Author: Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//8//  Executes a SearchFilter against the local index and ranks results:9//  OCR match, feature-print distance, capture context, recency10//  (CLAUDE.md §7). Foundation Models only ever intervenes at the very11//  end, on a small candidate set — and that happens in IndexPipeline,12//  not here.13//1415import Foundation16import SwiftData1718struct SearchResult: Sendable, Identifiable {19    let localIdentifier: String20    let score: Double21    /// Generated gist, shown with a GeneratedBadge — never as a real datum.22    let gist: String?23    let ocrMatched: Bool2425    var id: String { localIdentifier }26}2728actor SearchEngine {2930    private let container: ModelContainer3132    init(container: ModelContainer) {33        self.container = container34    }3536    func search(_ filter: SearchFilter, limit: Int = 120) -> [SearchResult] {37        guard !filter.isEmpty else { return [] }38        let modelContext = ModelContext(container)3940        // Cheap structured predicate first (dates, favorites), then41        // in-memory folding for French text matching.42        var descriptor = FetchDescriptor<PhotoRecord>(43            sortBy: [SortDescriptor(\.captureDate, order: .reverse)]44        )45        if let range = filter.dateRange {46            let start = range.lowerBound47            let end = range.upperBound48            descriptor.predicate = #Predicate {49                $0.captureDate != nil && $0.captureDate! >= start && $0.captureDate! <= end50            }51        }52        guard let records = try? modelContext.fetch(descriptor) else { return [] }5354        let referencePrint: Data? = filter.similarToIdentifier.flatMap { identifier in55            records.first { $0.localIdentifier == identifier }?.featurePrint56        }5758        var results: [SearchResult] = []59        results.reserveCapacity(min(records.count, limit * 4))6061        for record in records {62            if filter.favoritesOnly && !record.isFavorite { continue }63            if filter.actionableOnly && !record.hasActionableInfo { continue }64            if !filter.kinds.isEmpty {65                guard let kind = record.kindRawValue, filter.kinds.contains(kind) else { continue }66            }67            if let score = score(record, filter: filter, referencePrint: referencePrint) {68                results.append(score)69            }70        }7172        return Array(results.sorted { $0.score > $1.score }.prefix(limit))73    }7475    /// Returns nil when the record doesn't match at all.76    private func score(77        _ record: PhotoRecord,78        filter: SearchFilter,79        referencePrint: Data?80    ) -> SearchResult? {81        var score = 0.082        var matchedAnyTerm = false83        var ocrMatched = false8485        let ocr = record.ocrText?.searchFolded ?? ""86        let entityHaystack = (87            record.entities + record.classificationLabels88            + [record.subjectHint ?? "", record.gist ?? ""]89        ).joined(separator: " ").searchFolded9091        for term in Set(filter.textTerms.map(\.searchFolded)) where !term.isEmpty {92            if ocr.contains(term) {93                score += 3.0            // OCR is the strongest signal — real data94                matchedAnyTerm = true95                ocrMatched = true96            }97        }98        for term in Set(filter.entityTerms.map(\.searchFolded)) where !term.isEmpty {99            if entityHaystack.contains(term) {100                score += 2.0101                matchedAnyTerm = true102            }103        }104        for term in filter.placeTerms.map(\.searchFolded) where !term.isEmpty {105            let place = "\(record.placeName ?? "") \(record.placeLocality ?? "")".searchFolded106            if place.contains(term) {107                score += 2.0108                matchedAnyTerm = true109            }110        }111        if let project = filter.projectTerm?.searchFolded, !project.isEmpty {112            if (record.projectName ?? "").searchFolded.contains(project) {113                score += 2.5            // declared context beats inference114                matchedAnyTerm = true115            } else if filter.textTerms.isEmpty && filter.entityTerms.isEmpty {116                return nil              // project-only query: hard filter117            }118        }119120        if let reference = referencePrint, let print = record.featurePrint,121           let distance = VisionIndexer.distance(reference, print) {122            // Closer = higher; typical feature print distances land in 0...2.123            score += Double(max(0, 2.0 - distance))124            matchedAnyTerm = true125        }126127        let hasTermCriteria = !filter.textTerms.isEmpty || !filter.entityTerms.isEmpty128            || !filter.placeTerms.isEmpty || filter.projectTerm != nil129            || filter.similarToIdentifier != nil130        if hasTermCriteria && !matchedAnyTerm { return nil }131132        // Recency: gentle boost, people search the last two years first.133        if let date = record.captureDate {134            let days = max(0, Date.now.timeIntervalSince(date) / 86_400)135            score += 1.0 / (1.0 + days / 365.0)136        }137        if record.hasActionableInfo { score += 0.25 }138139        return SearchResult(140            localIdentifier: record.localIdentifier,141            score: score,142            gist: record.gist,143            ocrMatched: ocrMatched144        )145    }146}147