SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
6.1 KB · 145 lines swift
Raw Blame History
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