// // QueryAnalyzer.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Natural language → SearchFilter. The model gets a small prompt // (trivial cost) and only the query — never photo lists (CLAUDE.md §5). // The keyword fallback is a real product path, not an apology: it's // the whole search story on non-A17 Pro devices. // import Foundation import FoundationModels /// Model-facing mirror of SearchFilter. Dates as ISO strings — date math /// stays deterministic, out of the model's hands. @Generable struct ParsedQuery { @Guide(description: "Date de début ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide") let startDate: String @Guide(description: "Date de fin ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide") let endDate: String @Guide(description: "Types de contenu recherchés parmi: document, receipt, person, place, object, screen, food, animal", .count(0...3)) let kinds: [String] @Guide(description: "Mots à chercher dans le texte visible des photos (OCR)", .count(0...5)) let textTerms: [String] @Guide(description: "Sujets ou objets recherchés dans les images", .count(0...5)) let entityTerms: [String] @Guide(description: "Lieux mentionnés dans la requête", .count(0...3)) let placeTerms: [String] @Guide(description: "Nom de projet mentionné (chantier, voyage, inventaire...), sinon vide") let projectTerm: String } struct QueryAnalyzer: Sendable { /// `now` injected for testability of relative dates ("l'automne passé"). func analyze(_ query: String, now: Date = .now) async -> SearchFilter { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .empty } if SystemLanguageModel.default.availability == .available, let parsed = await analyzeWithModel(trimmed, now: now) { return parsed } return KeywordQueryParser.parse(trimmed, now: now) } private func analyzeWithModel(_ query: String, now: Date) async -> SearchFilter? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate] let today = formatter.string(from: now) let session = LanguageModelSession(instructions: """ Tu convertis une requête de recherche de photos en filtre structuré. Nous sommes le \(today). Résous les périodes relatives \ (« l'automne passé », « la semaine passée ») en dates absolues. N'invente aucun terme absent de la requête. """) do { let response = try await session.respond( to: "Requête : \(query)", generating: ParsedQuery.self ) return makeFilter(from: response.content) } catch { return nil // fall back to the deterministic parser } } private func makeFilter(from parsed: ParsedQuery) -> SearchFilter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate] var dateRange: ClosedRange? if let start = formatter.date(from: parsed.startDate), let end = formatter.date(from: parsed.endDate), start <= end { dateRange = start...end.addingTimeInterval(86_400) // inclusive end day } var filter = SearchFilter.empty filter.dateRange = dateRange filter.kinds = parsed.kinds.filter { PhotoKind(rawValue: $0) != nil } filter.textTerms = parsed.textTerms filter.entityTerms = parsed.entityTerms filter.placeTerms = parsed.placeTerms filter.projectTerm = parsed.projectTerm.isEmpty ? nil : parsed.projectTerm return filter } } /// Deterministic French keyword parser — the Vision-only path (CLAUDE.md §10), /// and the safety net when the model declines. enum KeywordQueryParser { private static let kindKeywords: [String: PhotoKind] = [ "recu": .receipt, "reçu": .receipt, "facture": .receipt, "document": .document, "papier": .document, "contrat": .document, "capture": .screen, "ecran": .screen, "écran": .screen, "bouffe": .food, "repas": .food, "restaurant": .food, "chien": .animal, "chat": .animal, "animal": .animal, ] private static let stopWords: Set = [ "le", "la", "les", "de", "des", "du", "un", "une", "et", "ou", "avec", "sans", "dans", "sur", "mes", "mon", "ma", "photo", "photos", "que", "qui", "j'ai", "pris", "prise", "prises", ] static func parse(_ query: String, now: Date = .now) -> SearchFilter { var filter = SearchFilter.empty let folded = query.searchFolded filter.dateRange = parseDateRange(from: folded, now: now) var terms: [String] = [] for word in folded.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) { let token = String(word) if token.count < 3 || stopWords.contains(token) { continue } if let kind = kindKeywords[token] { if !filter.kinds.contains(kind.rawValue) { filter.kinds.append(kind.rawValue) } continue } terms.append(token) } // Every remaining term is worth trying against both OCR and entities. filter.textTerms = terms filter.entityTerms = terms return filter } private static func parseDateRange(from folded: String, now: Date) -> ClosedRange? { let calendar = Calendar(identifier: .gregorian) let year = calendar.component(.year, from: now) // Explicit year ("2026") in the query. for candidate in (year - 10)...year { if folded.contains(String(candidate)) { return calendar.yearRange(candidate) } } if folded.contains("semaine passee") || folded.contains("semaine derniere") { let end = now let start = calendar.date(byAdding: .day, value: -14, to: end)! return start...end } if folded.contains("hier") { let start = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -1, to: now)!) return start...now } if folded.contains("mois passe") || folded.contains("mois dernier") { let start = calendar.date(byAdding: .month, value: -2, to: now)! return start...now } let seasons: [(String, Int, Int)] = [ // (keyword, startMonth, endMonth) ("printemps", 3, 6), ("ete", 6, 9), ("automne", 9, 12), ("hiver", 12, 3), ] for (keyword, startMonth, endMonth) in seasons where folded.contains(keyword) { let isPast = folded.contains("passe") || folded.contains("dernier") let currentMonth = calendar.component(.month, from: now) var seasonYear = year if isPast && startMonth >= currentMonth { seasonYear -= 1 } let start = calendar.date(from: DateComponents(year: seasonYear, month: startMonth, day: 1))! let endYear = endMonth < startMonth ? seasonYear + 1 : seasonYear let end = calendar.date(from: DateComponents(year: endYear, month: endMonth, day: 21))! return start...end } return nil } } private extension Calendar { func yearRange(_ year: Int) -> ClosedRange? { guard let start = date(from: DateComponents(year: year, month: 1, day: 1)), let end = date(from: DateComponents(year: year + 1, month: 1, day: 1)) else { return nil } return start...end } }