Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID v2 — moteur de préférences du Groupe KA.3// Déduit un profil de préférences par univers (+ transversal) à partir du4// journal d'interactions et des favoris : pondération par type d'événement,5// décroissance temporelle (demi-vie 30 jours), signaux négatifs, plages6// numériques robustes (quartiles pondérés), corrections explicites de7// l'utilisateur (pref_overrides). Calcul paresseux, mis en cache dans8// user_prefs et invalidé par les événements forts (favori, masquage…).9import { db } from "./db";10import {11 eventsOf, eventsCount, cachedProfile, storeProfile, overridesOf,12 type UserEventRow,13} from "./kaid-data";1415/* ---------- pondérations ---------- */1617// Tous les signaux ne se valent pas : un ♥ pèse 80× une impression.18export const EVENT_WEIGHTS: Record<string, number> = {19 search: 0.5,20 impression: 0.1,21 click: 1,22 detail_view: 2.5,23 detail_dwell: 4, // consultation longue (≥ 20 s)24 favorite: 8,25 unfavorite: -4,26 share: 4,27 compare: 2.5,28 hide: -8,29 unhide: 2,30 dismiss: -2.5,31 map_open: 0.5,32 map_marker_click: 1.2,33 filter_change: 0.3,34 price_filter: 0.5,35 location_filter: 0.8,36 scroll_depth: 0.2,37 return_visit: 1.5,38 alert_create: 10,39 alert_open: 2,40 external_click: 5, // sortie vers l'annonce d'origine = intérêt fort41 saved_search: 6,42};4344const HALF_LIFE_DAYS = 30;45const LOCATION_DIMS = new Set([46 "city", "region", "sector", "quartier", "neighborhood", "location", "ville",47]);48const LANGUAGE_DIMS = new Set(["language", "langue"]);49const MAX_VALUES_PER_DIM = 12;50const MIN_AFFINITY = 0.05;51// Shrinkage : une valeur vue une seule fois ne mérite pas une affinité52// pleine — le facteur de support tend vers 1 avec l'accumulation de poids.53const SUPPORT_SCALE = 3;5455/* ---------- types ---------- */5657export type DimProfile = { values: Record<string, number>; conf: number };58export type RangeProfile = { p25: number; p50: number; p75: number; n: number };59export type AppProfile = {60 n: number; // volume pondéré de signaux positifs61 dims: Record<string, DimProfile>;62 ranges: Record<string, RangeProfile>;63 similar?: string[]; // co-favoris des membres semblables64 mf?: string[]; // recommandations du modèle ALS65};66export type Profile = {67 version: 2;68 computed_at: string;69 events_n: number;70 apps: Record<string, AppProfile>;71 global: {72 location: DimProfile & { apps: number };73 language?: DimProfile & { apps: number };74 };75};7677/* ---------- accumulation ---------- */7879type Acc = {80 dims: Map<string, Map<string, { score: number; pos: number }>>;81 nums: Map<string, Array<{ v: number; w: number }>>;82 posw: number;83};8485function newAcc(): Acc {86 return { dims: new Map(), nums: new Map(), posw: 0 };87}8889function addFeature(acc: Acc, dim: string, value: unknown, w: number): void {90 if (value == null) return;91 if (typeof value === "number" && Number.isFinite(value)) {92 if (w <= 0) return; // les plages n'apprennent que du positif93 let arr = acc.nums.get(dim);94 if (!arr) acc.nums.set(dim, (arr = []));95 if (arr.length < 2000) arr.push({ v: value, w });96 return;97 }98 const vals = Array.isArray(value) ? value : [value];99 for (const raw of vals.slice(0, 8)) {100 if (typeof raw !== "string" && typeof raw !== "boolean") continue;101 const val = String(raw).trim().toLowerCase().slice(0, 80);102 if (!val) continue;103 let m = acc.dims.get(dim);104 if (!m) acc.dims.set(dim, (m = new Map()));105 const cur = m.get(val) ?? { score: 0, pos: 0 };106 cur.score += w;107 if (w > 0) cur.pos += w;108 m.set(val, cur);109 }110}111112function decayOf(createdAt: string): number {113 const t = Date.parse(createdAt.includes("T") ? createdAt : createdAt + "Z");114 if (Number.isNaN(t)) return 0.5;115 const days = Math.max(0, (Date.now() - t) / 86_400_000);116 return Math.pow(0.5, days / HALF_LIFE_DAYS);117}118119function eventWeight(e: UserEventRow): number {120 let w = EVENT_WEIGHTS[e.event_type] ?? 0;121 if (!w) return 0;122 if (e.event_type === "detail_dwell") {123 // gradue selon la durée réelle si transmise124 try {125 const meta = JSON.parse(e.metadata ?? "{}");126 const s = (meta.dwell_ms ?? 0) / 1000;127 if (s >= 60) w = 5;128 else if (s < 20) w = 2.5;129 } catch { /* poids par défaut */ }130 }131 return w * decayOf(e.created_at);132}133134function featuresOfEvent(e: UserEventRow): Record<string, unknown> | null {135 if (!e.metadata) return null;136 try {137 const f = JSON.parse(e.metadata).features;138 return f && typeof f === "object" ? (f as Record<string, unknown>) : null;139 } catch {140 return null;141 }142}143144/* ---------- quartiles pondérés ---------- */145146function weightedQuartiles(arr: Array<{ v: number; w: number }>): RangeProfile | null {147 if (arr.length < 4) return null;148 const sorted = [...arr].sort((a, b) => a.v - b.v);149 const total = sorted.reduce((s, x) => s + x.w, 0);150 if (total <= 0) return null;151 const q = (p: number): number => {152 let cum = 0;153 for (const x of sorted) {154 cum += x.w;155 if (cum >= p * total) return x.v;156 }157 return sorted[sorted.length - 1].v;158 };159 return {160 p25: Math.round(q(0.25) * 100) / 100,161 p50: Math.round(q(0.5) * 100) / 100,162 p75: Math.round(q(0.75) * 100) / 100,163 n: arr.length,164 };165}166167/* ---------- calcul du profil ---------- */168169export function computeProfile(userId: number): Profile {170 const events = eventsOf(userId);171 const perApp = new Map<string, Acc>();172 const accOf = (app: string): Acc => {173 let a = perApp.get(app);174 if (!a) perApp.set(app, (a = newAcc()));175 return a;176 };177178 for (const e of events) {179 const w = eventWeight(e);180 if (!w) continue;181 const acc = accOf(e.app);182 if (w > 0) acc.posw += w;183 const feats = featuresOfEvent(e);184 if (feats)185 for (const [dim, value] of Object.entries(feats))186 addFeature(acc, dim.slice(0, 40), value, w);187 // la requête et les filtres d'une recherche sont eux-mêmes des signaux188 if (e.event_type === "search" && e.filters) {189 try {190 const f = JSON.parse(e.filters) as Record<string, unknown>;191 for (const [dim, value] of Object.entries(f))192 addFeature(acc, dim.slice(0, 40), value, w * 0.8);193 } catch { /* filtres illisibles : ignorés */ }194 }195 }196197 // Les favoris encore actifs comptent aussi (avec leur méta si présente),198 // même s'ils précèdent la mise en place du journal.199 const favs = db.prepare(200 "SELECT app, meta, created_at FROM favorites WHERE user_id = ?",201 ).all(userId) as { app: string; meta: string | null; created_at: string }[];202 for (const f of favs) {203 if (!f.meta) continue;204 try {205 const meta = JSON.parse(f.meta) as Record<string, unknown>;206 const feats = (meta.features ?? meta) as Record<string, unknown>;207 if (!feats || typeof feats !== "object") continue;208 const w = (EVENT_WEIGHTS.favorite ?? 8) *209 Math.max(0.35, decayOf(f.created_at)); // un ♥ vieillit lentement210 const acc = accOf(f.app);211 acc.posw += w;212 for (const [dim, value] of Object.entries(feats))213 addFeature(acc, String(dim).slice(0, 40), value, w);214 } catch { /* méta illisible */ }215 }216217 // Normalisation + agrégation transversale de la localisation.218 const overrides = overridesOf(userId);219 const banned = new Set(220 overrides.filter((o) => o.mode === "ban")221 .map((o) => `${o.app}|${o.dim}|${o.value.toLowerCase()}`),222 );223 const boosted = overrides.filter((o) => o.mode === "boost");224225 const apps: Record<string, AppProfile> = {};226 const globalLoc = new Map<string, { score: number; apps: Set<string> }>();227 const globalLang = new Map<string, { score: number; apps: Set<string> }>();228229 for (const [app, acc] of perApp) {230 const dims: Record<string, DimProfile> = {};231 for (const [dim, m] of acc.dims) {232 let maxPos = 0;233 for (const { pos } of m.values()) maxPos = Math.max(maxPos, pos);234 if (maxPos <= 0) {235 let maxAbs = 0;236 for (const { score } of m.values()) maxAbs = Math.max(maxAbs, Math.abs(score));237 maxPos = maxAbs || 1;238 }239 const entries = [...m.entries()]240 .filter(([val]) => !banned.has(`${app}|${dim}|${val}`) && !banned.has(`*|${dim}|${val}`))241 .map(([val, { score, pos }]) => {242 // shrinkage : affinité relative × support accumulé (une seule243 // consultation ⇒ affinité prudente, jamais 1.0 d'emblée)244 const support = pos + Math.max(0, -score);245 const factor = 1 - Math.exp(-support / SUPPORT_SCALE);246 const aff = Math.max(-1, Math.min(1, score / maxPos)) * factor;247 return [val, aff] as const;248 })249 .filter(([, aff]) => Math.abs(aff) >= MIN_AFFINITY)250 .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))251 .slice(0, MAX_VALUES_PER_DIM);252 if (!entries.length) continue;253 const values = Object.fromEntries(254 entries.map(([v, a]) => [v, Math.round(a * 100) / 100]),255 );256 const conf = Math.round((1 - Math.exp(-acc.posw / 8)) * 100) / 100;257 dims[dim] = { values, conf };258 const globalMap = LOCATION_DIMS.has(dim) ? globalLoc259 : LANGUAGE_DIMS.has(dim) ? globalLang : null;260 if (globalMap)261 for (const [val, aff] of entries) {262 if (aff <= 0) continue;263 const g = globalMap.get(val) ?? { score: 0, apps: new Set<string>() };264 g.score += aff;265 g.apps.add(app);266 globalMap.set(val, g);267 }268 }269 const ranges: Record<string, RangeProfile> = {};270 for (const [dim, arr] of acc.nums) {271 const r = weightedQuartiles(arr);272 if (r) ranges[dim] = r;273 }274 // Filtrage collaboratif léger (item-item par co-favoris) : « les membres275 // qui ont aimé les mêmes annonces que vous ont aussi aimé… ». Ne devient276 // actif que lorsque d'autres membres partagent des favoris — sinon vide.277 const similar = coFavorites(userId, app);278 if (Object.keys(dims).length || Object.keys(ranges).length || similar.length)279 apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges,280 ...(similar.length ? { similar } : {}) };281 }282283 // Renforcements explicites.284 for (const o of boosted) {285 const app = apps[o.app] ?? (apps[o.app] = { n: 1, dims: {}, ranges: {} });286 const dim = app.dims[o.dim] ?? (app.dims[o.dim] = { values: {}, conf: 1 });287 dim.values[o.value.toLowerCase()] = 1;288 }289290 // CF aussi pour les univers où le membre n'a QUE des favoris (pas d'événement)291 const favApps = db.prepare(292 "SELECT DISTINCT app FROM favorites WHERE user_id = ?",293 ).all(userId) as { app: string }[];294 for (const { app } of favApps) {295 if (apps[app]?.similar) continue;296 const similar = coFavorites(userId, app);297 if (!similar.length) continue;298 if (apps[app]) apps[app].similar = similar;299 else apps[app] = { n: 0, dims: {}, ranges: {}, similar };300 }301302 // Transversal : fort seulement si vu dans 2 univers ou + (localisation,303 // langue) — jamais « cherche à Montréal sur Lou·Ka ⇒ tout à Montréal ».304 const aggregate = (m: Map<string, { score: number; apps: Set<string> }>) => {305 let max = 0;306 for (const g of m.values()) max = Math.max(max, g.score);307 const values: Record<string, number> = {};308 let napps = 0;309 for (const [val, g] of [...m.entries()]310 .sort((a, b) => b[1].score - a[1].score).slice(0, MAX_VALUES_PER_DIM)) {311 const crossFactor = g.apps.size >= 2 ? 1 : 0.5;312 values[val] = Math.round((g.score / (max || 1)) * crossFactor * 100) / 100;313 napps = Math.max(napps, g.apps.size);314 }315 return {316 values,317 conf: Math.round(Math.min(1, napps / 3) * 100) / 100,318 apps: napps,319 };320 };321322 return {323 version: 2,324 computed_at: new Date().toISOString(),325 events_n: events.length,326 apps,327 global: {328 location: aggregate(globalLoc),329 ...(globalLang.size ? { language: aggregate(globalLang) } : {}),330 },331 };332}333334/** Co-favoris : les autres favoris des membres qui partagent au moins un335 * favori avec l'utilisateur dans cet univers (item-item, max 12). */336function coFavorites(userId: number, app: string): string[] {337 try {338 const rows = db.prepare(339 `WITH mine AS (340 SELECT item_id FROM favorites WHERE user_id = ? AND app = ?341 ),342 peers AS (343 SELECT DISTINCT user_id FROM favorites344 WHERE app = ? AND user_id != ?345 AND item_id IN (SELECT item_id FROM mine)346 )347 SELECT f.item_id, COUNT(DISTINCT f.user_id) AS n348 FROM favorites f349 WHERE f.app = ? AND f.user_id IN (SELECT user_id FROM peers)350 AND f.item_id NOT IN (SELECT item_id FROM mine)351 GROUP BY f.item_id352 ORDER BY n DESC, MAX(f.created_at) DESC353 LIMIT 12`,354 ).all(userId, app, app, userId, app) as { item_id: string }[];355 return rows.map((r) => r.item_id);356 } catch {357 return [];358 }359}360361/** Profil (avec cache 15 min, invalidé par les événements forts). */362export function getProfile(userId: number): Profile {363 const cached = cachedProfile(userId);364 if (cached) {365 const ageMin =366 (Date.now() - Date.parse(cached.updated_at + "Z")) / 60000;367 const n = eventsCount(userId);368 if (ageMin < 15 || n === cached.events_n) {369 try {370 return JSON.parse(cached.profile) as Profile;371 } catch { /* cache corrompu : recalcul */ }372 }373 }374 const profile = computeProfile(userId);375 storeProfile(userId, profile, eventsCount(userId));376 return profile;377}378379/* ---------- résumé lisible (« Ce que KA a appris ») ---------- */380381const DIM_LABELS: Record<string, string> = {382 city: "Ville", region: "Région", sector: "Secteur", quartier: "Quartier",383 brand: "Marque", model: "Modèle", body_type: "Carrosserie",384 transmission: "Transmission", fuel: "Carburant", category: "Catégorie",385 categories: "Catégorie", cuisine: "Cuisine", store: "Enseigne",386 employer: "Employeur", industry: "Industrie", work_mode: "Mode de travail",387 seniority: "Séniorité", unit_type: "Type de logement",388 property_type: "Type de propriété", bedrooms: "Chambres", platform: "Plateforme",389 source: "Source", price: "Prix", salary: "Salaire", year: "Année",390 mileage: "Kilométrage", venue: "Lieu", is_free: "Gratuit",391};392393export function dimLabel(dim: string): string {394 return DIM_LABELS[dim] ?? dim;395}396397export type LearnedItem = {398 app: string; dim: string; label: string; value: string;399 affinity: number; kind: "value" | "range";400};401402export function learnedSummary(profile: Profile): LearnedItem[] {403 const out: LearnedItem[] = [];404 for (const [app, p] of Object.entries(profile.apps)) {405 for (const [dim, d] of Object.entries(p.dims)) {406 for (const [value, aff] of Object.entries(d.values)) {407 if (aff < 0.35) continue; // on ne montre que les préférences nettes408 out.push({ app, dim, label: dimLabel(dim), value, affinity: aff, kind: "value" });409 }410 }411 for (const [dim, r] of Object.entries(p.ranges)) {412 if (r.n < 5) continue;413 out.push({414 app, dim, label: dimLabel(dim),415 value: `${Math.round(r.p25).toLocaleString("fr-CA")} – ${Math.round(r.p75).toLocaleString("fr-CA")}`,416 affinity: Math.min(1, r.n / 30), kind: "range",417 });418 }419 }420 return out.sort((a, b) => b.affinity - a.affinity).slice(0, 60);421}422