// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Reco.swift : moteur de recommandation on-device du mode Découverte // // Chaque swipe (👍 droite / 👎 gauche) alimente un profil de préférences // par caractéristique : ville, quartier, taille, tranche de prix, meublé, // animaux, gestionnaire. Le score d'une annonce = moyenne des taux // d'appréciation lissés (Laplace) de ses caractéristiques, pondérée par la // confiance (nombre d'observations). Les annonces non vues sont classées // par score, avec une part d'exploration pour éviter la bulle de filtre. // Tout est persisté localement (UserDefaults) — aucune donnée ne sort. // ----------------------------------------------------------------------------- import Foundation import Observation @MainActor @Observable final class RecoEngine { /// uids aimés, du plus récent au plus ancien (alimente « Mes coups de cœur ») private(set) var likedUIDs: [String] = [] private(set) var passedUIDs: Set = [] /// caractéristique -> [nb de 👍, nb de 👎] private var featureCounts: [String: [Int]] = [:] var decisions: Int { likedUIDs.count + passedUIDs.count } private static let storeKey = "louka.reco.v1" init() { load() } // MARK: décisions func isSeen(_ uid: String) -> Bool { passedUIDs.contains(uid) || likedUIDs.contains(uid) } func record(_ listing: Listing, liked: Bool) { if liked { likedUIDs.removeAll { $0 == listing.uid } likedUIDs.insert(listing.uid, at: 0) } else { passedUIDs.insert(listing.uid) } for f in Self.features(of: listing) { var c = featureCounts[f] ?? [0, 0] c[liked ? 0 : 1] += 1 featureCounts[f] = c } save() } /// annule la dernière décision sur cette annonce (bouton ↩) func undo(_ listing: Listing, wasLiked: Bool) { if wasLiked { likedUIDs.removeAll { $0 == listing.uid } } else { passedUIDs.remove(listing.uid) } for f in Self.features(of: listing) { var c = featureCounts[f] ?? [0, 0] c[wasLiked ? 0 : 1] = max(0, c[wasLiked ? 0 : 1] - 1) featureCounts[f] = c } save() } func unlike(_ uid: String) { likedUIDs.removeAll { $0 == uid } save() } func resetProfile() { likedUIDs = [] passedUIDs = [] featureCounts = [:] save() } // MARK: scoring /// score 0..1 — 0,5 = aucun signal func score(_ listing: Listing) -> Double { let feats = Self.features(of: listing) guard !feats.isEmpty else { return 0.5 } var num = 0.0, den = 0.0 for f in feats { let c = featureCounts[f] ?? [0, 0] let likes = Double(c[0]), passes = Double(c[1]) let rate = (likes + 1) / (likes + passes + 2) // lissage de Laplace let weight = 1 + log(1 + likes + passes) // confiance num += rate * weight den += weight } return num / den } /// classe les annonces non vues : meilleures d'abord, avec exploration /// (~1 carte sur 6 vient du reste du bassin pour continuer d'apprendre) func rank(_ listings: [Listing]) -> [Listing] { let unseen = listings.filter { !isSeen($0.uid) } guard decisions >= 3 else { return unseen.shuffled() } var scored = unseen .map { (listing: $0, s: score($0) + Double.random(in: 0..<0.04)) } .sorted { $0.s > $1.s } var out: [Listing] = [] var explorePool = scored.count > 12 ? Array(scored.suffix(from: scored.count / 2)) : [] var i = 0 while !scored.isEmpty { i += 1 if i % 6 == 0, !explorePool.isEmpty { let pick = explorePool.removeFirst() if let idx = scored.firstIndex(where: { $0.listing.uid == pick.listing.uid }) { scored.remove(at: idx) out.append(pick.listing) continue } } out.append(scored.removeFirst().listing) } return out } /// caractéristiques apprises d'une annonce static func features(of l: Listing) -> [String] { var f: [String] = [] if !l.city.isEmpty { f.append("city:\(l.city)") } if !l.sector.isEmpty { f.append("sector:\(l.sector)") } if !l.unitType.isEmpty { f.append("type:\(l.unitType)") } if !l.source.isEmpty { f.append("source:\(l.source)") } if let p = l.price { f.append("price:\(Int(p / 300) * 300)") // tranches de 300 $ } if let furn = l.furnished { f.append("furnished:\(furn)") } if let pets = l.pets { f.append("pets:\(pets)") } if l.details?.balcony == true { f.append("balcony") } if l.details?.parking?.available == true { f.append("parking") } return f } /// aperçu du profil appris (affiché dans Découverte) : top caractéristiques aimées func topTastes(_ n: Int = 3) -> [String] { featureCounts .filter { $0.value[0] >= 2 && $0.value[0] > $0.value[1] } .sorted { ($0.value[0] - $0.value[1]) > ($1.value[0] - $1.value[1]) } .prefix(n) .compactMap { key, _ in let parts = key.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2 else { return key == "balcony" ? "Balcon" : (key == "parking" ? "Stationnement" : key) } switch parts[0] { case "city": return parts[1] case "sector": return parts[1] case "type": return parts[1] case "price": return "~\(parts[1]) $" case "furnished": return parts[1] == "true" ? "Meublé" : "Non meublé" case "pets": return "Animaux \(parts[1])" case "source": return nil // trop interne pour l'affichage default: return nil } } } // MARK: persistance private struct Snapshot: Codable { var liked: [String] var passed: [String] var counts: [String: [Int]] } private func save() { let snap = Snapshot(liked: likedUIDs, passed: Array(passedUIDs), counts: featureCounts) if let data = try? JSONEncoder().encode(snap) { UserDefaults.standard.set(data, forKey: Self.storeKey) } } private func load() { guard let data = UserDefaults.standard.data(forKey: Self.storeKey), let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } likedUIDs = snap.liked passedUIDs = Set(snap.passed) featureCounts = snap.counts } }