#!/usr/bin/env node // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // KA ID v2.2 — Matrix factorization implicite (ALS, Hu-Koren-Volinsky 2008). // Job batch quotidien (pm2 groupe-ka-kaid-mf, cron 03:12) : factorise la // matrice membre × annonce de chaque univers à partir du journal // d'interactions et des favoris (confiance c = 1 + α·r, r pondéré par type // de signal et décroissance temporelle), puis écrit le top-20 de // recommandations par membre dans `mf_recs`. Le endpoint /api/sso/prefs les // fusionne dans le profil (`mf`) et le rerank des satellites les booste. // // PRÊT MÊME SANS VOLUME : sous les seuils (3 membres, 4 annonces, 6 // interactions croisées par univers), l'univers est sauté proprement — le // modèle s'activera de lui-même quand la base grandira. // // Vie privée : les membres ayant désactivé la personnalisation sont exclus // de l'entraînement ET ne reçoivent aucune recommandation. // // Usage : node scripts/kaid-mf.mjs [--app ] [--verbose] import Database from "better-sqlite3"; import path from "node:path"; const DB_PATH = process.env.KA_DB_PATH ?? path.join(process.cwd(), "data", "ka-id.db"); const ARGS = process.argv.slice(2); const ONLY_APP = ARGS.includes("--app") ? ARGS[ARGS.indexOf("--app") + 1] : null; const VERBOSE = ARGS.includes("--verbose"); // hyperparamètres (implicit ALS) const K_MAX = 16; // facteurs latents (réduit automatiquement si petit) const LAMBDA = 0.1; // régularisation const ALPHA = 40; // échelle de confiance c = 1 + α·r_norm const ITERS = 12; // alternances const TOP_N = 20; // recommandations conservées par membre // seuils d'activation par univers const MIN_USERS = 3; const MIN_ITEMS = 4; const MIN_NNZ = 6; const HALF_LIFE_DAYS = 45; const WEIGHTS = { favorite: 8, external_click: 5, detail_dwell: 4, share: 4, detail_view: 2.5, compare: 2.5, alert_open: 2, click: 1, map_marker_click: 1, }; const NEGATIVE = new Set(["hide", "dismiss", "unfavorite"]); const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.exec(` CREATE TABLE IF NOT EXISTS mf_recs ( user_id INTEGER NOT NULL, app TEXT NOT NULL, item_id TEXT NOT NULL, score REAL NOT NULL, rank INTEGER NOT NULL, run_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, app, rank) ); CREATE INDEX IF NOT EXISTS mf_recs_user ON mf_recs(user_id, app); `); const log = (...a) => console.log("[kaid-mf]", ...a); const vlog = (...a) => VERBOSE && console.log("[kaid-mf]", ...a); function decay(createdAt) { const t = Date.parse(String(createdAt).replace(" ", "T") + "Z"); if (Number.isNaN(t)) return 0.5; const days = Math.max(0, (Date.now() - t) / 86_400_000); return Math.pow(0.5, days / HALF_LIFE_DAYS); } /** Interactions (membre, annonce) → confiance r, négatifs retirés. */ function buildInteractions(app) { const r = new Map(); // "uid|item" -> poids accumulé const neg = new Set(); const key = (u, i) => `${u}|${i}`; const events = db.prepare( `SELECT e.user_id, e.entity_id, e.event_type, e.created_at FROM user_events e JOIN users u ON u.id = e.user_id WHERE e.app = ? AND e.entity_id IS NOT NULL AND e.created_at > datetime('now', '-180 days') AND COALESCE(u.personalization, 1) = 1 ORDER BY e.id DESC LIMIT 200000`, ).all(app); for (const e of events) { if (NEGATIVE.has(e.event_type)) { neg.add(key(e.user_id, e.entity_id)); continue; } const w = WEIGHTS[e.event_type]; if (!w) continue; const k = key(e.user_id, e.entity_id); r.set(k, (r.get(k) ?? 0) + w * decay(e.created_at)); } const favs = db.prepare( `SELECT f.user_id, f.item_id, f.created_at FROM favorites f JOIN users u ON u.id = f.user_id WHERE f.app = ? AND COALESCE(u.personalization, 1) = 1`, ).all(app); for (const f of favs) { const k = key(f.user_id, f.item_id); r.set(k, (r.get(k) ?? 0) + WEIGHTS.favorite * Math.max(0.35, decay(f.created_at))); } for (const k of neg) r.delete(k); const hidden = new Map(); // user_id -> Set(item_id) for (const h of db.prepare( "SELECT user_id, item_id FROM hidden_items WHERE app = ?").all(app)) { if (!hidden.has(h.user_id)) hidden.set(h.user_id, new Set()); hidden.get(h.user_id).add(h.item_id); } return { r, hidden }; } /** Résout A·x = b (A symétrique définie positive k×k) — Gauss pivot partiel. */ function solve(A, b, k) { const M = new Float64Array(k * (k + 1)); for (let i = 0; i < k; i++) { for (let j = 0; j < k; j++) M[i * (k + 1) + j] = A[i * k + j]; M[i * (k + 1) + k] = b[i]; } for (let col = 0; col < k; col++) { let piv = col; for (let row = col + 1; row < k; row++) if (Math.abs(M[row * (k + 1) + col]) > Math.abs(M[piv * (k + 1) + col])) piv = row; if (piv !== col) for (let j = col; j <= k; j++) { const t = M[col * (k + 1) + j]; M[col * (k + 1) + j] = M[piv * (k + 1) + j]; M[piv * (k + 1) + j] = t; } const d = M[col * (k + 1) + col] || 1e-9; for (let row = col + 1; row < k; row++) { const f = M[row * (k + 1) + col] / d; if (!f) continue; for (let j = col; j <= k; j++) M[row * (k + 1) + j] -= f * M[col * (k + 1) + j]; } } const x = new Float64Array(k); for (let i = k - 1; i >= 0; i--) { let s = M[i * (k + 1) + k]; for (let j = i + 1; j < k; j++) s -= M[i * (k + 1) + j] * x[j]; x[i] = s / (M[i * (k + 1) + i] || 1e-9); } return x; } /** Un passage d'alternance : recalcule X (les « lignes ») à Y fixé. */ function alsStep(X, Y, rowsOf, nRows, k) { // YtY + λI (précalculé une fois par passage) const base = new Float64Array(k * k); const nY = Y.length / k; for (let i = 0; i < nY; i++) for (let a = 0; a < k; a++) { const ya = Y[i * k + a]; if (!ya) continue; for (let b = 0; b < k; b++) base[a * k + b] += ya * Y[i * k + b]; } for (let a = 0; a < k; a++) base[a * k + a] += LAMBDA; const A = new Float64Array(k * k); const bvec = new Float64Array(k); for (let u = 0; u < nRows; u++) { A.set(base); bvec.fill(0); for (const [i, c] of rowsOf(u)) { const extra = ALPHA * c; // (c_ui − 1) avec c_ui = 1 + α·c for (let a = 0; a < k; a++) { const ya = Y[i * k + a]; if (!ya) continue; bvec[a] += (1 + ALPHA * c) * ya; for (let b = 0; b < k; b++) A[a * k + b] += extra * ya * Y[i * k + b]; } } X.set(solve(A, bvec, k), u * k); } } function factorizeApp(app) { const { r, hidden } = buildInteractions(app); const users = new Map(); const items = new Map(); for (const key of r.keys()) { const [u, i] = key.split(/\|(.+)/s); if (!users.has(u)) users.set(u, users.size); if (!items.has(i)) items.set(i, items.size); } const nU = users.size, nI = items.size, nnz = r.size; if (nU < MIN_USERS || nI < MIN_ITEMS || nnz < MIN_NNZ) { log(`${app} : volume insuffisant (membres=${nU}, annonces=${nI}, ` + `interactions=${nnz}) — modèle en veille`); return { app, trained: false, users: nU, items: nI }; } const k = Math.max(2, Math.min(K_MAX, Math.floor(Math.min(nU, nI) / 2))); // normaliser r (le poids brut varie de 1 à ~40) → r/8 borné à 3 const byUser = Array.from({ length: nU }, () => []); const byItem = Array.from({ length: nI }, () => []); for (const [key, w] of r) { const [u, i] = key.split(/\|(.+)/s); const uu = users.get(u), ii = items.get(i); const c = Math.min(3, w / 8); byUser[uu].push([ii, c]); byItem[ii].push([uu, c]); } // init déterministe légère (hash) — reproductible d'un run à l'autre const X = new Float64Array(nU * k); const Y = new Float64Array(nI * k); let seed = 42; const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return (seed / 0x7fffffff - 0.5) * 0.1; }; for (let i = 0; i < X.length; i++) X[i] = rand(); for (let i = 0; i < Y.length; i++) Y[i] = rand(); for (let it = 0; it < ITERS; it++) { alsStep(X, Y, (u) => byUser[u], nU, k); alsStep(Y, X, (i) => byItem[i], nI, k); } // recommandations : items non vus, non masqués, score > 0, top-N const itemIds = [...items.keys()]; const insert = db.prepare( `INSERT INTO mf_recs (user_id, app, item_id, score, rank) VALUES (?, ?, ?, ?, ?)`, ); const clear = db.prepare("DELETE FROM mf_recs WHERE app = ?"); let written = 0; const tx = db.transaction(() => { clear.run(app); for (const [uidStr, u] of users) { const userId = Number(uidStr); const seen = new Set(byUser[u].map(([i]) => i)); const hid = hidden.get(userId) ?? new Set(); const scores = []; for (let i = 0; i < nI; i++) { if (seen.has(i) || hid.has(itemIds[i])) continue; let s = 0; for (let a = 0; a < k; a++) s += X[u * k + a] * Y[i * k + a]; if (s > 0.05) scores.push([s, i]); } scores.sort((a, b) => b[0] - a[0]); const top = scores.slice(0, TOP_N); const max = top.length ? top[0][0] : 1; top.forEach(([s, i], rank) => { insert.run(userId, app, itemIds[i], Math.round((s / max) * 100) / 100, rank + 1); written++; }); } }); tx(); log(`${app} : entraîné (membres=${nU}, annonces=${nI}, nnz=${nnz}, k=${k}) ` + `→ ${written} recommandations`); return { app, trained: true, users: nU, items: nI, written }; } // ---------------------------------------------------------------- principal const apps = ONLY_APP ? [ONLY_APP] : db.prepare( `SELECT DISTINCT app FROM ( SELECT app FROM user_events UNION SELECT app FROM favorites)`, ).all().map((r0) => r0.app); log(`démarrage — base ${DB_PATH}, univers : ${apps.join(", ") || "(aucun)"}`); let trained = 0; for (const app of apps) { try { if (factorizeApp(app).trained) trained++; } catch (e) { log(`${app} : ERREUR — ${e.message}`); } } // purge des recommandations orphelines de plus de 7 jours db.prepare("DELETE FROM mf_recs WHERE run_at < datetime('now', '-7 days')").run(); log(`terminé : ${trained}/${apps.length} univers entraînés`);