// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Personalization.swift — le cerveau local de la super-app : chaque geste // (fiche ouverte, favori, recherche, lien source) devient un signal, agrégé // avec décroissance temporelle en un PROFIL DE PRÉFÉRENCES (univers suivis, // villes, gamme de prix, mots-clés) qui alimente le feed « Pour vous », la // recherche et l'accueil. 100 % sur l'appareil — rien n'est envoyé au serveur, // désactivable et effaçable dans Profil (esprit Loi 25). import Foundation import SwiftUI // MARK: - Signaux enum InteractionKind: String, Codable { case view // fiche consultée case favorite // ajout aux favoris (signal fort) case unfavorite // retrait (signal négatif) case search // recherche soumise case openSource // sortie vers l'annonce originale (signal très fort) } struct InteractionEvent: Codable { var kind: InteractionKind var universeID: String? var city: String? var price: Double? var tags: [String] = [] var query: String? var date: Date = .now } // MARK: - Profil de préférences (calcul pur, testé dans KATests) struct PreferenceProfile { /// Affinité par univers, normalisée 0…1 var affinity: [String: Double] = [:] /// Villes les plus consultées, de la plus forte à la plus faible var topCities: [String] = [] /// Gamme de prix consultée par univers (autour de la médiane pondérée) var priceBands: [String: ClosedRange] = [:] /// Mots-clés récurrents (étiquettes de fiches + termes de recherche) var topTags: [String] = [] var eventCount: Int = 0 /// Assez de signal pour personnaliser sans dire n'importe quoi var hasSignals: Bool { eventCount >= 5 && !affinity.isEmpty } func topUniverses(_ n: Int) -> [String] { affinity.sorted { $0.value > $1.value }.prefix(n).map(\.key) } } enum PreferenceEngine { /// Poids d'un geste (le favori et la sortie source pèsent plus qu'une vue) static func weight(_ kind: InteractionKind) -> Double { switch kind { case .view: return 1.0 case .favorite: return 3.0 case .unfavorite: return -3.0 // le retrait annule complètement le favori case .search: return 1.5 case .openSource: return 2.0 } } /// Décroissance : un geste d'il y a 2 semaines pèse ~37 % d'un geste du jour static func decay(_ date: Date, now: Date) -> Double { let days = max(0, now.timeIntervalSince(date) / 86_400) return exp(-days / 14) } static func computeProfile(_ events: [InteractionEvent], now: Date = .now) -> PreferenceProfile { var p = PreferenceProfile() p.eventCount = events.count var uniScore: [String: Double] = [:] var cityScore: [String: Double] = [:] var tagScore: [String: Double] = [:] var prices: [String: [(Double, Double)]] = [:] // universe → (prix, poids) for e in events { let w = weight(e.kind) * decay(e.date, now: now) if let u = e.universeID { uniScore[u, default: 0] += w } if let c = e.city, !c.isEmpty, w > 0 { cityScore[c, default: 0] += w } for t in e.tags where t.count > 2 && w > 0 { tagScore[t.lowercased(), default: 0] += w } if let q = e.query { for word in q.lowercased().split(separator: " ") where word.count > 3 { tagScore[String(word), default: 0] += w * 0.6 } } if let u = e.universeID, let pr = e.price, pr > 0, w > 0 { prices[u, default: []].append((pr, w)) } } if let maxU = uniScore.values.max(), maxU > 0 { p.affinity = uniScore.filter { $0.value > 0 }.mapValues { $0 / maxU } } p.topCities = cityScore.sorted { $0.value > $1.value }.prefix(5).map(\.key) p.topTags = tagScore.sorted { $0.value > $1.value }.prefix(12).map(\.key) for (u, list) in prices { let sorted = list.sorted { $0.0 < $1.0 } // médiane pondérée : le prix au milieu de la masse des poids let totalW = sorted.reduce(0) { $0 + $1.1 } var acc = 0.0 var median = sorted[sorted.count / 2].0 for (price, w) in sorted { acc += w if acc >= totalW / 2 { median = price; break } } p.priceBands[u] = (median * 0.6)...(median * 1.45) } return p } /// Score de recommandation d'un item pour ce profil (plus haut = mieux placé). static func score(_ item: KAItem, profile: PreferenceProfile) -> Double { var s = profile.affinity[item.universeID] ?? 0 if let c = item.city, profile.topCities.prefix(3).contains(c) { s += 0.35 } if let pr = item.price, let band = profile.priceBands[item.universeID], band.contains(pr) { s += 0.25 } let haystack = (item.title + " " + (item.subtitle ?? "")).lowercased() var tagBonus = 0.0 for t in profile.topTags where haystack.contains(t) { tagBonus += 0.1 if tagBonus >= 0.3 { break } } return s + tagBonus } } // MARK: - Le magasin de signaux (persistance locale, plafonné) @MainActor final class PersonalizationStore: ObservableObject { static let shared = PersonalizationStore() static let enabledKey = "ka.perso.enabled" private static let maxEvents = 600 @Published private(set) var events: [InteractionEvent] = [] @Published private(set) var profile = PreferenceProfile() /// Personnalisation active (défaut : oui) — pilotée depuis Profil. var enabled: Bool { UserDefaults.standard.object(forKey: Self.enabledKey) == nil ? true : UserDefaults.standard.bool(forKey: Self.enabledKey) } private var fileURL: URL { let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("ka-signaux.json") } init() { if let data = try? Data(contentsOf: fileURL), let saved = try? JSONDecoder().decode([InteractionEvent].self, from: data) { events = saved } profile = PreferenceEngine.computeProfile(events) } func record(_ kind: InteractionKind, item: KAItem) { record(InteractionEvent(kind: kind, universeID: item.universeID, city: item.city, price: item.price, tags: Array((item.tags ?? []).prefix(6)))) } func recordSearch(_ query: String) { record(InteractionEvent(kind: .search, universeID: nil, query: query)) } func record(_ event: InteractionEvent) { guard enabled else { return } events.append(event) if events.count > Self.maxEvents { events.removeFirst(events.count - Self.maxEvents) } profile = PreferenceEngine.computeProfile(events) save() } func reset() { events = [] profile = PreferenceProfile() try? FileManager.default.removeItem(at: fileURL) } private func save() { if let data = try? JSONEncoder().encode(events) { try? data.write(to: fileURL, options: .atomic) } } }