Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Recommendation.swift — le feed « Pour vous » : à partir du profil de3// préférences (Personalization.swift), on interroge les univers les plus4// suivis avec la ville dominante, on SCORE chaque item côté client (ville,5// gamme de prix, mots-clés) puis on entrelace les univers — jamais un simple6// tri chronologique, jamais de contenu inventé : tout vient des API live.7// Prêt pour mieux plus tard (embeddings, collaboratif) : il suffit de8// remplacer PreferenceEngine.score sans toucher aux vues.9import Foundation1011struct ForYouEntry: Identifiable, Hashable {12 let item: KAItem13 let reason: String14 var id: String { item.id }15}1617enum RecommendationService {18 /// Univers où filtrer par ville a du sens (épicerie/produits/créateurs : non)19 private static let citySensible: Set<String> = [20 "lou-ka", "immo-ka", "resto-ka", "sorti-ka", "job-ka", "auto-ka",21 ]2223 /// Construit le feed mélangé et classé. `excluding` = déjà vus (historique).24 static func feed(profile: PreferenceProfile,25 excluding: Set<String> = [],26 limit: Int = 10) async -> [ForYouEntry] {27 guard profile.hasSignals else { return [] }28 let universes = profile.topUniverses(4)29 .compactMap(Ecosystem.universe)30 .filter { $0.map != nil }31 guard !universes.isEmpty else { return [] }32 let city = profile.topCities.first3334 var pools: [String: [KAItem]] = [:]35 await withTaskGroup(of: (String, [KAItem]).self) { group in36 for u in universes {37 group.addTask {38 let c = citySensible.contains(u.id) ? city : nil39 var items = (try? await UniverseService.fetch(u, city: c, limit: 14)) ?? []40 // ville trop pointue pour cet univers ? on élargit41 if items.isEmpty, c != nil {42 items = (try? await UniverseService.fetch(u, limit: 14)) ?? []43 }44 return (u.id, items)45 }46 }47 for await (id, items) in group { pools[id] = items }48 }4950 // score + tri à l'intérieur de chaque univers51 var ranked: [String: [KAItem]] = [:]52 for (id, items) in pools {53 ranked[id] = items54 .filter { !excluding.contains($0.id) }55 .sorted { PreferenceEngine.score($0, profile: profile) > PreferenceEngine.score($1, profile: profile) }56 }5758 // entrelacement pondéré : l'univers dominant place plus de cartes,59 // mais le feed reste un MÉLANGE (jamais 10 cartes du même univers)60 var feed: [ForYouEntry] = []61 var cursor: [String: Int] = [:]62 let order = universes.map(\.id)63 while feed.count < limit {64 var advanced = false65 for id in order {66 let take = (profile.affinity[id] ?? 0) > 0.66 ? 2 : 167 for _ in 0..<take where feed.count < limit {68 let i = cursor[id, default: 0]69 guard let pool = ranked[id], i < pool.count else { continue }70 let item = pool[i]71 cursor[id] = i + 172 feed.append(ForYouEntry(item: item, reason: reason(item, profile: profile)))73 advanced = true74 }75 }76 if !advanced { break }77 }78 return feed79 }8081 /// Pourquoi cette carte est là — honnête et lisible.82 static func reason(_ item: KAItem, profile: PreferenceProfile) -> String {83 if let c = item.city, profile.topCities.prefix(3).contains(c) {84 return "À \(c), comme vos dernières visites"85 }86 if let p = item.price, let band = profile.priceBands[item.universeID], band.contains(p) {87 return "Dans votre gamme de prix"88 }89 if let u = Ecosystem.universe(item.universeID) {90 return "Parce que vous suivez \(u.wordmark)"91 }92 return "Suggestion de l'écosystème"93 }94}9596// MARK: - Regroupement temporel des événements (Sorti·Ka)9798enum EventBucket: Int, CaseIterable, Comparable {99 case today, thisWeek, thisMonth, later, ongoing, undated100101 var label: String {102 switch self {103 case .today: return "Aujourd'hui"104 case .thisWeek: return "Cette semaine"105 case .thisMonth: return "Ce mois-ci"106 case .later: return "Plus tard"107 case .ongoing: return "En ce moment"108 case .undated: return "À venir"109 }110 }111112 static func < (a: EventBucket, b: EventBucket) -> Bool { a.rawValue < b.rawValue }113114 /// Classe une date ISO « yyyy-MM-dd » par rapport à aujourd'hui.115 static func bucket(iso: String?, now: Date = .now) -> EventBucket {116 guard let iso, iso.count >= 10 else { return .undated }117 let fmt = DateFormatter()118 fmt.dateFormat = "yyyy-MM-dd"119 fmt.locale = Locale(identifier: "fr_CA")120 guard let date = fmt.date(from: String(iso.prefix(10))) else { return .undated }121 var cal = Calendar(identifier: .gregorian)122 cal.locale = Locale(identifier: "fr_CA")123 if cal.isDate(date, inSameDayAs: now) { return .today }124 if date < now { return .ongoing } // commencé, toujours actif (upcoming=true)125 if let week = cal.date(byAdding: .day, value: 7, to: now), date <= week { return .thisWeek }126 if let month = cal.date(byAdding: .day, value: 31, to: now), date <= month { return .thisMonth }127 return .later128 }129}130131// MARK: - Hypothèque (Immo·Ka) — calcul pur, testé132133enum MortgageMath {134 /// Paiement mensuel : prix, mise de fonds (fraction 0…1), taux annuel (%), années.135 static func monthlyPayment(price: Double, downFraction: Double,136 annualRate: Double, years: Int) -> Double {137 let principal = price * (1 - downFraction)138 guard principal > 0, years > 0 else { return 0 }139 let r = annualRate / 100 / 12140 let n = Double(years * 12)141 guard r > 0 else { return principal / n }142 return principal * r * pow(1 + r, n) / (pow(1 + r, n) - 1)143 }144}145