// // SearchEngine.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Executes a SearchFilter against the local index and ranks results: // OCR match, feature-print distance, capture context, recency // (CLAUDE.md ยง7). Foundation Models only ever intervenes at the very // end, on a small candidate set โ€” and that happens in IndexPipeline, // not here. // import Foundation import SwiftData struct SearchResult: Sendable, Identifiable { let localIdentifier: String let score: Double /// Generated gist, shown with a GeneratedBadge โ€” never as a real datum. let gist: String? let ocrMatched: Bool var id: String { localIdentifier } } actor SearchEngine { private let container: ModelContainer init(container: ModelContainer) { self.container = container } func search(_ filter: SearchFilter, limit: Int = 120) -> [SearchResult] { guard !filter.isEmpty else { return [] } let modelContext = ModelContext(container) // Cheap structured predicate first (dates, favorites), then // in-memory folding for French text matching. var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.captureDate, order: .reverse)] ) if let range = filter.dateRange { let start = range.lowerBound let end = range.upperBound descriptor.predicate = #Predicate { $0.captureDate != nil && $0.captureDate! >= start && $0.captureDate! <= end } } guard let records = try? modelContext.fetch(descriptor) else { return [] } let referencePrint: Data? = filter.similarToIdentifier.flatMap { identifier in records.first { $0.localIdentifier == identifier }?.featurePrint } var results: [SearchResult] = [] results.reserveCapacity(min(records.count, limit * 4)) for record in records { if filter.favoritesOnly && !record.isFavorite { continue } if filter.actionableOnly && !record.hasActionableInfo { continue } if !filter.kinds.isEmpty { guard let kind = record.kindRawValue, filter.kinds.contains(kind) else { continue } } if let score = score(record, filter: filter, referencePrint: referencePrint) { results.append(score) } } return Array(results.sorted { $0.score > $1.score }.prefix(limit)) } /// Returns nil when the record doesn't match at all. private func score( _ record: PhotoRecord, filter: SearchFilter, referencePrint: Data? ) -> SearchResult? { var score = 0.0 var matchedAnyTerm = false var ocrMatched = false let ocr = record.ocrText?.searchFolded ?? "" let entityHaystack = ( record.entities + record.classificationLabels + [record.subjectHint ?? "", record.gist ?? ""] ).joined(separator: " ").searchFolded for term in Set(filter.textTerms.map(\.searchFolded)) where !term.isEmpty { if ocr.contains(term) { score += 3.0 // OCR is the strongest signal โ€” real data matchedAnyTerm = true ocrMatched = true } } for term in Set(filter.entityTerms.map(\.searchFolded)) where !term.isEmpty { if entityHaystack.contains(term) { score += 2.0 matchedAnyTerm = true } } for term in filter.placeTerms.map(\.searchFolded) where !term.isEmpty { let place = "\(record.placeName ?? "") \(record.placeLocality ?? "")".searchFolded if place.contains(term) { score += 2.0 matchedAnyTerm = true } } if let project = filter.projectTerm?.searchFolded, !project.isEmpty { if (record.projectName ?? "").searchFolded.contains(project) { score += 2.5 // declared context beats inference matchedAnyTerm = true } else if filter.textTerms.isEmpty && filter.entityTerms.isEmpty { return nil // project-only query: hard filter } } if let reference = referencePrint, let print = record.featurePrint, let distance = VisionIndexer.distance(reference, print) { // Closer = higher; typical feature print distances land in 0...2. score += Double(max(0, 2.0 - distance)) matchedAnyTerm = true } let hasTermCriteria = !filter.textTerms.isEmpty || !filter.entityTerms.isEmpty || !filter.placeTerms.isEmpty || filter.projectTerm != nil || filter.similarToIdentifier != nil if hasTermCriteria && !matchedAnyTerm { return nil } // Recency: gentle boost, people search the last two years first. if let date = record.captureDate { let days = max(0, Date.now.timeIntervalSince(date) / 86_400) score += 1.0 / (1.0 + days / 365.0) } if record.hasActionableInfo { score += 0.25 } return SearchResult( localIdentifier: record.localIdentifier, score: score, gist: record.gist, ocrMatched: ocrMatched ) } }