SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
7.3 KB · 192 lines swift
Raw Blame History
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