feat(ka-id v2.1): le moteur connaît mieux le membre — shrinkage par valeur (une seule consultation ne vaut jamais une affinité pleine), affinité de langue transversale, filtrage collaboratif item-item par co-favoris (profile.apps[app].similar) ; alertes ACTIVES : évaluation baseline sur les API publiques (src/lib/alerts.ts, adaptateurs 9 apps), courriels Resend selon la fréquence, route /api/kaid/alerts/tick (clé KAID_TICK_KEY) + cron pm2 horaire groupe-ka-kaid-alerts ; « Pour vous » enrichi des co-favoris des membres semblables
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 changed files +298 −24
added
src/app/api/kaid/alerts/tick/route.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — tick d'évaluation des alertes (pm2 cron horaire sur le nœud) : | |
| 3 | +// GET /api/kaid/alerts/tick?key=<KAID_TICK_KEY> | |
| 4 | +// Relance chaque recherche sauvegardée avec alerte active sur l'API publique | |
| 5 | +// de sa plateforme, compare au dernier total connu et notifie par courriel | |
| 6 | +// (voir src/lib/alerts.ts). Clé requise — jamais exposée au navigateur. | |
| 7 | +import { NextRequest, NextResponse } from "next/server"; | |
| 8 | +import crypto from "crypto"; | |
| 9 | +import { runAlerts } from "@/lib/alerts"; | |
| 10 | + | |
| 11 | +export const dynamic = "force-dynamic"; | |
| 12 | +export const maxDuration = 300; | |
| 13 | + | |
| 14 | +export async function GET(req: NextRequest) { | |
| 15 | + const key = req.nextUrl.searchParams.get("key") ?? ""; | |
| 16 | + const expected = process.env.KAID_TICK_KEY ?? ""; | |
| 17 | + const a = Buffer.from(key); | |
| 18 | + const b = Buffer.from(expected); | |
| 19 | + if (!expected || a.length !== b.length || !crypto.timingSafeEqual(a, b)) | |
| 20 | + return NextResponse.json({ error: "clé invalide" }, { status: 401 }); | |
| 21 | + const summary = await runAlerts(); | |
| 22 | + return NextResponse.json({ ok: true, ...summary }); | |
| 23 | +} | |
modified
src/app/mon-ka/page.tsx
+29 −2
@@ -6,7 +6,7 @@ | ||
| 6 | 6 | import type { Metadata } from "next"; |
| 7 | 7 | import { redirect } from "next/navigation"; |
| 8 | 8 | import { getSessionUser } from "@/lib/auth"; |
| 9 | −import { ensureKaId, favoritesOf, type FavoriteRow } from "@/lib/db"; | |
| 9 | +import { db, ensureKaId, favoritesOf, type FavoriteRow } from "@/lib/db"; | |
| 10 | 10 | import { SSO_CLIENTS } from "@/lib/sso"; |
| 11 | 11 | import { |
| 12 | 12 | privacyOf, savedSearchesOf, eventsOf, eventsCount, hiddenOf, |
@@ -121,6 +121,31 @@ function buildFeed( | ||
| 121 | 121 | return feed.slice(0, 8); |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | +/** Filtrage collaboratif : ce que les membres aux goûts semblables ont aussi | |
| 125 | + * aimé (co-favoris calculés par le moteur) — métadonnées relues du magasin | |
| 126 | + * central de favoris. */ | |
| 127 | +function similarFeed(profile: ReturnType<typeof getProfile> | null): FeedItem[] { | |
| 128 | + if (!profile) return []; | |
| 129 | + const out: FeedItem[] = []; | |
| 130 | + const meta = db.prepare( | |
| 131 | + `SELECT title, url FROM favorites | |
| 132 | + WHERE app = ? AND item_id = ? ORDER BY updated_at DESC LIMIT 1`, | |
| 133 | + ); | |
| 134 | + for (const [app, p] of Object.entries(profile.apps)) { | |
| 135 | + for (const id of (p.similar ?? []).slice(0, 3)) { | |
| 136 | + const row = meta.get(app, id) as { title: string; url: string | null } | undefined; | |
| 137 | + if (!row) continue; | |
| 138 | + out.push({ | |
| 139 | + icon: "✦", | |
| 140 | + app, | |
| 141 | + text: `Les membres aux goûts proches des vôtres aiment aussi « ${row.title} » (${appLabel(app)})`, | |
| 142 | + href: row.url ?? undefined, | |
| 143 | + }); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + return out.slice(0, 4); | |
| 147 | +} | |
| 148 | + | |
| 124 | 149 | export default async function MonKa() { |
| 125 | 150 | const user = await getSessionUser(); |
| 126 | 151 | if (!user) redirect("/connexion?next=%2Fmon-ka"); |
@@ -133,7 +158,9 @@ export default async function MonKa() { | ||
| 133 | 158 | const profile = privacy.personalization ? getProfile(user.id) : null; |
| 134 | 159 | const learned = profile ? learnedSummary(profile) : []; |
| 135 | 160 | const recent = privacy.history ? eventsOf(user.id, { limit: 30 }) : []; |
| 136 | − const feed = privacy.recos ? buildFeed(learned, searches, favs) : []; | |
| 161 | + const feed = privacy.recos | |
| 162 | + ? [...similarFeed(profile), ...buildFeed(learned, searches, favs)].slice(0, 10) | |
| 163 | + : []; | |
| 137 | 164 | |
| 138 | 165 | const byApp = new Map<string, FavoriteRow[]>(); |
| 139 | 166 | for (const f of favs) { |
added
src/lib/alerts.ts
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — évaluation des alertes de recherches sauvegardées. | |
| 3 | +// Méthode « baseline » : pour chaque alerte active, on relance la recherche | |
| 4 | +// sur l'API publique de la plateforme (les filtres sauvegardés SONT ses | |
| 5 | +// paramètres) et on compare le total au dernier total connu — une hausse | |
| 6 | +// = des nouveautés → courriel Resend (fréquence respectée). Robuste et | |
| 7 | +// générique : aucune notion de « date de publication » requise côté app. | |
| 8 | +// Déclenchée par GET /api/kaid/alerts/tick (pm2 cron horaire sur le nœud). | |
| 9 | +import { db } from "./db"; | |
| 10 | +import { sendEmail } from "./email"; | |
| 11 | + | |
| 12 | +type Adapter = { base: string; path: string; small: Record<string, string>; total: string }; | |
| 13 | + | |
| 14 | +// app → API de listing publique (paramètre « 1 résultat » + clé du total) | |
| 15 | +const ADAPTERS: Record<string, Adapter> = { | |
| 16 | + "lou-ka": { base: "https://www.lou-ka.com", path: "/api/listings", small: { limit: "1" }, total: "total" }, | |
| 17 | + "immo-ka": { base: "https://www.immo-ka.com", path: "/api/listings", small: { limit: "1" }, total: "total" }, | |
| 18 | + "auto-ka": { base: "https://www.auto-ka.com", path: "/api/vehicles", small: { limit: "1" }, total: "total" }, | |
| 19 | + "food-ka": { base: "https://www.food-ka.com", path: "/api/products", small: { limit: "1" }, total: "total" }, | |
| 20 | + "fabri-ka": { base: "https://www.fabri-ka.com", path: "/api/products", small: { per_page: "1" }, total: "total" }, | |
| 21 | + "resto-ka": { base: "https://www.resto-ka.com", path: "/api/restaurants", small: { limit: "1" }, total: "total" }, | |
| 22 | + "sorti-ka": { base: "https://www.sorti-ka.com", path: "/api/events", small: { limit: "1" }, total: "total" }, | |
| 23 | + "job-ka": { base: "https://www.job-ka.com", path: "/api/jobs", small: { limit: "1" }, total: "total" }, | |
| 24 | + "crea-ka": { base: "https://www.crea-ka.com", path: "/api/creators", small: { limit: "1" }, total: "total" }, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +const APP_LABELS: Record<string, string> = { | |
| 28 | + "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "auto-ka": "Auto·Ka", | |
| 29 | + "food-ka": "Food·Ka", "fabri-ka": "Fabri·Ka", "resto-ka": "Resto·Ka", | |
| 30 | + "sorti-ka": "Sorti·Ka", "job-ka": "Job·Ka", "crea-ka": "Créa·Ka", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +type AlertRow = { | |
| 34 | + id: number; user_id: number; app: string; label: string; | |
| 35 | + query: string | null; filters: string | null; url: string | null; | |
| 36 | + alert_frequency: string; last_total: number | null; | |
| 37 | + last_notified_at: string | null; | |
| 38 | + email: string; name: string; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +function dueByFrequency(row: AlertRow): boolean { | |
| 42 | + if (!row.last_notified_at) return true; | |
| 43 | + const hours = | |
| 44 | + (Date.now() - Date.parse(row.last_notified_at.replace(" ", "T") + "Z")) / 3.6e6; | |
| 45 | + if (row.alert_frequency === "instant") return hours >= 0.9; | |
| 46 | + if (row.alert_frequency === "weekly") return hours >= 156; | |
| 47 | + return hours >= 20; // daily | |
| 48 | +} | |
| 49 | + | |
| 50 | +async function currentTotal(row: AlertRow): Promise<number | null> { | |
| 51 | + const a = ADAPTERS[row.app]; | |
| 52 | + if (!a) return null; | |
| 53 | + const params = new URLSearchParams(a.small); | |
| 54 | + if (row.query) params.set("q", row.query); | |
| 55 | + if (row.filters) { | |
| 56 | + try { | |
| 57 | + const f = JSON.parse(row.filters) as Record<string, unknown>; | |
| 58 | + for (const [k, v] of Object.entries(f)) { | |
| 59 | + if (v == null || typeof v === "object") continue; | |
| 60 | + if (k === "q" || k in a.small) continue; | |
| 61 | + params.set(k.slice(0, 40), String(v).slice(0, 120)); | |
| 62 | + } | |
| 63 | + } catch { /* filtres illisibles : requête large */ } | |
| 64 | + } | |
| 65 | + try { | |
| 66 | + const r = await fetch(`${a.base}${a.path}?${params}`, { | |
| 67 | + signal: AbortSignal.timeout(8000), | |
| 68 | + headers: { "User-Agent": "ka-id-alerts/1.0 (groupe-ka.com)" }, | |
| 69 | + }); | |
| 70 | + if (!r.ok) return null; | |
| 71 | + const data = (await r.json()) as Record<string, unknown>; | |
| 72 | + const total = data[a.total]; | |
| 73 | + return typeof total === "number" ? total : null; | |
| 74 | + } catch { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +function alertEmail(row: AlertRow, nouveaux: number): { html: string; text: string } { | |
| 80 | + const label = APP_LABELS[row.app] ?? row.app; | |
| 81 | + const link = row.url ?? ADAPTERS[row.app]?.base ?? "https://www.groupe-ka.com"; | |
| 82 | + const text = | |
| 83 | + `Bonjour ${row.name.split(" ")[0]},\n\n` + | |
| 84 | + `${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""} ` + | |
| 85 | + `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` + | |
| 86 | + `« ${row.label} » sur ${label}.\n\nVoir : ${link}\n\n` + | |
| 87 | + `Gérer vos alertes : https://www.groupe-ka.com/mon-ka\n— Groupe KA`; | |
| 88 | + const html = | |
| 89 | + `<p>Bonjour ${row.name.split(" ")[0]},</p>` + | |
| 90 | + `<p><strong>${nouveaux} nouveau${nouveaux > 1 ? "x" : ""} résultat${nouveaux > 1 ? "s" : ""}</strong> ` + | |
| 91 | + `correspond${nouveaux > 1 ? "ent" : ""} à votre recherche sauvegardée ` + | |
| 92 | + `« <strong>${row.label}</strong> » sur ${label}.</p>` + | |
| 93 | + `<p><a href="${link}">Voir les résultats →</a></p>` + | |
| 94 | + `<p style="color:#666;font-size:13px">Vous recevez ce courriel parce qu'une alerte est active ` + | |
| 95 | + `sur cette recherche. <a href="https://www.groupe-ka.com/mon-ka">Gérer mes alertes</a></p>`; | |
| 96 | + return { html, text }; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function runAlerts(): Promise<{ | |
| 100 | + checked: number; notified: number; baselined: number; errors: number; | |
| 101 | +}> { | |
| 102 | + const rows = db.prepare( | |
| 103 | + `SELECT s.id, s.user_id, s.app, s.label, s.query, s.filters, s.url, | |
| 104 | + s.alert_frequency, s.last_total, s.last_notified_at, | |
| 105 | + u.email, u.name | |
| 106 | + FROM saved_searches s JOIN users u ON u.id = s.user_id | |
| 107 | + WHERE s.alert_enabled = 1`, | |
| 108 | + ).all() as AlertRow[]; | |
| 109 | + let checked = 0, notified = 0, baselined = 0, errors = 0; | |
| 110 | + for (const row of rows) { | |
| 111 | + if (!ADAPTERS[row.app]) continue; | |
| 112 | + checked++; | |
| 113 | + const total = await currentTotal(row); | |
| 114 | + if (total == null) { errors++; continue; } | |
| 115 | + if (row.last_total == null) { | |
| 116 | + db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?") | |
| 117 | + .run(total, row.id); | |
| 118 | + baselined++; | |
| 119 | + continue; | |
| 120 | + } | |
| 121 | + if (total > row.last_total && dueByFrequency(row)) { | |
| 122 | + const nouveaux = total - row.last_total; | |
| 123 | + try { | |
| 124 | + const { html, text } = alertEmail(row, nouveaux); | |
| 125 | + await sendEmail({ | |
| 126 | + to: row.email, | |
| 127 | + subject: `${nouveaux} nouveauté${nouveaux > 1 ? "s" : ""} — ${row.label}`, | |
| 128 | + html, text, | |
| 129 | + }); | |
| 130 | + db.prepare( | |
| 131 | + `UPDATE saved_searches SET last_total = ?, last_run_at = datetime('now'), | |
| 132 | + last_triggered_at = datetime('now'), last_notified_at = datetime('now') | |
| 133 | + WHERE id = ?`, | |
| 134 | + ).run(total, row.id); | |
| 135 | + notified++; | |
| 136 | + } catch { | |
| 137 | + errors++; | |
| 138 | + } | |
| 139 | + } else if (total < row.last_total) { | |
| 140 | + // baisse (annonces expirées) : rebaser sans bruit. Une hausse hors | |
| 141 | + // fenêtre de fréquence n'est PAS rebasée — elle sera notifiée au | |
| 142 | + // prochain tick admissible. | |
| 143 | + db.prepare("UPDATE saved_searches SET last_total = ? WHERE id = ?") | |
| 144 | + .run(total, row.id); | |
| 145 | + } | |
| 146 | + } | |
| 147 | + return { checked, notified, baselined, errors }; | |
| 148 | +} | |
modified
src/lib/kaid-data.ts
+9 −0
@@ -71,6 +71,15 @@ db.exec(` | ||
| 71 | 71 | ); |
| 72 | 72 | `); |
| 73 | 73 | |
| 74 | +// Alertes actives (2026-08-26) : baseline du total de résultats + dernier envoi. | |
| 75 | +for (const col of ["last_total INTEGER", "last_notified_at TEXT"]) { | |
| 76 | + try { | |
| 77 | + db.exec(`ALTER TABLE saved_searches ADD COLUMN ${col}`); | |
| 78 | + } catch { | |
| 79 | + /* colonne déjà présente */ | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 74 | 83 | // Confidentialité — trois interrupteurs, tous ON par défaut (opt-out). |
| 75 | 84 | for (const col of [ |
| 76 | 85 | "personalization INTEGER DEFAULT 1", // reranking personnalisé |
modified
src/lib/personal.ts
+89 −22
@@ -45,8 +45,12 @@ const HALF_LIFE_DAYS = 30; | ||
| 45 | 45 | const LOCATION_DIMS = new Set([ |
| 46 | 46 | "city", "region", "sector", "quartier", "neighborhood", "location", "ville", |
| 47 | 47 | ]); |
| 48 | +const LANGUAGE_DIMS = new Set(["language", "langue"]); | |
| 48 | 49 | const MAX_VALUES_PER_DIM = 12; |
| 49 | 50 | const 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. | |
| 53 | +const SUPPORT_SCALE = 3; | |
| 50 | 54 | |
| 51 | 55 | /* ---------- types ---------- */ |
| 52 | 56 | |
@@ -56,13 +60,17 @@ export type AppProfile = { | ||
| 56 | 60 | n: number; // volume pondéré de signaux positifs |
| 57 | 61 | dims: Record<string, DimProfile>; |
| 58 | 62 | ranges: Record<string, RangeProfile>; |
| 63 | + similar?: string[]; // co-favoris des membres semblables | |
| 59 | 64 | }; |
| 60 | 65 | export type Profile = { |
| 61 | 66 | version: 2; |
| 62 | 67 | computed_at: string; |
| 63 | 68 | events_n: number; |
| 64 | 69 | apps: Record<string, AppProfile>; |
| 65 | − global: { location: DimProfile & { apps: number } }; | |
| 70 | + global: { | |
| 71 | + location: DimProfile & { apps: number }; | |
| 72 | + language?: DimProfile & { apps: number }; | |
| 73 | + }; | |
| 66 | 74 | }; |
| 67 | 75 | |
| 68 | 76 | /* ---------- accumulation ---------- */ |
@@ -215,6 +223,7 @@ export function computeProfile(userId: number): Profile { | ||
| 215 | 223 | |
| 216 | 224 | const apps: Record<string, AppProfile> = {}; |
| 217 | 225 | const globalLoc = new Map<string, { score: number; apps: Set<string> }>(); |
| 226 | + const globalLang = new Map<string, { score: number; apps: Set<string> }>(); | |
| 218 | 227 | |
| 219 | 228 | for (const [app, acc] of perApp) { |
| 220 | 229 | const dims: Record<string, DimProfile> = {}; |
@@ -228,7 +237,14 @@ export function computeProfile(userId: number): Profile { | ||
| 228 | 237 | } |
| 229 | 238 | const entries = [...m.entries()] |
| 230 | 239 | .filter(([val]) => !banned.has(`${app}|${dim}|${val}`) && !banned.has(`*|${dim}|${val}`)) |
| 231 | − .map(([val, { score }]) => [val, Math.max(-1, Math.min(1, score / maxPos))] as const) | |
| 240 | + .map(([val, { score, pos }]) => { | |
| 241 | + // shrinkage : affinité relative × support accumulé (une seule | |
| 242 | + // consultation ⇒ affinité prudente, jamais 1.0 d'emblée) | |
| 243 | + const support = pos + Math.max(0, -score); | |
| 244 | + const factor = 1 - Math.exp(-support / SUPPORT_SCALE); | |
| 245 | + const aff = Math.max(-1, Math.min(1, score / maxPos)) * factor; | |
| 246 | + return [val, aff] as const; | |
| 247 | + }) | |
| 232 | 248 | .filter(([, aff]) => Math.abs(aff) >= MIN_AFFINITY) |
| 233 | 249 | .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) |
| 234 | 250 | .slice(0, MAX_VALUES_PER_DIM); |
@@ -238,13 +254,15 @@ export function computeProfile(userId: number): Profile { | ||
| 238 | 254 | ); |
| 239 | 255 | const conf = Math.round((1 - Math.exp(-acc.posw / 8)) * 100) / 100; |
| 240 | 256 | dims[dim] = { values, conf }; |
| 241 | − if (LOCATION_DIMS.has(dim)) | |
| 257 | + const globalMap = LOCATION_DIMS.has(dim) ? globalLoc | |
| 258 | + : LANGUAGE_DIMS.has(dim) ? globalLang : null; | |
| 259 | + if (globalMap) | |
| 242 | 260 | for (const [val, aff] of entries) { |
| 243 | 261 | if (aff <= 0) continue; |
| 244 | − const g = globalLoc.get(val) ?? { score: 0, apps: new Set<string>() }; | |
| 262 | + const g = globalMap.get(val) ?? { score: 0, apps: new Set<string>() }; | |
| 245 | 263 | g.score += aff; |
| 246 | 264 | g.apps.add(app); |
| 247 | − globalLoc.set(val, g); | |
| 265 | + globalMap.set(val, g); | |
| 248 | 266 | } |
| 249 | 267 | } |
| 250 | 268 | const ranges: Record<string, RangeProfile> = {}; |
@@ -252,8 +270,13 @@ export function computeProfile(userId: number): Profile { | ||
| 252 | 270 | const r = weightedQuartiles(arr); |
| 253 | 271 | if (r) ranges[dim] = r; |
| 254 | 272 | } |
| 255 | − if (Object.keys(dims).length || Object.keys(ranges).length) | |
| 256 | − apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges }; | |
| 273 | + // Filtrage collaboratif léger (item-item par co-favoris) : « les membres | |
| 274 | + // qui ont aimé les mêmes annonces que vous ont aussi aimé… ». Ne devient | |
| 275 | + // actif que lorsque d'autres membres partagent des favoris — sinon vide. | |
| 276 | + const similar = coFavorites(userId, app); | |
| 277 | + if (Object.keys(dims).length || Object.keys(ranges).length || similar.length) | |
| 278 | + apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges, | |
| 279 | + ...(similar.length ? { similar } : {}) }; | |
| 257 | 280 | } |
| 258 | 281 | |
| 259 | 282 | // Renforcements explicites. |
@@ -263,33 +286,77 @@ export function computeProfile(userId: number): Profile { | ||
| 263 | 286 | dim.values[o.value.toLowerCase()] = 1; |
| 264 | 287 | } |
| 265 | 288 | |
| 266 | − // Localisation transversale : forte seulement si vue dans 2 univers ou +. | |
| 267 | − let maxLoc = 0; | |
| 268 | − for (const g of globalLoc.values()) maxLoc = Math.max(maxLoc, g.score); | |
| 269 | − const locValues: Record<string, number> = {}; | |
| 270 | − let locApps = 0; | |
| 271 | − for (const [val, g] of [...globalLoc.entries()] | |
| 272 | − .sort((a, b) => b[1].score - a[1].score).slice(0, MAX_VALUES_PER_DIM)) { | |
| 273 | − const crossFactor = g.apps.size >= 2 ? 1 : 0.5; | |
| 274 | − locValues[val] = Math.round((g.score / (maxLoc || 1)) * crossFactor * 100) / 100; | |
| 275 | − locApps = Math.max(locApps, g.apps.size); | |
| 289 | + // CF aussi pour les univers où le membre n'a QUE des favoris (pas d'événement) | |
| 290 | + const favApps = db.prepare( | |
| 291 | + "SELECT DISTINCT app FROM favorites WHERE user_id = ?", | |
| 292 | + ).all(userId) as { app: string }[]; | |
| 293 | + for (const { app } of favApps) { | |
| 294 | + if (apps[app]?.similar) continue; | |
| 295 | + const similar = coFavorites(userId, app); | |
| 296 | + if (!similar.length) continue; | |
| 297 | + if (apps[app]) apps[app].similar = similar; | |
| 298 | + else apps[app] = { n: 0, dims: {}, ranges: {}, similar }; | |
| 276 | 299 | } |
| 277 | 300 | |
| 301 | + // Transversal : fort seulement si vu dans 2 univers ou + (localisation, | |
| 302 | + // langue) — jamais « cherche à Montréal sur Lou·Ka ⇒ tout à Montréal ». | |
| 303 | + const aggregate = (m: Map<string, { score: number; apps: Set<string> }>) => { | |
| 304 | + let max = 0; | |
| 305 | + for (const g of m.values()) max = Math.max(max, g.score); | |
| 306 | + const values: Record<string, number> = {}; | |
| 307 | + let napps = 0; | |
| 308 | + for (const [val, g] of [...m.entries()] | |
| 309 | + .sort((a, b) => b[1].score - a[1].score).slice(0, MAX_VALUES_PER_DIM)) { | |
| 310 | + const crossFactor = g.apps.size >= 2 ? 1 : 0.5; | |
| 311 | + values[val] = Math.round((g.score / (max || 1)) * crossFactor * 100) / 100; | |
| 312 | + napps = Math.max(napps, g.apps.size); | |
| 313 | + } | |
| 314 | + return { | |
| 315 | + values, | |
| 316 | + conf: Math.round(Math.min(1, napps / 3) * 100) / 100, | |
| 317 | + apps: napps, | |
| 318 | + }; | |
| 319 | + }; | |
| 320 | + | |
| 278 | 321 | return { |
| 279 | 322 | version: 2, |
| 280 | 323 | computed_at: new Date().toISOString(), |
| 281 | 324 | events_n: events.length, |
| 282 | 325 | apps, |
| 283 | 326 | global: { |
| 284 | − location: { | |
| 285 | − values: locValues, | |
| 286 | − conf: Math.round(Math.min(1, locApps / 3) * 100) / 100, | |
| 287 | − apps: locApps, | |
| 288 | − }, | |
| 327 | + location: aggregate(globalLoc), | |
| 328 | + ...(globalLang.size ? { language: aggregate(globalLang) } : {}), | |
| 289 | 329 | }, |
| 290 | 330 | }; |
| 291 | 331 | } |
| 292 | 332 | |
| 333 | +/** Co-favoris : les autres favoris des membres qui partagent au moins un | |
| 334 | + * favori avec l'utilisateur dans cet univers (item-item, max 12). */ | |
| 335 | +function coFavorites(userId: number, app: string): string[] { | |
| 336 | + try { | |
| 337 | + const rows = db.prepare( | |
| 338 | + `WITH mine AS ( | |
| 339 | + SELECT item_id FROM favorites WHERE user_id = ? AND app = ? | |
| 340 | + ), | |
| 341 | + peers AS ( | |
| 342 | + SELECT DISTINCT user_id FROM favorites | |
| 343 | + WHERE app = ? AND user_id != ? | |
| 344 | + AND item_id IN (SELECT item_id FROM mine) | |
| 345 | + ) | |
| 346 | + SELECT f.item_id, COUNT(DISTINCT f.user_id) AS n | |
| 347 | + FROM favorites f | |
| 348 | + WHERE f.app = ? AND f.user_id IN (SELECT user_id FROM peers) | |
| 349 | + AND f.item_id NOT IN (SELECT item_id FROM mine) | |
| 350 | + GROUP BY f.item_id | |
| 351 | + ORDER BY n DESC, MAX(f.created_at) DESC | |
| 352 | + LIMIT 12`, | |
| 353 | + ).all(userId, app, app, userId, app) as { item_id: string }[]; | |
| 354 | + return rows.map((r) => r.item_id); | |
| 355 | + } catch { | |
| 356 | + return []; | |
| 357 | + } | |
| 358 | +} | |
| 359 | + | |
| 293 | 360 | /** Profil (avec cache 15 min, invalidé par les événements forts). */ |
| 294 | 361 | export function getProfile(userId: number): Profile { |
| 295 | 362 | const cached = cachedProfile(userId); |
| 296 | 363 | |