SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%
15.1 KB · 422 lines typescript
Raw Blame History
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