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%
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