Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Personalization.swift — le cerveau local de la super-app : chaque geste3// (fiche ouverte, favori, recherche, lien source) devient un signal, agrégé4// avec décroissance temporelle en un PROFIL DE PRÉFÉRENCES (univers suivis,5// villes, gamme de prix, mots-clés) qui alimente le feed « Pour vous », la6// recherche et l'accueil. 100 % sur l'appareil — rien n'est envoyé au serveur,7// désactivable et effaçable dans Profil (esprit Loi 25).8import Foundation9import SwiftUI1011// MARK: - Signaux1213enum InteractionKind: String, Codable {14 case view // fiche consultée15 case favorite // ajout aux favoris (signal fort)16 case unfavorite // retrait (signal négatif)17 case search // recherche soumise18 case openSource // sortie vers l'annonce originale (signal très fort)19}2021struct InteractionEvent: Codable {22 var kind: InteractionKind23 var universeID: String?24 var city: String?25 var price: Double?26 var tags: [String] = []27 var query: String?28 var date: Date = .now29}3031// MARK: - Profil de préférences (calcul pur, testé dans KATests)3233struct PreferenceProfile {34 /// Affinité par univers, normalisée 0…135 var affinity: [String: Double] = [:]36 /// Villes les plus consultées, de la plus forte à la plus faible37 var topCities: [String] = []38 /// Gamme de prix consultée par univers (autour de la médiane pondérée)39 var priceBands: [String: ClosedRange<Double>] = [:]40 /// Mots-clés récurrents (étiquettes de fiches + termes de recherche)41 var topTags: [String] = []42 var eventCount: Int = 04344 /// Assez de signal pour personnaliser sans dire n'importe quoi45 var hasSignals: Bool { eventCount >= 5 && !affinity.isEmpty }4647 func topUniverses(_ n: Int) -> [String] {48 affinity.sorted { $0.value > $1.value }.prefix(n).map(\.key)49 }50}5152enum PreferenceEngine {53 /// Poids d'un geste (le favori et la sortie source pèsent plus qu'une vue)54 static func weight(_ kind: InteractionKind) -> Double {55 switch kind {56 case .view: return 1.057 case .favorite: return 3.058 case .unfavorite: return -3.0 // le retrait annule complètement le favori59 case .search: return 1.560 case .openSource: return 2.061 }62 }6364 /// Décroissance : un geste d'il y a 2 semaines pèse ~37 % d'un geste du jour65 static func decay(_ date: Date, now: Date) -> Double {66 let days = max(0, now.timeIntervalSince(date) / 86_400)67 return exp(-days / 14)68 }6970 static func computeProfile(_ events: [InteractionEvent], now: Date = .now) -> PreferenceProfile {71 var p = PreferenceProfile()72 p.eventCount = events.count73 var uniScore: [String: Double] = [:]74 var cityScore: [String: Double] = [:]75 var tagScore: [String: Double] = [:]76 var prices: [String: [(Double, Double)]] = [:] // universe → (prix, poids)7778 for e in events {79 let w = weight(e.kind) * decay(e.date, now: now)80 if let u = e.universeID { uniScore[u, default: 0] += w }81 if let c = e.city, !c.isEmpty, w > 0 { cityScore[c, default: 0] += w }82 for t in e.tags where t.count > 2 && w > 0 {83 tagScore[t.lowercased(), default: 0] += w84 }85 if let q = e.query {86 for word in q.lowercased().split(separator: " ") where word.count > 3 {87 tagScore[String(word), default: 0] += w * 0.688 }89 }90 if let u = e.universeID, let pr = e.price, pr > 0, w > 0 {91 prices[u, default: []].append((pr, w))92 }93 }9495 if let maxU = uniScore.values.max(), maxU > 0 {96 p.affinity = uniScore.filter { $0.value > 0 }.mapValues { $0 / maxU }97 }98 p.topCities = cityScore.sorted { $0.value > $1.value }.prefix(5).map(\.key)99 p.topTags = tagScore.sorted { $0.value > $1.value }.prefix(12).map(\.key)100 for (u, list) in prices {101 let sorted = list.sorted { $0.0 < $1.0 }102 // médiane pondérée : le prix au milieu de la masse des poids103 let totalW = sorted.reduce(0) { $0 + $1.1 }104 var acc = 0.0105 var median = sorted[sorted.count / 2].0106 for (price, w) in sorted {107 acc += w108 if acc >= totalW / 2 { median = price; break }109 }110 p.priceBands[u] = (median * 0.6)...(median * 1.45)111 }112 return p113 }114115 /// Score de recommandation d'un item pour ce profil (plus haut = mieux placé).116 static func score(_ item: KAItem, profile: PreferenceProfile) -> Double {117 var s = profile.affinity[item.universeID] ?? 0118 if let c = item.city, profile.topCities.prefix(3).contains(c) { s += 0.35 }119 if let pr = item.price, let band = profile.priceBands[item.universeID],120 band.contains(pr) { s += 0.25 }121 let haystack = (item.title + " " + (item.subtitle ?? "")).lowercased()122 var tagBonus = 0.0123 for t in profile.topTags where haystack.contains(t) {124 tagBonus += 0.1125 if tagBonus >= 0.3 { break }126 }127 return s + tagBonus128 }129}130131// MARK: - Le magasin de signaux (persistance locale, plafonné)132133@MainActor134final class PersonalizationStore: ObservableObject {135 static let shared = PersonalizationStore()136 static let enabledKey = "ka.perso.enabled"137 private static let maxEvents = 600138139 @Published private(set) var events: [InteractionEvent] = []140 @Published private(set) var profile = PreferenceProfile()141142 /// Personnalisation active (défaut : oui) — pilotée depuis Profil.143 var enabled: Bool {144 UserDefaults.standard.object(forKey: Self.enabledKey) == nil145 ? true : UserDefaults.standard.bool(forKey: Self.enabledKey)146 }147148 private var fileURL: URL {149 let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]150 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)151 return dir.appendingPathComponent("ka-signaux.json")152 }153154 init() {155 if let data = try? Data(contentsOf: fileURL),156 let saved = try? JSONDecoder().decode([InteractionEvent].self, from: data) {157 events = saved158 }159 profile = PreferenceEngine.computeProfile(events)160 }161162 func record(_ kind: InteractionKind, item: KAItem) {163 record(InteractionEvent(kind: kind, universeID: item.universeID,164 city: item.city, price: item.price,165 tags: Array((item.tags ?? []).prefix(6))))166 }167168 func recordSearch(_ query: String) {169 record(InteractionEvent(kind: .search, universeID: nil, query: query))170 }171172 func record(_ event: InteractionEvent) {173 guard enabled else { return }174 events.append(event)175 if events.count > Self.maxEvents { events.removeFirst(events.count - Self.maxEvents) }176 profile = PreferenceEngine.computeProfile(events)177 save()178 }179180 func reset() {181 events = []182 profile = PreferenceProfile()183 try? FileManager.default.removeItem(at: fileURL)184 }185186 private func save() {187 if let data = try? JSONEncoder().encode(events) {188 try? data.write(to: fileURL, options: .atomic)189 }190 }191}192