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%
10.0 KB · 280 lines javascript
Raw Blame History
1#!/usr/bin/env node2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// KA ID v2.2 — Matrix factorization implicite (ALS, Hu-Koren-Volinsky 2008).4// Job batch quotidien (pm2 groupe-ka-kaid-mf, cron 03:12) : factorise la5// matrice membre × annonce de chaque univers à partir du journal6// d'interactions et des favoris (confiance c = 1 + α·r, r pondéré par type7// de signal et décroissance temporelle), puis écrit le top-20 de8// recommandations par membre dans `mf_recs`. Le endpoint /api/sso/prefs les9// fusionne dans le profil (`mf`) et le rerank des satellites les booste.10//11// PRÊT MÊME SANS VOLUME : sous les seuils (3 membres, 4 annonces, 612// interactions croisées par univers), l'univers est sauté proprement — le13// modèle s'activera de lui-même quand la base grandira.14//15// Vie privée : les membres ayant désactivé la personnalisation sont exclus16// de l'entraînement ET ne reçoivent aucune recommandation.17//18// Usage : node scripts/kaid-mf.mjs [--app <univers>] [--verbose]19import Database from "better-sqlite3";20import path from "node:path";2122const DB_PATH =23  process.env.KA_DB_PATH ?? path.join(process.cwd(), "data", "ka-id.db");24const ARGS = process.argv.slice(2);25const ONLY_APP = ARGS.includes("--app") ? ARGS[ARGS.indexOf("--app") + 1] : null;26const VERBOSE = ARGS.includes("--verbose");2728// hyperparamètres (implicit ALS)29const K_MAX = 16;        // facteurs latents (réduit automatiquement si petit)30const LAMBDA = 0.1;      // régularisation31const ALPHA = 40;        // échelle de confiance c = 1 + α·r_norm32const ITERS = 12;        // alternances33const TOP_N = 20;        // recommandations conservées par membre34// seuils d'activation par univers35const MIN_USERS = 3;36const MIN_ITEMS = 4;37const MIN_NNZ = 6;3839const HALF_LIFE_DAYS = 45;40const WEIGHTS = {41  favorite: 8, external_click: 5, detail_dwell: 4, share: 4,42  detail_view: 2.5, compare: 2.5, alert_open: 2, click: 1,43  map_marker_click: 1,44};45const NEGATIVE = new Set(["hide", "dismiss", "unfavorite"]);4647const db = new Database(DB_PATH);48db.pragma("journal_mode = WAL");49db.exec(`50  CREATE TABLE IF NOT EXISTS mf_recs (51    user_id INTEGER NOT NULL,52    app TEXT NOT NULL,53    item_id TEXT NOT NULL,54    score REAL NOT NULL,55    rank INTEGER NOT NULL,56    run_at TEXT NOT NULL DEFAULT (datetime('now')),57    PRIMARY KEY (user_id, app, rank)58  );59  CREATE INDEX IF NOT EXISTS mf_recs_user ON mf_recs(user_id, app);60`);6162const log = (...a) => console.log("[kaid-mf]", ...a);63const vlog = (...a) => VERBOSE && console.log("[kaid-mf]", ...a);6465function decay(createdAt) {66  const t = Date.parse(String(createdAt).replace(" ", "T") + "Z");67  if (Number.isNaN(t)) return 0.5;68  const days = Math.max(0, (Date.now() - t) / 86_400_000);69  return Math.pow(0.5, days / HALF_LIFE_DAYS);70}7172/** Interactions (membre, annonce) → confiance r, négatifs retirés. */73function buildInteractions(app) {74  const r = new Map(); // "uid|item" -> poids accumulé75  const neg = new Set();76  const key = (u, i) => `${u}|${i}`;7778  const events = db.prepare(79    `SELECT e.user_id, e.entity_id, e.event_type, e.created_at80     FROM user_events e JOIN users u ON u.id = e.user_id81     WHERE e.app = ? AND e.entity_id IS NOT NULL82       AND e.created_at > datetime('now', '-180 days')83       AND COALESCE(u.personalization, 1) = 184     ORDER BY e.id DESC LIMIT 200000`,85  ).all(app);86  for (const e of events) {87    if (NEGATIVE.has(e.event_type)) {88      neg.add(key(e.user_id, e.entity_id));89      continue;90    }91    const w = WEIGHTS[e.event_type];92    if (!w) continue;93    const k = key(e.user_id, e.entity_id);94    r.set(k, (r.get(k) ?? 0) + w * decay(e.created_at));95  }96  const favs = db.prepare(97    `SELECT f.user_id, f.item_id, f.created_at98     FROM favorites f JOIN users u ON u.id = f.user_id99     WHERE f.app = ? AND COALESCE(u.personalization, 1) = 1`,100  ).all(app);101  for (const f of favs) {102    const k = key(f.user_id, f.item_id);103    r.set(k, (r.get(k) ?? 0) + WEIGHTS.favorite * Math.max(0.35, decay(f.created_at)));104  }105  for (const k of neg) r.delete(k);106107  const hidden = new Map(); // user_id -> Set(item_id)108  for (const h of db.prepare(109    "SELECT user_id, item_id FROM hidden_items WHERE app = ?").all(app)) {110    if (!hidden.has(h.user_id)) hidden.set(h.user_id, new Set());111    hidden.get(h.user_id).add(h.item_id);112  }113  return { r, hidden };114}115116/** Résout A·x = b (A symétrique définie positive k×k) — Gauss pivot partiel. */117function solve(A, b, k) {118  const M = new Float64Array(k * (k + 1));119  for (let i = 0; i < k; i++) {120    for (let j = 0; j < k; j++) M[i * (k + 1) + j] = A[i * k + j];121    M[i * (k + 1) + k] = b[i];122  }123  for (let col = 0; col < k; col++) {124    let piv = col;125    for (let row = col + 1; row < k; row++)126      if (Math.abs(M[row * (k + 1) + col]) > Math.abs(M[piv * (k + 1) + col])) piv = row;127    if (piv !== col)128      for (let j = col; j <= k; j++) {129        const t = M[col * (k + 1) + j];130        M[col * (k + 1) + j] = M[piv * (k + 1) + j];131        M[piv * (k + 1) + j] = t;132      }133    const d = M[col * (k + 1) + col] || 1e-9;134    for (let row = col + 1; row < k; row++) {135      const f = M[row * (k + 1) + col] / d;136      if (!f) continue;137      for (let j = col; j <= k; j++) M[row * (k + 1) + j] -= f * M[col * (k + 1) + j];138    }139  }140  const x = new Float64Array(k);141  for (let i = k - 1; i >= 0; i--) {142    let s = M[i * (k + 1) + k];143    for (let j = i + 1; j < k; j++) s -= M[i * (k + 1) + j] * x[j];144    x[i] = s / (M[i * (k + 1) + i] || 1e-9);145  }146  return x;147}148149/** Un passage d'alternance : recalcule X (les « lignes ») à Y fixé. */150function alsStep(X, Y, rowsOf, nRows, k) {151  // YtY + λI (précalculé une fois par passage)152  const base = new Float64Array(k * k);153  const nY = Y.length / k;154  for (let i = 0; i < nY; i++)155    for (let a = 0; a < k; a++) {156      const ya = Y[i * k + a];157      if (!ya) continue;158      for (let b = 0; b < k; b++) base[a * k + b] += ya * Y[i * k + b];159    }160  for (let a = 0; a < k; a++) base[a * k + a] += LAMBDA;161162  const A = new Float64Array(k * k);163  const bvec = new Float64Array(k);164  for (let u = 0; u < nRows; u++) {165    A.set(base);166    bvec.fill(0);167    for (const [i, c] of rowsOf(u)) {168      const extra = ALPHA * c; // (c_ui − 1) avec c_ui = 1 + α·c169      for (let a = 0; a < k; a++) {170        const ya = Y[i * k + a];171        if (!ya) continue;172        bvec[a] += (1 + ALPHA * c) * ya;173        for (let b = 0; b < k; b++) A[a * k + b] += extra * ya * Y[i * k + b];174      }175    }176    X.set(solve(A, bvec, k), u * k);177  }178}179180function factorizeApp(app) {181  const { r, hidden } = buildInteractions(app);182  const users = new Map();183  const items = new Map();184  for (const key of r.keys()) {185    const [u, i] = key.split(/\|(.+)/s);186    if (!users.has(u)) users.set(u, users.size);187    if (!items.has(i)) items.set(i, items.size);188  }189  const nU = users.size, nI = items.size, nnz = r.size;190  if (nU < MIN_USERS || nI < MIN_ITEMS || nnz < MIN_NNZ) {191    log(`${app} : volume insuffisant (membres=${nU}, annonces=${nI}, ` +192        `interactions=${nnz}) — modèle en veille`);193    return { app, trained: false, users: nU, items: nI };194  }195  const k = Math.max(2, Math.min(K_MAX, Math.floor(Math.min(nU, nI) / 2)));196197  // normaliser r (le poids brut varie de 1 à ~40) → r/8 borné à 3198  const byUser = Array.from({ length: nU }, () => []);199  const byItem = Array.from({ length: nI }, () => []);200  for (const [key, w] of r) {201    const [u, i] = key.split(/\|(.+)/s);202    const uu = users.get(u), ii = items.get(i);203    const c = Math.min(3, w / 8);204    byUser[uu].push([ii, c]);205    byItem[ii].push([uu, c]);206  }207208  // init déterministe légère (hash) — reproductible d'un run à l'autre209  const X = new Float64Array(nU * k);210  const Y = new Float64Array(nI * k);211  let seed = 42;212  const rand = () => {213    seed = (seed * 1103515245 + 12345) & 0x7fffffff;214    return (seed / 0x7fffffff - 0.5) * 0.1;215  };216  for (let i = 0; i < X.length; i++) X[i] = rand();217  for (let i = 0; i < Y.length; i++) Y[i] = rand();218219  for (let it = 0; it < ITERS; it++) {220    alsStep(X, Y, (u) => byUser[u], nU, k);221    alsStep(Y, X, (i) => byItem[i], nI, k);222  }223224  // recommandations : items non vus, non masqués, score > 0, top-N225  const itemIds = [...items.keys()];226  const insert = db.prepare(227    `INSERT INTO mf_recs (user_id, app, item_id, score, rank)228     VALUES (?, ?, ?, ?, ?)`,229  );230  const clear = db.prepare("DELETE FROM mf_recs WHERE app = ?");231  let written = 0;232  const tx = db.transaction(() => {233    clear.run(app);234    for (const [uidStr, u] of users) {235      const userId = Number(uidStr);236      const seen = new Set(byUser[u].map(([i]) => i));237      const hid = hidden.get(userId) ?? new Set();238      const scores = [];239      for (let i = 0; i < nI; i++) {240        if (seen.has(i) || hid.has(itemIds[i])) continue;241        let s = 0;242        for (let a = 0; a < k; a++) s += X[u * k + a] * Y[i * k + a];243        if (s > 0.05) scores.push([s, i]);244      }245      scores.sort((a, b) => b[0] - a[0]);246      const top = scores.slice(0, TOP_N);247      const max = top.length ? top[0][0] : 1;248      top.forEach(([s, i], rank) => {249        insert.run(userId, app, itemIds[i], Math.round((s / max) * 100) / 100, rank + 1);250        written++;251      });252    }253  });254  tx();255  log(`${app} : entraîné (membres=${nU}, annonces=${nI}, nnz=${nnz}, k=${k}) ` +256      `→ ${written} recommandations`);257  return { app, trained: true, users: nU, items: nI, written };258}259260// ---------------------------------------------------------------- principal261const apps = ONLY_APP262  ? [ONLY_APP]263  : db.prepare(264      `SELECT DISTINCT app FROM (265         SELECT app FROM user_events UNION SELECT app FROM favorites)`,266    ).all().map((r0) => r0.app);267268log(`démarrage — base ${DB_PATH}, univers : ${apps.join(", ") || "(aucun)"}`);269let trained = 0;270for (const app of apps) {271  try {272    if (factorizeApp(app).trained) trained++;273  } catch (e) {274    log(`${app} : ERREUR — ${e.message}`);275  }276}277// purge des recommandations orphelines de plus de 7 jours278db.prepare("DELETE FROM mf_recs WHERE run_at < datetime('now', '-7 days')").run();279log(`terminé : ${trained}/${apps.length} univers entraînés`);280