// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Recommendation.swift — le feed « Pour vous » : à partir du profil de // préférences (Personalization.swift), on interroge les univers les plus // suivis avec la ville dominante, on SCORE chaque item côté client (ville, // gamme de prix, mots-clés) puis on entrelace les univers — jamais un simple // tri chronologique, jamais de contenu inventé : tout vient des API live. // Prêt pour mieux plus tard (embeddings, collaboratif) : il suffit de // remplacer PreferenceEngine.score sans toucher aux vues. import Foundation struct ForYouEntry: Identifiable, Hashable { let item: KAItem let reason: String var id: String { item.id } } enum RecommendationService { /// Univers où filtrer par ville a du sens (épicerie/produits/créateurs : non) private static let citySensible: Set = [ "lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka", "auto-ka", ] /// Construit le feed mélangé et classé. `excluding` = déjà vus (historique). static func feed(profile: PreferenceProfile, excluding: Set = [], limit: Int = 10) async -> [ForYouEntry] { guard profile.hasSignals else { return [] } let universes = profile.topUniverses(4) .compactMap(Ecosystem.universe) .filter { $0.map != nil } guard !universes.isEmpty else { return [] } let city = profile.topCities.first var pools: [String: [KAItem]] = [:] await withTaskGroup(of: (String, [KAItem]).self) { group in for u in universes { group.addTask { let c = citySensible.contains(u.id) ? city : nil var items = (try? await UniverseService.fetch(u, city: c, limit: 14)) ?? [] // ville trop pointue pour cet univers ? on élargit if items.isEmpty, c != nil { items = (try? await UniverseService.fetch(u, limit: 14)) ?? [] } return (u.id, items) } } for await (id, items) in group { pools[id] = items } } // score + tri à l'intérieur de chaque univers var ranked: [String: [KAItem]] = [:] for (id, items) in pools { ranked[id] = items .filter { !excluding.contains($0.id) } .sorted { PreferenceEngine.score($0, profile: profile) > PreferenceEngine.score($1, profile: profile) } } // entrelacement pondéré : l'univers dominant place plus de cartes, // mais le feed reste un MÉLANGE (jamais 10 cartes du même univers) var feed: [ForYouEntry] = [] var cursor: [String: Int] = [:] let order = universes.map(\.id) while feed.count < limit { var advanced = false for id in order { let take = (profile.affinity[id] ?? 0) > 0.66 ? 2 : 1 for _ in 0.. String { if let c = item.city, profile.topCities.prefix(3).contains(c) { return "À \(c), comme vos dernières visites" } if let p = item.price, let band = profile.priceBands[item.universeID], band.contains(p) { return "Dans votre gamme de prix" } if let u = Ecosystem.universe(item.universeID) { return "Parce que vous suivez \(u.wordmark)" } return "Suggestion de l'écosystème" } } // MARK: - Regroupement temporel des événements (Sorti·Ka) enum EventBucket: Int, CaseIterable, Comparable { case today, thisWeek, thisMonth, later, ongoing, undated var label: String { switch self { case .today: return "Aujourd'hui" case .thisWeek: return "Cette semaine" case .thisMonth: return "Ce mois-ci" case .later: return "Plus tard" case .ongoing: return "En ce moment" case .undated: return "À venir" } } static func < (a: EventBucket, b: EventBucket) -> Bool { a.rawValue < b.rawValue } /// Classe une date ISO « yyyy-MM-dd » par rapport à aujourd'hui. static func bucket(iso: String?, now: Date = .now) -> EventBucket { guard let iso, iso.count >= 10 else { return .undated } let fmt = DateFormatter() fmt.dateFormat = "yyyy-MM-dd" fmt.locale = Locale(identifier: "fr_CA") guard let date = fmt.date(from: String(iso.prefix(10))) else { return .undated } var cal = Calendar(identifier: .gregorian) cal.locale = Locale(identifier: "fr_CA") if cal.isDate(date, inSameDayAs: now) { return .today } if date < now { return .ongoing } // commencé, toujours actif (upcoming=true) if let week = cal.date(byAdding: .day, value: 7, to: now), date <= week { return .thisWeek } if let month = cal.date(byAdding: .day, value: 31, to: now), date <= month { return .thisMonth } return .later } } // MARK: - Hypothèque (Immo·Ka) — calcul pur, testé enum MortgageMath { /// Paiement mensuel : prix, mise de fonds (fraction 0…1), taux annuel (%), années. static func monthlyPayment(price: Double, downFraction: Double, annualRate: Double, years: Int) -> Double { let principal = price * (1 - downFraction) guard principal > 0, years > 0 else { return 0 } let r = annualRate / 100 / 12 let n = Double(years * 12) guard r > 0 else { return principal / n } return principal * r * pow(1 + r, n) / (pow(1 + r, n) - 1) } }