spb/focale Public
Swift 100%
1//2// QueryAnalyzer.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Natural language → SearchFilter. The model gets a small prompt9// (trivial cost) and only the query — never photo lists (CLAUDE.md §5).10// The keyword fallback is a real product path, not an apology: it's11// the whole search story on non-A17 Pro devices.12//1314import Foundation15import FoundationModels1617/// Model-facing mirror of SearchFilter. Dates as ISO strings — date math18/// stays deterministic, out of the model's hands.19@Generable20struct ParsedQuery {21 @Guide(description: "Date de début ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide")22 let startDate: String2324 @Guide(description: "Date de fin ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide")25 let endDate: String2627 @Guide(description: "Types de contenu recherchés parmi: document, receipt, person, place, object, screen, food, animal", .count(0...3))28 let kinds: [String]2930 @Guide(description: "Mots à chercher dans le texte visible des photos (OCR)", .count(0...5))31 let textTerms: [String]3233 @Guide(description: "Sujets ou objets recherchés dans les images", .count(0...5))34 let entityTerms: [String]3536 @Guide(description: "Lieux mentionnés dans la requête", .count(0...3))37 let placeTerms: [String]3839 @Guide(description: "Nom de projet mentionné (chantier, voyage, inventaire...), sinon vide")40 let projectTerm: String41}4243struct QueryAnalyzer: Sendable {4445 /// `now` injected for testability of relative dates ("l'automne passé").46 func analyze(_ query: String, now: Date = .now) async -> SearchFilter {47 let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)48 guard !trimmed.isEmpty else { return .empty }4950 if SystemLanguageModel.default.availability == .available,51 let parsed = await analyzeWithModel(trimmed, now: now) {52 return parsed53 }54 return KeywordQueryParser.parse(trimmed, now: now)55 }5657 private func analyzeWithModel(_ query: String, now: Date) async -> SearchFilter? {58 let formatter = ISO8601DateFormatter()59 formatter.formatOptions = [.withFullDate]60 let today = formatter.string(from: now)6162 let session = LanguageModelSession(instructions: """63 Tu convertis une requête de recherche de photos en filtre structuré.64 Nous sommes le \(today). Résous les périodes relatives \65 (« l'automne passé », « la semaine passée ») en dates absolues.66 N'invente aucun terme absent de la requête.67 """)68 do {69 let response = try await session.respond(70 to: "Requête : \(query)",71 generating: ParsedQuery.self72 )73 return makeFilter(from: response.content)74 } catch {75 return nil // fall back to the deterministic parser76 }77 }7879 private func makeFilter(from parsed: ParsedQuery) -> SearchFilter {80 let formatter = ISO8601DateFormatter()81 formatter.formatOptions = [.withFullDate]8283 var dateRange: ClosedRange<Date>?84 if let start = formatter.date(from: parsed.startDate),85 let end = formatter.date(from: parsed.endDate),86 start <= end {87 dateRange = start...end.addingTimeInterval(86_400) // inclusive end day88 }8990 var filter = SearchFilter.empty91 filter.dateRange = dateRange92 filter.kinds = parsed.kinds.filter { PhotoKind(rawValue: $0) != nil }93 filter.textTerms = parsed.textTerms94 filter.entityTerms = parsed.entityTerms95 filter.placeTerms = parsed.placeTerms96 filter.projectTerm = parsed.projectTerm.isEmpty ? nil : parsed.projectTerm97 return filter98 }99}100101/// Deterministic French keyword parser — the Vision-only path (CLAUDE.md §10),102/// and the safety net when the model declines.103enum KeywordQueryParser {104105 private static let kindKeywords: [String: PhotoKind] = [106 "recu": .receipt, "reçu": .receipt, "facture": .receipt,107 "document": .document, "papier": .document, "contrat": .document,108 "capture": .screen, "ecran": .screen, "écran": .screen,109 "bouffe": .food, "repas": .food, "restaurant": .food,110 "chien": .animal, "chat": .animal, "animal": .animal,111 ]112113 private static let stopWords: Set<String> = [114 "le", "la", "les", "de", "des", "du", "un", "une", "et", "ou",115 "avec", "sans", "dans", "sur", "mes", "mon", "ma", "photo", "photos",116 "que", "qui", "j'ai", "pris", "prise", "prises",117 ]118119 static func parse(_ query: String, now: Date = .now) -> SearchFilter {120 var filter = SearchFilter.empty121 let folded = query.searchFolded122123 filter.dateRange = parseDateRange(from: folded, now: now)124125 var terms: [String] = []126 for word in folded.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) {127 let token = String(word)128 if token.count < 3 || stopWords.contains(token) { continue }129 if let kind = kindKeywords[token] {130 if !filter.kinds.contains(kind.rawValue) { filter.kinds.append(kind.rawValue) }131 continue132 }133 terms.append(token)134 }135 // Every remaining term is worth trying against both OCR and entities.136 filter.textTerms = terms137 filter.entityTerms = terms138 return filter139 }140141 private static func parseDateRange(from folded: String, now: Date) -> ClosedRange<Date>? {142 let calendar = Calendar(identifier: .gregorian)143 let year = calendar.component(.year, from: now)144145 // Explicit year ("2026") in the query.146 for candidate in (year - 10)...year {147 if folded.contains(String(candidate)) {148 return calendar.yearRange(candidate)149 }150 }151152 if folded.contains("semaine passee") || folded.contains("semaine derniere") {153 let end = now154 let start = calendar.date(byAdding: .day, value: -14, to: end)!155 return start...end156 }157 if folded.contains("hier") {158 let start = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -1, to: now)!)159 return start...now160 }161 if folded.contains("mois passe") || folded.contains("mois dernier") {162 let start = calendar.date(byAdding: .month, value: -2, to: now)!163 return start...now164 }165166 let seasons: [(String, Int, Int)] = [ // (keyword, startMonth, endMonth)167 ("printemps", 3, 6), ("ete", 6, 9), ("automne", 9, 12), ("hiver", 12, 3),168 ]169 for (keyword, startMonth, endMonth) in seasons where folded.contains(keyword) {170 let isPast = folded.contains("passe") || folded.contains("dernier")171 let currentMonth = calendar.component(.month, from: now)172 var seasonYear = year173 if isPast && startMonth >= currentMonth { seasonYear -= 1 }174 let start = calendar.date(from: DateComponents(year: seasonYear, month: startMonth, day: 1))!175 let endYear = endMonth < startMonth ? seasonYear + 1 : seasonYear176 let end = calendar.date(from: DateComponents(year: endYear, month: endMonth, day: 21))!177 return start...end178 }179 return nil180 }181}182183private extension Calendar {184 func yearRange(_ year: Int) -> ClosedRange<Date>? {185 guard let start = date(from: DateComponents(year: year, month: 1, day: 1)),186 let end = date(from: DateComponents(year: year + 1, month: 1, day: 1))187 else { return nil }188 return start...end189 }190}191