SPB Git

spb/focale Public

Swift 100%
7.6 KB · 191 lines swift
Raw Blame History
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