spb/focale Public
Swift 100%
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