SPB Git

spb/lou-ka-ios Public

Lou·Ka iOS — app SwiftUI native de l'agrégateur de logements du Québec : filtres avancés, carte, stats, et mode Découverte (swipe) avec recommandation on-device

Swift 100%
6.9 KB · 186 lines swift
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// Reco.swift : moteur de recommandation on-device du mode Découverte5//6//   Chaque swipe (👍 droite / 👎 gauche) alimente un profil de préférences7//   par caractéristique : ville, quartier, taille, tranche de prix, meublé,8//   animaux, gestionnaire. Le score d'une annonce = moyenne des taux9//   d'appréciation lissés (Laplace) de ses caractéristiques, pondérée par la10//   confiance (nombre d'observations). Les annonces non vues sont classées11//   par score, avec une part d'exploration pour éviter la bulle de filtre.12//   Tout est persisté localement (UserDefaults) — aucune donnée ne sort.13// -----------------------------------------------------------------------------14import Foundation15import Observation1617@MainActor18@Observable19final class RecoEngine {20    /// uids aimés, du plus récent au plus ancien (alimente « Mes coups de cœur »)21    private(set) var likedUIDs: [String] = []22    private(set) var passedUIDs: Set<String> = []23    /// caractéristique -> [nb de 👍, nb de 👎]24    private var featureCounts: [String: [Int]] = [:]2526    var decisions: Int { likedUIDs.count + passedUIDs.count }2728    private static let storeKey = "louka.reco.v1"2930    init() { load() }3132    // MARK: décisions3334    func isSeen(_ uid: String) -> Bool {35        passedUIDs.contains(uid) || likedUIDs.contains(uid)36    }3738    func record(_ listing: Listing, liked: Bool) {39        if liked {40            likedUIDs.removeAll { $0 == listing.uid }41            likedUIDs.insert(listing.uid, at: 0)42        } else {43            passedUIDs.insert(listing.uid)44        }45        for f in Self.features(of: listing) {46            var c = featureCounts[f] ?? [0, 0]47            c[liked ? 0 : 1] += 148            featureCounts[f] = c49        }50        save()51    }5253    /// annule la dernière décision sur cette annonce (bouton ↩)54    func undo(_ listing: Listing, wasLiked: Bool) {55        if wasLiked {56            likedUIDs.removeAll { $0 == listing.uid }57        } else {58            passedUIDs.remove(listing.uid)59        }60        for f in Self.features(of: listing) {61            var c = featureCounts[f] ?? [0, 0]62            c[wasLiked ? 0 : 1] = max(0, c[wasLiked ? 0 : 1] - 1)63            featureCounts[f] = c64        }65        save()66    }6768    func unlike(_ uid: String) {69        likedUIDs.removeAll { $0 == uid }70        save()71    }7273    func resetProfile() {74        likedUIDs = []75        passedUIDs = []76        featureCounts = [:]77        save()78    }7980    // MARK: scoring8182    /// score 0..1 — 0,5 = aucun signal83    func score(_ listing: Listing) -> Double {84        let feats = Self.features(of: listing)85        guard !feats.isEmpty else { return 0.5 }86        var num = 0.0, den = 0.087        for f in feats {88            let c = featureCounts[f] ?? [0, 0]89            let likes = Double(c[0]), passes = Double(c[1])90            let rate = (likes + 1) / (likes + passes + 2)   // lissage de Laplace91            let weight = 1 + log(1 + likes + passes)        // confiance92            num += rate * weight93            den += weight94        }95        return num / den96    }9798    /// classe les annonces non vues : meilleures d'abord, avec exploration99    /// (~1 carte sur 6 vient du reste du bassin pour continuer d'apprendre)100    func rank(_ listings: [Listing]) -> [Listing] {101        let unseen = listings.filter { !isSeen($0.uid) }102        guard decisions >= 3 else { return unseen.shuffled() }103        var scored = unseen104            .map { (listing: $0, s: score($0) + Double.random(in: 0..<0.04)) }105            .sorted { $0.s > $1.s }106        var out: [Listing] = []107        var explorePool = scored.count > 12 ? Array(scored.suffix(from: scored.count / 2)) : []108        var i = 0109        while !scored.isEmpty {110            i += 1111            if i % 6 == 0, !explorePool.isEmpty {112                let pick = explorePool.removeFirst()113                if let idx = scored.firstIndex(where: { $0.listing.uid == pick.listing.uid }) {114                    scored.remove(at: idx)115                    out.append(pick.listing)116                    continue117                }118            }119            out.append(scored.removeFirst().listing)120        }121        return out122    }123124    /// caractéristiques apprises d'une annonce125    static func features(of l: Listing) -> [String] {126        var f: [String] = []127        if !l.city.isEmpty { f.append("city:\(l.city)") }128        if !l.sector.isEmpty { f.append("sector:\(l.sector)") }129        if !l.unitType.isEmpty { f.append("type:\(l.unitType)") }130        if !l.source.isEmpty { f.append("source:\(l.source)") }131        if let p = l.price {132            f.append("price:\(Int(p / 300) * 300)")   // tranches de 300 $133        }134        if let furn = l.furnished { f.append("furnished:\(furn)") }135        if let pets = l.pets { f.append("pets:\(pets)") }136        if l.details?.balcony == true { f.append("balcony") }137        if l.details?.parking?.available == true { f.append("parking") }138        return f139    }140141    /// aperçu du profil appris (affiché dans Découverte) : top caractéristiques aimées142    func topTastes(_ n: Int = 3) -> [String] {143        featureCounts144            .filter { $0.value[0] >= 2 && $0.value[0] > $0.value[1] }145            .sorted { ($0.value[0] - $0.value[1]) > ($1.value[0] - $1.value[1]) }146            .prefix(n)147            .compactMap { key, _ in148                let parts = key.split(separator: ":", maxSplits: 1).map(String.init)149                guard parts.count == 2 else { return key == "balcony" ? "Balcon" : (key == "parking" ? "Stationnement" : key) }150                switch parts[0] {151                case "city": return parts[1]152                case "sector": return parts[1]153                case "type": return parts[1]154                case "price": return "~\(parts[1]) $"155                case "furnished": return parts[1] == "true" ? "Meublé" : "Non meublé"156                case "pets": return "Animaux \(parts[1])"157                case "source": return nil   // trop interne pour l'affichage158                default: return nil159                }160            }161    }162163    // MARK: persistance164165    private struct Snapshot: Codable {166        var liked: [String]167        var passed: [String]168        var counts: [String: [Int]]169    }170171    private func save() {172        let snap = Snapshot(liked: likedUIDs, passed: Array(passedUIDs), counts: featureCounts)173        if let data = try? JSONEncoder().encode(snap) {174            UserDefaults.standard.set(data, forKey: Self.storeKey)175        }176    }177178    private func load() {179        guard let data = UserDefaults.standard.data(forKey: Self.storeKey),180              let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return }181        likedUIDs = snap.liked182        passedUIDs = Set(snap.passed)183        featureCounts = snap.counts184    }185}186