feat(ka-id v2): moteur de personnalisation Groupe KA — feature store central (journal d'interactions, recherches sauvegardées + alertes, éléments masqués, profil de préférences calculé avec décroissance 30 j et signaux négatifs), endpoints s2s HMAC /api/sso/{events,prefs,saved-searches,hide}, page /mon-ka (Pour vous, Ce que KA a appris corrigeable, confidentialité Loi 25 : 3 interrupteurs + effacement + réinitialisation + export JSON), raccourci depuis /compte
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 changed files +1,803 −0
added
src/app/api/monka/export/route.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — export des données personnelles (Loi 25) : JSON téléchargeable | |
| 3 | +// (profil, favoris, recherches, alertes, préférences apprises, journal). | |
| 4 | +import { NextResponse } from "next/server"; | |
| 5 | +import { getSessionUser } from "@/lib/auth"; | |
| 6 | +import { exportUserData } from "@/lib/kaid-data"; | |
| 7 | + | |
| 8 | +export async function GET() { | |
| 9 | + const user = await getSessionUser(); | |
| 10 | + if (!user) | |
| 11 | + return NextResponse.json({ error: "non connecté" }, { status: 401 }); | |
| 12 | + const data = exportUserData(user.id); | |
| 13 | + return new NextResponse(JSON.stringify(data, null, 2), { | |
| 14 | + headers: { | |
| 15 | + "Content-Type": "application/json; charset=utf-8", | |
| 16 | + "Content-Disposition": `attachment; filename="ka-id-export-${user.kaId ?? user.id}.json"`, | |
| 17 | + }, | |
| 18 | + }); | |
| 19 | +} | |
added
src/app/api/monka/prefs/route.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — corriger « Ce que KA a appris » (session hub requise). | |
| 3 | +// POST { app, dim, value, mode: "ban" | "boost" | "clear" } | |
| 4 | +// ban = ne plus utiliser cette préférence ; boost = la renforcer ; | |
| 5 | +// clear = retirer la correction (le moteur réapprend naturellement). | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { getSessionUser } from "@/lib/auth"; | |
| 8 | +import { setOverride } from "@/lib/kaid-data"; | |
| 9 | + | |
| 10 | +export async function POST(req: NextRequest) { | |
| 11 | + const user = await getSessionUser(); | |
| 12 | + if (!user) | |
| 13 | + return NextResponse.json({ error: "non connecté" }, { status: 401 }); | |
| 14 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 15 | + const app = String(body.app ?? "").slice(0, 30); | |
| 16 | + const dim = String(body.dim ?? "").slice(0, 40); | |
| 17 | + const value = String(body.value ?? "").slice(0, 120); | |
| 18 | + const mode = String(body.mode ?? ""); | |
| 19 | + if (!app || !dim || !value || !["ban", "boost", "clear"].includes(mode)) | |
| 20 | + return NextResponse.json({ error: "paramètres invalides" }, { status: 400 }); | |
| 21 | + setOverride(user.id, app, dim, value, mode as "ban" | "boost" | "clear"); | |
| 22 | + return NextResponse.json({ ok: true }); | |
| 23 | +} | |
added
src/app/api/monka/privacy/route.ts
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — confidentialité (session hub requise). | |
| 3 | +// POST { personalization? | history? | recos? : bool, | |
| 4 | +// clear_history?: true, reset?: true } | |
| 5 | +// « reset » efface le journal, le profil appris, les corrections et les | |
| 6 | +// éléments masqués — repart de zéro (les favoris et recherches restent). | |
| 7 | +import { NextRequest, NextResponse } from "next/server"; | |
| 8 | +import { getSessionUser } from "@/lib/auth"; | |
| 9 | +import { | |
| 10 | + setPrivacy, privacyOf, clearHistory, resetPersonalization, | |
| 11 | +} from "@/lib/kaid-data"; | |
| 12 | + | |
| 13 | +export async function POST(req: NextRequest) { | |
| 14 | + const user = await getSessionUser(); | |
| 15 | + if (!user) | |
| 16 | + return NextResponse.json({ error: "non connecté" }, { status: 401 }); | |
| 17 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 18 | + | |
| 19 | + if (body.reset === true) resetPersonalization(user.id); | |
| 20 | + else if (body.clear_history === true) clearHistory(user.id); | |
| 21 | + else | |
| 22 | + setPrivacy(user.id, { | |
| 23 | + personalization: | |
| 24 | + typeof body.personalization === "boolean" ? body.personalization : undefined, | |
| 25 | + history: typeof body.history === "boolean" ? body.history : undefined, | |
| 26 | + recos: typeof body.recos === "boolean" ? body.recos : undefined, | |
| 27 | + }); | |
| 28 | + return NextResponse.json({ ok: true, privacy: privacyOf(user.id) }); | |
| 29 | +} | |
added
src/app/api/monka/searches/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — gérer ses recherches sauvegardées et leurs alertes (session hub). | |
| 3 | +// POST { id, action: "remove" | "alert", alert?: bool, frequency? } | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { getSessionUser } from "@/lib/auth"; | |
| 6 | +import { removeSavedSearch, setSavedSearchAlert } from "@/lib/kaid-data"; | |
| 7 | + | |
| 8 | +export async function POST(req: NextRequest) { | |
| 9 | + const user = await getSessionUser(); | |
| 10 | + if (!user) | |
| 11 | + return NextResponse.json({ error: "non connecté" }, { status: 401 }); | |
| 12 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 13 | + const id = typeof body.id === "number" ? body.id : NaN; | |
| 14 | + if (!Number.isInteger(id)) | |
| 15 | + return NextResponse.json({ error: "id requis" }, { status: 400 }); | |
| 16 | + if (body.action === "remove") removeSavedSearch(user.id, id); | |
| 17 | + else if (body.action === "alert") | |
| 18 | + setSavedSearchAlert(user.id, id, body.alert !== false, | |
| 19 | + typeof body.frequency === "string" ? body.frequency : undefined); | |
| 20 | + else return NextResponse.json({ error: "action inconnue" }, { status: 400 }); | |
| 21 | + return NextResponse.json({ ok: true }); | |
| 22 | +} | |
added
src/app/api/sso/events/route.ts
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — ingestion du journal d'interactions (serveur-à-serveur). | |
| 3 | +// Les plateformes poussent les événements comportementaux de leurs membres | |
| 4 | +// (recherche, consultation, favori, masquage…), signés du secret SSO partagé. | |
| 5 | +// POST { client_id, ka_id, ts, sig, events: [{ type, entity_type?, | |
| 6 | +// entity_id?, session_id?, query?, filters?, position?, features?, | |
| 7 | +// dwell_ms?, ts? }] } (max 50 événements par lot) | |
| 8 | +// Respecte le réglage « Historique » du membre (rien n'est stocké si OFF). | |
| 9 | +import { NextRequest, NextResponse } from "next/server"; | |
| 10 | +import { findUserByKaId } from "@/lib/db"; | |
| 11 | +import { verifyClientSig } from "@/lib/sso"; | |
| 12 | +import { ingestEvents, privacyOf } from "@/lib/kaid-data"; | |
| 13 | + | |
| 14 | +export async function POST(req: NextRequest) { | |
| 15 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 16 | + const clientId = String(body.client_id ?? "").slice(0, 30); | |
| 17 | + const kaId = String(body.ka_id ?? "").slice(0, 20); | |
| 18 | + if (!verifyClientSig(clientId, kaId, String(body.ts ?? ""), String(body.sig ?? ""))) | |
| 19 | + return NextResponse.json({ error: "signature invalide" }, { status: 401 }); | |
| 20 | + const user = findUserByKaId(kaId); | |
| 21 | + if (!user) | |
| 22 | + return NextResponse.json({ error: "membre introuvable" }, { status: 404 }); | |
| 23 | + | |
| 24 | + if (!privacyOf(user.id).history) | |
| 25 | + return NextResponse.json({ ok: true, stored: 0, history: false }); | |
| 26 | + | |
| 27 | + const events = Array.isArray(body.events) ? (body.events as Record<string, unknown>[]) : []; | |
| 28 | + const stored = ingestEvents(user.id, clientId, events); | |
| 29 | + return NextResponse.json({ ok: true, stored }); | |
| 30 | +} | |
added
src/app/api/sso/hide/route.ts
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — « Pas pour moi » (serveur-à-serveur). | |
| 3 | +// Masquer une annonce l'exclut des résultats de la plateforme ET nourrit le | |
| 4 | +// signal négatif du moteur de préférences. | |
| 5 | +// POST { client_id, ka_id, ts, sig, item_id, on: true|false, features? } | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { findUserByKaId } from "@/lib/db"; | |
| 8 | +import { verifyClientSig } from "@/lib/sso"; | |
| 9 | +import { setHidden, ingestEvents } from "@/lib/kaid-data"; | |
| 10 | + | |
| 11 | +export async function POST(req: NextRequest) { | |
| 12 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 13 | + const clientId = String(body.client_id ?? "").slice(0, 30); | |
| 14 | + const kaId = String(body.ka_id ?? "").slice(0, 20); | |
| 15 | + if (!verifyClientSig(clientId, kaId, String(body.ts ?? ""), String(body.sig ?? ""))) | |
| 16 | + return NextResponse.json({ error: "signature invalide" }, { status: 401 }); | |
| 17 | + const user = findUserByKaId(kaId); | |
| 18 | + if (!user) | |
| 19 | + return NextResponse.json({ error: "membre introuvable" }, { status: 404 }); | |
| 20 | + | |
| 21 | + const itemId = String(body.item_id ?? "").trim(); | |
| 22 | + if (!itemId) | |
| 23 | + return NextResponse.json({ error: "item_id requis" }, { status: 400 }); | |
| 24 | + const on = body.on !== false; | |
| 25 | + setHidden(user.id, clientId, itemId, on); | |
| 26 | + ingestEvents(user.id, clientId, [{ | |
| 27 | + type: on ? "hide" : "unhide", | |
| 28 | + entity_id: itemId, | |
| 29 | + features: body.features, | |
| 30 | + }]); | |
| 31 | + return NextResponse.json({ ok: true, hidden: on }); | |
| 32 | +} | |
added
src/app/api/sso/prefs/route.ts
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — profil de préférences pour le reranking personnalisé. | |
| 3 | +// Les plateformes lisent ici le profil appris de leur membre (leur univers + | |
| 4 | +// affinités transversales) et la liste de ses éléments masqués. Elles mettent | |
| 5 | +// ce résultat en cache court (60-120 s) et retombent sur le classement de | |
| 6 | +// base à la moindre erreur (fail-open). | |
| 7 | +// GET ?client_id&ka_id&ts&sig | |
| 8 | +// → { ok, personalization, profile: { app, global } | null, hidden: […] } | |
| 9 | +import { NextRequest, NextResponse } from "next/server"; | |
| 10 | +import { findUserByKaId } from "@/lib/db"; | |
| 11 | +import { verifyClientSig } from "@/lib/sso"; | |
| 12 | +import { privacyOf, hiddenOf } from "@/lib/kaid-data"; | |
| 13 | +import { getProfile } from "@/lib/personal"; | |
| 14 | + | |
| 15 | +export async function GET(req: NextRequest) { | |
| 16 | + const q = req.nextUrl.searchParams; | |
| 17 | + const clientId = q.get("client_id") ?? ""; | |
| 18 | + const kaId = q.get("ka_id") ?? ""; | |
| 19 | + if (!verifyClientSig(clientId, kaId, q.get("ts") ?? "", q.get("sig") ?? "")) | |
| 20 | + return NextResponse.json({ error: "signature invalide" }, { status: 401 }); | |
| 21 | + const user = findUserByKaId(kaId); | |
| 22 | + if (!user) | |
| 23 | + return NextResponse.json({ error: "membre introuvable" }, { status: 404 }); | |
| 24 | + | |
| 25 | + const privacy = privacyOf(user.id); | |
| 26 | + const hidden = hiddenOf(user.id, clientId).map((h) => h.item_id); | |
| 27 | + if (!privacy.personalization) | |
| 28 | + return NextResponse.json({ | |
| 29 | + ok: true, personalization: false, profile: null, hidden, | |
| 30 | + }); | |
| 31 | + | |
| 32 | + const full = getProfile(user.id); | |
| 33 | + return NextResponse.json({ | |
| 34 | + ok: true, | |
| 35 | + personalization: true, | |
| 36 | + profile: { | |
| 37 | + app: full.apps[clientId] ?? null, | |
| 38 | + global: full.global, | |
| 39 | + computed_at: full.computed_at, | |
| 40 | + }, | |
| 41 | + hidden, | |
| 42 | + }); | |
| 43 | +} | |
added
src/app/api/sso/saved-searches/route.ts
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — recherches sauvegardées universelles (serveur-à-serveur). | |
| 3 | +// Le hub est LE magasin central : chaque plateforme sauvegarde et relit les | |
| 4 | +// recherches de ses membres, signées du secret SSO partagé. | |
| 5 | +// GET ?client_id&ka_id&ts&sig[&scope=app|all] (défaut : app) | |
| 6 | +// POST { client_id, ka_id, ts, sig, action: "add"|"remove"|"touch"|"alert", | |
| 7 | +// search: { id?, label, query?, filters?, location?, url?, | |
| 8 | +// alert?, frequency? } } | |
| 9 | +import { NextRequest, NextResponse } from "next/server"; | |
| 10 | +import { findUserByKaId } from "@/lib/db"; | |
| 11 | +import { verifyClientSig } from "@/lib/sso"; | |
| 12 | +import { | |
| 13 | + savedSearchesOf, addSavedSearch, removeSavedSearch, touchSavedSearch, | |
| 14 | + setSavedSearchAlert, ingestEvents, | |
| 15 | +} from "@/lib/kaid-data"; | |
| 16 | + | |
| 17 | +function serialize(rows: ReturnType<typeof savedSearchesOf>) { | |
| 18 | + return rows.map((s) => ({ | |
| 19 | + id: s.id, app: s.app, label: s.label, query: s.query, | |
| 20 | + filters: s.filters ? JSON.parse(s.filters) : null, | |
| 21 | + location: s.location, url: s.url, | |
| 22 | + alert_enabled: !!s.alert_enabled, alert_frequency: s.alert_frequency, | |
| 23 | + created_at: s.created_at, last_run_at: s.last_run_at, | |
| 24 | + })); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function GET(req: NextRequest) { | |
| 28 | + const q = req.nextUrl.searchParams; | |
| 29 | + const clientId = q.get("client_id") ?? ""; | |
| 30 | + const kaId = q.get("ka_id") ?? ""; | |
| 31 | + if (!verifyClientSig(clientId, kaId, q.get("ts") ?? "", q.get("sig") ?? "")) | |
| 32 | + return NextResponse.json({ error: "signature invalide" }, { status: 401 }); | |
| 33 | + const user = findUserByKaId(kaId); | |
| 34 | + if (!user) | |
| 35 | + return NextResponse.json({ error: "membre introuvable" }, { status: 404 }); | |
| 36 | + const scope = q.get("scope") === "all" ? undefined : clientId; | |
| 37 | + return NextResponse.json({ ok: true, searches: serialize(savedSearchesOf(user.id, scope)) }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export async function POST(req: NextRequest) { | |
| 41 | + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>; | |
| 42 | + const clientId = String(body.client_id ?? "").slice(0, 30); | |
| 43 | + const kaId = String(body.ka_id ?? "").slice(0, 20); | |
| 44 | + if (!verifyClientSig(clientId, kaId, String(body.ts ?? ""), String(body.sig ?? ""))) | |
| 45 | + return NextResponse.json({ error: "signature invalide" }, { status: 401 }); | |
| 46 | + const user = findUserByKaId(kaId); | |
| 47 | + if (!user) | |
| 48 | + return NextResponse.json({ error: "membre introuvable" }, { status: 404 }); | |
| 49 | + | |
| 50 | + const s = (body.search ?? {}) as Record<string, unknown>; | |
| 51 | + const action = String(body.action ?? "add"); | |
| 52 | + | |
| 53 | + if (action === "remove" && typeof s.id === "number") { | |
| 54 | + removeSavedSearch(user.id, s.id); | |
| 55 | + return NextResponse.json({ ok: true, action }); | |
| 56 | + } | |
| 57 | + if (action === "touch" && typeof s.id === "number") { | |
| 58 | + touchSavedSearch(user.id, s.id); | |
| 59 | + return NextResponse.json({ ok: true, action }); | |
| 60 | + } | |
| 61 | + if (action === "alert" && typeof s.id === "number") { | |
| 62 | + setSavedSearchAlert(user.id, s.id, !!s.alert, | |
| 63 | + typeof s.frequency === "string" ? s.frequency : undefined); | |
| 64 | + return NextResponse.json({ ok: true, action }); | |
| 65 | + } | |
| 66 | + if (action === "add") { | |
| 67 | + const label = String(s.label ?? "").trim(); | |
| 68 | + if (!label) | |
| 69 | + return NextResponse.json({ error: "label requis" }, { status: 400 }); | |
| 70 | + const id = addSavedSearch(user.id, clientId, { | |
| 71 | + label, | |
| 72 | + query: typeof s.query === "string" ? s.query : undefined, | |
| 73 | + filters: s.filters, | |
| 74 | + location: typeof s.location === "string" ? s.location : undefined, | |
| 75 | + url: typeof s.url === "string" ? s.url : undefined, | |
| 76 | + alert: !!s.alert, | |
| 77 | + frequency: typeof s.frequency === "string" ? s.frequency : undefined, | |
| 78 | + }); | |
| 79 | + ingestEvents(user.id, clientId, [{ | |
| 80 | + type: s.alert ? "alert_create" : "saved_search", | |
| 81 | + query: typeof s.query === "string" ? s.query : undefined, | |
| 82 | + filters: s.filters, | |
| 83 | + }]); | |
| 84 | + return NextResponse.json({ ok: true, action, id }); | |
| 85 | + } | |
| 86 | + return NextResponse.json({ error: "action inconnue" }, { status: 400 }); | |
| 87 | +} | |
modified
src/app/compte/page.tsx
+13 −0
@@ -243,6 +243,19 @@ export default async function Compte() { | ||
| 243 | 243 | </h1> |
| 244 | 244 | </section> |
| 245 | 245 | |
| 246 | + {/* ——— Raccourci Mon KA (personnalisation) ——— */} | |
| 247 | + <a | |
| 248 | + href="/mon-ka" | |
| 249 | + className="gk-card rise mt-6 flex items-center gap-4 p-4 transition-colors hover:bg-lime [animation-delay:0.1s]" | |
| 250 | + > | |
| 251 | + <span aria-hidden="true" className="gk-display text-[22px] font-bold">✦</span> | |
| 252 | + <span className="min-w-0 flex-1"> | |
| 253 | + <span className="block text-[14px] font-bold">Mon KA — recommandations, recherches, alertes</span> | |
| 254 | + <span className="block text-[12.5px] text-ink-2">Ce que KA a appris de vous, vos favoris cross-univers et vos réglages de confidentialité.</span> | |
| 255 | + </span> | |
| 256 | + <span aria-hidden="true" className="gk-display text-[18px] font-bold">→</span> | |
| 257 | + </a> | |
| 258 | + | |
| 246 | 259 | {/* ——— Carte de membre Groupe KA ——— */} |
| 247 | 260 | <div |
| 248 | 261 | className="mc rise [animation-delay:0.14s]" |
added
src/app/mon-ka/LearnedChips.tsx
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — « Ce que KA a appris » : chaque préférence déduite est affichée | |
| 3 | +// avec sa force et peut être renforcée (★) ou retirée (✕) — la correction | |
| 4 | +// est immédiate et prime sur l'apprentissage. | |
| 5 | +"use client"; | |
| 6 | + | |
| 7 | +import { useState } from "react"; | |
| 8 | +import { useRouter } from "next/navigation"; | |
| 9 | + | |
| 10 | +export type LearnedChip = { | |
| 11 | + app: string; | |
| 12 | + appLabel: string; | |
| 13 | + dim: string; | |
| 14 | + label: string; | |
| 15 | + value: string; | |
| 16 | + affinity: number; | |
| 17 | + kind: "value" | "range"; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export default function LearnedChips({ items }: { items: LearnedChip[] }) { | |
| 21 | + const [gone, setGone] = useState<Set<string>>(new Set()); | |
| 22 | + const [starred, setStarred] = useState<Set<string>>(new Set()); | |
| 23 | + const [busy, setBusy] = useState(false); | |
| 24 | + const router = useRouter(); | |
| 25 | + const keyOf = (c: LearnedChip) => `${c.app}|${c.dim}|${c.value}`; | |
| 26 | + | |
| 27 | + async function act(c: LearnedChip, mode: "ban" | "boost"): Promise<void> { | |
| 28 | + setBusy(true); | |
| 29 | + try { | |
| 30 | + await fetch("/api/monka/prefs", { | |
| 31 | + method: "POST", | |
| 32 | + headers: { "Content-Type": "application/json" }, | |
| 33 | + body: JSON.stringify({ app: c.app, dim: c.dim, value: c.value, mode }), | |
| 34 | + }); | |
| 35 | + if (mode === "ban") setGone(new Set([...gone, keyOf(c)])); | |
| 36 | + else setStarred(new Set([...starred, keyOf(c)])); | |
| 37 | + router.refresh(); | |
| 38 | + } finally { | |
| 39 | + setBusy(false); | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + const byApp = new Map<string, LearnedChip[]>(); | |
| 44 | + for (const c of items) { | |
| 45 | + if (gone.has(keyOf(c))) continue; | |
| 46 | + if (!byApp.has(c.appLabel)) byApp.set(c.appLabel, []); | |
| 47 | + byApp.get(c.appLabel)!.push(c); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="mt-4 space-y-4"> | |
| 52 | + {[...byApp.entries()].map(([label, chips]) => ( | |
| 53 | + <div key={label}> | |
| 54 | + <p className="klabel">{label}</p> | |
| 55 | + <ul className="mt-2 flex flex-wrap gap-2"> | |
| 56 | + {chips.map((c) => ( | |
| 57 | + <li | |
| 58 | + key={keyOf(c)} | |
| 59 | + className="gk-card inline-flex items-center gap-2 px-2.5 py-1.5" | |
| 60 | + > | |
| 61 | + <span className="text-[12px] text-ink-2">{c.label}</span> | |
| 62 | + <span className="text-[12.5px] font-semibold"> | |
| 63 | + {c.kind === "range" ? c.value : c.value.replace(/^\w/, (m) => m.toUpperCase())} | |
| 64 | + </span> | |
| 65 | + <span | |
| 66 | + aria-hidden="true" | |
| 67 | + className="h-[5px] w-10 overflow-hidden rounded-full bg-[rgba(20,24,20,0.12)]" | |
| 68 | + title={`Force : ${Math.round(c.affinity * 100)} %`} | |
| 69 | + > | |
| 70 | + <span | |
| 71 | + className="block h-full rounded-full bg-ink" | |
| 72 | + style={{ width: `${Math.round(c.affinity * 100)}%` }} | |
| 73 | + /> | |
| 74 | + </span> | |
| 75 | + {c.kind === "value" && ( | |
| 76 | + <button | |
| 77 | + type="button" | |
| 78 | + disabled={busy || starred.has(keyOf(c))} | |
| 79 | + title="Renforcer cette préférence" | |
| 80 | + onClick={() => act(c, "boost")} | |
| 81 | + className="text-[13px] leading-none disabled:opacity-40" | |
| 82 | + > | |
| 83 | + {starred.has(keyOf(c)) ? "★" : "☆"} | |
| 84 | + </button> | |
| 85 | + )} | |
| 86 | + <button | |
| 87 | + type="button" | |
| 88 | + disabled={busy} | |
| 89 | + title="Retirer cette préférence" | |
| 90 | + onClick={() => act(c, "ban")} | |
| 91 | + className="text-[13px] leading-none text-ink-2 hover:text-ink" | |
| 92 | + > | |
| 93 | + ✕ | |
| 94 | + </button> | |
| 95 | + </li> | |
| 96 | + ))} | |
| 97 | + </ul> | |
| 98 | + </div> | |
| 99 | + ))} | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | +} | |
added
src/app/mon-ka/PrivacyPanel.tsx
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — panneau confidentialité : trois interrupteurs (personnalisation, | |
| 3 | +// historique, recommandations), effacement de l'historique, réinitialisation | |
| 4 | +// des recommandations et export JSON (Loi 25). | |
| 5 | +"use client"; | |
| 6 | + | |
| 7 | +import { useState } from "react"; | |
| 8 | +import { useRouter } from "next/navigation"; | |
| 9 | + | |
| 10 | +type Privacy = { personalization: boolean; history: boolean; recos: boolean }; | |
| 11 | + | |
| 12 | +const TOGGLES: Array<{ key: keyof Privacy; title: string; desc: string }> = [ | |
| 13 | + { | |
| 14 | + key: "personalization", | |
| 15 | + title: "Personnalisation", | |
| 16 | + desc: "Vos résultats de recherche sont reclassés selon vos préférences apprises. OFF : classement identique pour tous.", | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + key: "history", | |
| 20 | + title: "Historique", | |
| 21 | + desc: "Les plateformes journalisent vos interactions (recherches, fiches, ♥) pour apprendre. OFF : plus rien n'est enregistré.", | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + key: "recos", | |
| 25 | + title: "Recommandations personnalisées", | |
| 26 | + desc: "Le fil « Pour vous » et les suggestions proactives. OFF : plus de recommandations, la recherche reste complète.", | |
| 27 | + }, | |
| 28 | +]; | |
| 29 | + | |
| 30 | +export default function PrivacyPanel({ | |
| 31 | + initial, | |
| 32 | + eventsCount, | |
| 33 | +}: { | |
| 34 | + initial: Privacy; | |
| 35 | + eventsCount: number; | |
| 36 | +}) { | |
| 37 | + const [privacy, setPrivacy] = useState<Privacy>(initial); | |
| 38 | + const [busy, setBusy] = useState(false); | |
| 39 | + const router = useRouter(); | |
| 40 | + | |
| 41 | + async function post(body: Record<string, unknown>): Promise<void> { | |
| 42 | + setBusy(true); | |
| 43 | + try { | |
| 44 | + const r = await fetch("/api/monka/privacy", { | |
| 45 | + method: "POST", | |
| 46 | + headers: { "Content-Type": "application/json" }, | |
| 47 | + body: JSON.stringify(body), | |
| 48 | + }); | |
| 49 | + const data = await r.json(); | |
| 50 | + if (data.privacy) setPrivacy(data.privacy); | |
| 51 | + router.refresh(); | |
| 52 | + } finally { | |
| 53 | + setBusy(false); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <div> | |
| 59 | + <div className="space-y-4"> | |
| 60 | + {TOGGLES.map((t) => ( | |
| 61 | + <div key={t.key} className="flex items-start gap-3"> | |
| 62 | + <button | |
| 63 | + type="button" | |
| 64 | + role="switch" | |
| 65 | + aria-checked={privacy[t.key]} | |
| 66 | + disabled={busy} | |
| 67 | + onClick={() => post({ [t.key]: !privacy[t.key] })} | |
| 68 | + className={`mt-0.5 inline-flex h-6 w-11 flex-none items-center rounded-full border border-ink transition-colors ${ | |
| 69 | + privacy[t.key] ? "bg-ink" : "bg-transparent" | |
| 70 | + }`} | |
| 71 | + > | |
| 72 | + <span | |
| 73 | + className={`h-4 w-4 rounded-full transition-transform ${ | |
| 74 | + privacy[t.key] | |
| 75 | + ? "translate-x-[22px] bg-[#d9f26b]" | |
| 76 | + : "translate-x-[3px] bg-ink" | |
| 77 | + }`} | |
| 78 | + /> | |
| 79 | + </button> | |
| 80 | + <div> | |
| 81 | + <p className="text-[13.5px] font-semibold"> | |
| 82 | + {t.title}{" "} | |
| 83 | + <span className="klabel ml-1">{privacy[t.key] ? "ON" : "OFF"}</span> | |
| 84 | + </p> | |
| 85 | + <p className="mt-0.5 text-[12.5px] leading-relaxed text-ink-2"> | |
| 86 | + {t.desc} | |
| 87 | + </p> | |
| 88 | + </div> | |
| 89 | + </div> | |
| 90 | + ))} | |
| 91 | + </div> | |
| 92 | + | |
| 93 | + <div className="mt-6 flex flex-wrap gap-2 border-t border-[rgba(20,24,20,0.12)] pt-5"> | |
| 94 | + <button | |
| 95 | + type="button" | |
| 96 | + disabled={busy || eventsCount === 0} | |
| 97 | + onClick={() => { | |
| 98 | + if (confirm("Effacer tout votre historique d'interactions ? (irréversible)")) | |
| 99 | + post({ clear_history: true }); | |
| 100 | + }} | |
| 101 | + className="rounded-md border border-ink px-3 py-1.5 text-[12.5px] font-semibold disabled:opacity-40" | |
| 102 | + > | |
| 103 | + Effacer mon historique ({eventsCount}) | |
| 104 | + </button> | |
| 105 | + <button | |
| 106 | + type="button" | |
| 107 | + disabled={busy} | |
| 108 | + onClick={() => { | |
| 109 | + if ( | |
| 110 | + confirm( | |
| 111 | + "Réinitialiser vos recommandations ? Historique, préférences apprises, corrections et annonces masquées seront effacés. Vos favoris et recherches sauvegardées restent.", | |
| 112 | + ) | |
| 113 | + ) | |
| 114 | + post({ reset: true }); | |
| 115 | + }} | |
| 116 | + className="rounded-md border border-ink px-3 py-1.5 text-[12.5px] font-semibold disabled:opacity-40" | |
| 117 | + > | |
| 118 | + Réinitialiser mes recommandations | |
| 119 | + </button> | |
| 120 | + <a | |
| 121 | + href="/api/monka/export" | |
| 122 | + className="rounded-md border border-ink bg-ink px-3 py-1.5 text-[12.5px] font-semibold text-[#f5f3ee]" | |
| 123 | + > | |
| 124 | + Exporter mes données (JSON) | |
| 125 | + </a> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} | |
added
src/app/mon-ka/SearchList.tsx
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — recherches sauvegardées : relancer (lien direct sur la | |
| 3 | +// plateforme), activer/couper l'alerte, supprimer. | |
| 4 | +"use client"; | |
| 5 | + | |
| 6 | +import { useState } from "react"; | |
| 7 | +import { useRouter } from "next/navigation"; | |
| 8 | + | |
| 9 | +export type SearchRow = { | |
| 10 | + id: number; | |
| 11 | + app: string; | |
| 12 | + appLabel: string; | |
| 13 | + label: string; | |
| 14 | + query: string | null; | |
| 15 | + url: string | null; | |
| 16 | + alert: boolean; | |
| 17 | + frequency: string; | |
| 18 | + created_at: string; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export default function SearchList({ searches }: { searches: SearchRow[] }) { | |
| 22 | + const [rows, setRows] = useState(searches); | |
| 23 | + const [busy, setBusy] = useState(false); | |
| 24 | + const router = useRouter(); | |
| 25 | + | |
| 26 | + async function post(body: Record<string, unknown>): Promise<void> { | |
| 27 | + setBusy(true); | |
| 28 | + try { | |
| 29 | + await fetch("/api/monka/searches", { | |
| 30 | + method: "POST", | |
| 31 | + headers: { "Content-Type": "application/json" }, | |
| 32 | + body: JSON.stringify(body), | |
| 33 | + }); | |
| 34 | + router.refresh(); | |
| 35 | + } finally { | |
| 36 | + setBusy(false); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <ul className="mt-4 space-y-2"> | |
| 42 | + {rows.map((s) => ( | |
| 43 | + <li key={s.id} className="gk-card flex flex-wrap items-center gap-3 p-3"> | |
| 44 | + <div className="min-w-0 flex-1"> | |
| 45 | + {s.url ? ( | |
| 46 | + <a | |
| 47 | + href={s.url} | |
| 48 | + className="block truncate text-[13.5px] font-semibold underline-offset-2 hover:underline" | |
| 49 | + > | |
| 50 | + {s.label} | |
| 51 | + </a> | |
| 52 | + ) : ( | |
| 53 | + <p className="truncate text-[13.5px] font-semibold">{s.label}</p> | |
| 54 | + )} | |
| 55 | + <p className="truncate text-[12px] text-ink-2"> | |
| 56 | + {s.appLabel} | |
| 57 | + {s.query ? ` · « ${s.query} »` : ""} | |
| 58 | + </p> | |
| 59 | + </div> | |
| 60 | + <button | |
| 61 | + type="button" | |
| 62 | + disabled={busy} | |
| 63 | + onClick={() => { | |
| 64 | + const next = !s.alert; | |
| 65 | + setRows(rows.map((r) => (r.id === s.id ? { ...r, alert: next } : r))); | |
| 66 | + post({ id: s.id, action: "alert", alert: next }); | |
| 67 | + }} | |
| 68 | + className={`rounded-md border border-ink px-2.5 py-1 text-[12px] font-semibold ${ | |
| 69 | + s.alert ? "bg-ink text-[#f5f3ee]" : "" | |
| 70 | + }`} | |
| 71 | + title={s.alert ? `Alerte ${s.frequency} active` : "Activer l'alerte"} | |
| 72 | + > | |
| 73 | + {s.alert ? "🔔 Alerte ON" : "🔕 Alerte OFF"} | |
| 74 | + </button> | |
| 75 | + <button | |
| 76 | + type="button" | |
| 77 | + disabled={busy} | |
| 78 | + onClick={() => { | |
| 79 | + if (confirm(`Supprimer « ${s.label} » ?`)) { | |
| 80 | + setRows(rows.filter((r) => r.id !== s.id)); | |
| 81 | + post({ id: s.id, action: "remove" }); | |
| 82 | + } | |
| 83 | + }} | |
| 84 | + className="text-[13px] text-ink-2 hover:text-ink" | |
| 85 | + title="Supprimer" | |
| 86 | + > | |
| 87 | + ✕ | |
| 88 | + </button> | |
| 89 | + </li> | |
| 90 | + ))} | |
| 91 | + </ul> | |
| 92 | + ); | |
| 93 | +} | |
added
src/app/mon-ka/page.tsx
+392 −0
@@ -0,0 +1,392 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Mon KA — centre de contrôle de la personnalisation Groupe KA : | |
| 3 | +// « Pour vous », favoris cross-univers, recherches sauvegardées & alertes, | |
| 4 | +// « Ce que KA a appris » (corrigeable), activité récente, confidentialité. | |
| 5 | +// La page vit au hub : les plateformes n'affichent que des raccourcis vers ici. | |
| 6 | +import type { Metadata } from "next"; | |
| 7 | +import { redirect } from "next/navigation"; | |
| 8 | +import { getSessionUser } from "@/lib/auth"; | |
| 9 | +import { ensureKaId, favoritesOf, type FavoriteRow } from "@/lib/db"; | |
| 10 | +import { SSO_CLIENTS } from "@/lib/sso"; | |
| 11 | +import { | |
| 12 | + privacyOf, savedSearchesOf, eventsOf, eventsCount, hiddenOf, | |
| 13 | +} from "@/lib/kaid-data"; | |
| 14 | +import { getProfile, learnedSummary, dimLabel, type LearnedItem } from "@/lib/personal"; | |
| 15 | +import RemoveFavorite from "../compte/RemoveFavorite"; | |
| 16 | +import PrivacyPanel from "./PrivacyPanel"; | |
| 17 | +import LearnedChips from "./LearnedChips"; | |
| 18 | +import SearchList from "./SearchList"; | |
| 19 | + | |
| 20 | +export const metadata: Metadata = { | |
| 21 | + title: "Mon KA", | |
| 22 | + description: | |
| 23 | + "Votre univers Groupe KA : recommandations, favoris, recherches, alertes et préférences apprises — sous votre contrôle.", | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export const dynamic = "force-dynamic"; | |
| 27 | + | |
| 28 | +const APP_ACCENTS: Record<string, string> = { | |
| 29 | + "lou-ka": "#d9f26b", | |
| 30 | + "immo-ka": "#e23744", | |
| 31 | + "vrai-prix": "#ff5148", | |
| 32 | + valoplex: "#ff9f45", | |
| 33 | + "auto-ka": "#ff5a2a", | |
| 34 | + "fabri-ka": "#C4532E", | |
| 35 | + "food-ka": "#d9f26b", | |
| 36 | + "resto-ka": "#e23744", | |
| 37 | + "sorti-ka": "#ff9f45", | |
| 38 | + "crea-ka": "#d9f26b", | |
| 39 | + "job-ka": "#ff5a2a", | |
| 40 | + "trouve-ka": "#d9f26b", | |
| 41 | +}; | |
| 42 | + | |
| 43 | +const EVENT_LABELS: Record<string, string> = { | |
| 44 | + search: "Recherche", | |
| 45 | + click: "Résultat ouvert", | |
| 46 | + detail_view: "Fiche consultée", | |
| 47 | + detail_dwell: "Fiche lue en détail", | |
| 48 | + favorite: "Ajouté aux favoris", | |
| 49 | + unfavorite: "Retiré des favoris", | |
| 50 | + share: "Partagé", | |
| 51 | + compare: "Comparé", | |
| 52 | + hide: "Masqué", | |
| 53 | + unhide: "Démasqué", | |
| 54 | + dismiss: "Écarté", | |
| 55 | + map_open: "Carte ouverte", | |
| 56 | + map_marker_click: "Repère de carte", | |
| 57 | + filter_change: "Filtres ajustés", | |
| 58 | + external_click: "Annonce d'origine visitée", | |
| 59 | + alert_create: "Alerte créée", | |
| 60 | + saved_search: "Recherche sauvegardée", | |
| 61 | + return_visit: "Visite de retour", | |
| 62 | +}; | |
| 63 | + | |
| 64 | +function appLabel(app: string): string { | |
| 65 | + return SSO_CLIENTS[app]?.label ?? app; | |
| 66 | +} | |
| 67 | + | |
| 68 | +function AppDot({ app }: { app: string }) { | |
| 69 | + return ( | |
| 70 | + <span | |
| 71 | + aria-hidden="true" | |
| 72 | + className="inline-flex h-[14px] w-[14px] flex-none items-center justify-center rounded-[3px] bg-ink" | |
| 73 | + > | |
| 74 | + <span | |
| 75 | + className="h-[8px] w-[8px] rounded-[2px]" | |
| 76 | + style={{ background: APP_ACCENTS[app] ?? "#d9f26b" }} | |
| 77 | + /> | |
| 78 | + </span> | |
| 79 | + ); | |
| 80 | +} | |
| 81 | + | |
| 82 | +function fmtWhen(iso: string): string { | |
| 83 | + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z"); | |
| 84 | + if (Number.isNaN(d.getTime())) return ""; | |
| 85 | + return d.toLocaleDateString("fr-CA", { | |
| 86 | + day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", | |
| 87 | + }); | |
| 88 | +} | |
| 89 | + | |
| 90 | +/* ---------- « Pour vous » : agrégé depuis les données existantes ---------- */ | |
| 91 | + | |
| 92 | +type FeedItem = { icon: string; text: string; href?: string; app?: string }; | |
| 93 | + | |
| 94 | +function buildFeed( | |
| 95 | + learned: LearnedItem[], | |
| 96 | + searches: ReturnType<typeof savedSearchesOf>, | |
| 97 | + favs: FavoriteRow[], | |
| 98 | +): FeedItem[] { | |
| 99 | + const feed: FeedItem[] = []; | |
| 100 | + for (const s of searches.slice(0, 4)) | |
| 101 | + feed.push({ | |
| 102 | + icon: "◎", | |
| 103 | + app: s.app, | |
| 104 | + text: `Reprendre « ${s.label} » sur ${appLabel(s.app)}${s.alert_enabled ? " · alerte active" : ""}`, | |
| 105 | + href: s.url ?? undefined, | |
| 106 | + }); | |
| 107 | + const topLoc = learned.find((l) => ["city", "region", "quartier", "sector", "ville"].includes(l.dim)); | |
| 108 | + if (topLoc) | |
| 109 | + feed.push({ | |
| 110 | + icon: "◈", | |
| 111 | + app: topLoc.app, | |
| 112 | + text: `Vos recherches gravitent autour de « ${topLoc.value} » — les nouveautés de ce coin remontent maintenant en premier sur ${appLabel(topLoc.app)}.`, | |
| 113 | + }); | |
| 114 | + for (const f of favs.slice(0, 3)) | |
| 115 | + feed.push({ | |
| 116 | + icon: "♥", | |
| 117 | + app: f.app, | |
| 118 | + text: `Revoir « ${f.title} » (${appLabel(f.app)})`, | |
| 119 | + href: f.url ?? undefined, | |
| 120 | + }); | |
| 121 | + return feed.slice(0, 8); | |
| 122 | +} | |
| 123 | + | |
| 124 | +export default async function MonKa() { | |
| 125 | + const user = await getSessionUser(); | |
| 126 | + if (!user) redirect("/connexion?next=%2Fmon-ka"); | |
| 127 | + const kaId = user.kaId ?? ensureKaId(user.id); | |
| 128 | + const privacy = privacyOf(user.id); | |
| 129 | + const favs = favoritesOf(user.id); | |
| 130 | + const searches = savedSearchesOf(user.id); | |
| 131 | + const nEvents = eventsCount(user.id); | |
| 132 | + const hidden = hiddenOf(user.id); | |
| 133 | + const profile = privacy.personalization ? getProfile(user.id) : null; | |
| 134 | + const learned = profile ? learnedSummary(profile) : []; | |
| 135 | + const recent = privacy.history ? eventsOf(user.id, { limit: 30 }) : []; | |
| 136 | + const feed = privacy.recos ? buildFeed(learned, searches, favs) : []; | |
| 137 | + | |
| 138 | + const byApp = new Map<string, FavoriteRow[]>(); | |
| 139 | + for (const f of favs) { | |
| 140 | + if (!byApp.has(f.app)) byApp.set(f.app, []); | |
| 141 | + byApp.get(f.app)!.push(f); | |
| 142 | + } | |
| 143 | + | |
| 144 | + const universesUsed = [...new Set([ | |
| 145 | + ...favs.map((f) => f.app), | |
| 146 | + ...searches.map((s) => s.app), | |
| 147 | + ...learned.map((l) => l.app), | |
| 148 | + ])]; | |
| 149 | + | |
| 150 | + return ( | |
| 151 | + <main className="mx-auto max-w-2xl px-4 pb-16 sm:px-6"> | |
| 152 | + <section className="rise mt-8"> | |
| 153 | + <p className="kicker">Mon KA · {kaId}</p> | |
| 154 | + <h1 className="gk-display mt-2 text-[28px] font-bold uppercase sm:text-[34px]"> | |
| 155 | + Plus vous utilisez les plateformes ·Ka, plus elles vous connaissent | |
| 156 | + </h1> | |
| 157 | + <p className="mt-3 text-[14px] leading-relaxed text-ink-2"> | |
| 158 | + Vos favoris, recherches, alertes et préférences — un seul compte pour | |
| 159 | + tout le Groupe KA. Tout ce qui est appris ici vous appartient : | |
| 160 | + corrigez-le, effacez-le ou désactivez-le quand vous voulez.{" "} | |
| 161 | + <a href="/compte" className="underline underline-offset-2"> | |
| 162 | + Gérer mon compte → | |
| 163 | + </a> | |
| 164 | + </p> | |
| 165 | + {universesUsed.length > 0 && ( | |
| 166 | + <p className="klabel mt-4 flex flex-wrap items-center gap-2"> | |
| 167 | + {universesUsed.map((app) => ( | |
| 168 | + <span key={app} className="inline-flex items-center gap-1.5"> | |
| 169 | + <AppDot app={app} /> {appLabel(app)} | |
| 170 | + </span> | |
| 171 | + ))} | |
| 172 | + </p> | |
| 173 | + )} | |
| 174 | + </section> | |
| 175 | + | |
| 176 | + {/* ——— Pour vous ——— */} | |
| 177 | + <section className="rise mt-10 [animation-delay:0.05s]"> | |
| 178 | + <p className="kicker">Pour vous</p> | |
| 179 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 180 | + Ce qui vaut le détour aujourd'hui | |
| 181 | + </h2> | |
| 182 | + {!privacy.recos ? ( | |
| 183 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 184 | + Les recommandations personnalisées sont désactivées — réactivez-les | |
| 185 | + dans « Confidentialité » ci-dessous quand vous voulez. | |
| 186 | + </div> | |
| 187 | + ) : feed.length === 0 ? ( | |
| 188 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 189 | + Utilisez les plateformes ·Ka (une recherche, un ♥, une alerte) et | |
| 190 | + ce fil se remplira tout seul — sans jamais rien exiger de vous. | |
| 191 | + </div> | |
| 192 | + ) : ( | |
| 193 | + <ul className="mt-4 space-y-2"> | |
| 194 | + {feed.map((item, i) => ( | |
| 195 | + <li key={i} className="gk-card flex items-center gap-3 p-3"> | |
| 196 | + <span aria-hidden="true" className="text-[15px]">{item.icon}</span> | |
| 197 | + {item.app && <AppDot app={item.app} />} | |
| 198 | + {item.href ? ( | |
| 199 | + <a | |
| 200 | + href={item.href} | |
| 201 | + className="text-[13.5px] underline-offset-2 hover:underline" | |
| 202 | + > | |
| 203 | + {item.text} | |
| 204 | + </a> | |
| 205 | + ) : ( | |
| 206 | + <span className="text-[13.5px]">{item.text}</span> | |
| 207 | + )} | |
| 208 | + </li> | |
| 209 | + ))} | |
| 210 | + </ul> | |
| 211 | + )} | |
| 212 | + </section> | |
| 213 | + | |
| 214 | + {/* ——— Ce que KA a appris ——— */} | |
| 215 | + <section className="rise mt-10 [animation-delay:0.1s]"> | |
| 216 | + <p className="kicker">Ce que KA a appris · {nEvents} interactions</p> | |
| 217 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 218 | + Vos préférences, déduites — et corrigeables | |
| 219 | + </h2> | |
| 220 | + {!privacy.personalization ? ( | |
| 221 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 222 | + La personnalisation est désactivée : aucun profil de préférences | |
| 223 | + n'est calculé ni utilisé. | |
| 224 | + </div> | |
| 225 | + ) : learned.length === 0 ? ( | |
| 226 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 227 | + Rien encore — dès que vous consulterez des annonces, KA notera ce | |
| 228 | + qui revient (villes, prix, marques, cuisines…) pour mieux classer | |
| 229 | + vos résultats. | |
| 230 | + </div> | |
| 231 | + ) : ( | |
| 232 | + <LearnedChips | |
| 233 | + items={learned.map((l) => ({ | |
| 234 | + app: l.app, | |
| 235 | + appLabel: appLabel(l.app), | |
| 236 | + dim: l.dim, | |
| 237 | + label: l.label, | |
| 238 | + value: l.value, | |
| 239 | + affinity: l.affinity, | |
| 240 | + kind: l.kind, | |
| 241 | + }))} | |
| 242 | + /> | |
| 243 | + )} | |
| 244 | + {hidden.length > 0 && ( | |
| 245 | + <p className="klabel mt-3"> | |
| 246 | + {hidden.length} annonce{hidden.length > 1 ? "s" : ""} masquée | |
| 247 | + {hidden.length > 1 ? "s" : ""} (« Pas pour moi ») — elles | |
| 248 | + n'apparaîtront plus dans vos résultats. | |
| 249 | + </p> | |
| 250 | + )} | |
| 251 | + </section> | |
| 252 | + | |
| 253 | + {/* ——— Recherches sauvegardées & alertes ——— */} | |
| 254 | + <section className="rise mt-10 [animation-delay:0.15s]"> | |
| 255 | + <p className="kicker">Recherches sauvegardées · alertes</p> | |
| 256 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 257 | + KA surveille le Québec pour vous | |
| 258 | + </h2> | |
| 259 | + {searches.length === 0 ? ( | |
| 260 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 261 | + Sauvegardez une recherche sur n'importe quelle plateforme ·Ka | |
| 262 | + (« 3½ à Gatineau », « Corolla 2021+ »…) et retrouvez-la ici, avec | |
| 263 | + alerte si vous voulez. | |
| 264 | + </div> | |
| 265 | + ) : ( | |
| 266 | + <SearchList | |
| 267 | + searches={searches.map((s) => ({ | |
| 268 | + id: s.id, | |
| 269 | + app: s.app, | |
| 270 | + appLabel: appLabel(s.app), | |
| 271 | + label: s.label, | |
| 272 | + query: s.query, | |
| 273 | + url: s.url, | |
| 274 | + alert: !!s.alert_enabled, | |
| 275 | + frequency: s.alert_frequency, | |
| 276 | + created_at: s.created_at, | |
| 277 | + }))} | |
| 278 | + /> | |
| 279 | + )} | |
| 280 | + </section> | |
| 281 | + | |
| 282 | + {/* ——— Favoris cross-univers ——— */} | |
| 283 | + <section className="rise mt-10 [animation-delay:0.2s]"> | |
| 284 | + <p className="kicker">Mes favoris KA · {favs.length}</p> | |
| 285 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 286 | + Tous mes coups de cœur, tous univers | |
| 287 | + </h2> | |
| 288 | + {favs.length === 0 ? ( | |
| 289 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 290 | + Tapez ♥ sur un logement, une auto, un resto, un événement ou un | |
| 291 | + emploi — tout se retrouve ici. | |
| 292 | + </div> | |
| 293 | + ) : ( | |
| 294 | + <div className="mt-4 space-y-6"> | |
| 295 | + {[...byApp.entries()].map(([app, items]) => ( | |
| 296 | + <div key={app}> | |
| 297 | + <p className="klabel flex items-center gap-2"> | |
| 298 | + <AppDot app={app} /> {appLabel(app)} · {items.length} | |
| 299 | + </p> | |
| 300 | + <ul className="mt-2 grid gap-3 sm:grid-cols-2"> | |
| 301 | + {items.map((f) => ( | |
| 302 | + <li key={f.item_id} className="gk-card flex items-center gap-3 p-3"> | |
| 303 | + {f.image_url ? ( | |
| 304 | + // eslint-disable-next-line @next/next/no-img-element | |
| 305 | + <img | |
| 306 | + src={f.image_url} | |
| 307 | + alt="" | |
| 308 | + width={56} | |
| 309 | + height={56} | |
| 310 | + referrerPolicy="no-referrer" | |
| 311 | + className="h-14 w-14 flex-none rounded-md border border-[rgba(20,24,20,0.2)] object-cover" | |
| 312 | + /> | |
| 313 | + ) : ( | |
| 314 | + <span className="flex h-14 w-14 flex-none items-center justify-center rounded-md border border-[rgba(20,24,20,0.15)] text-[18px]"> | |
| 315 | + ♥ | |
| 316 | + </span> | |
| 317 | + )} | |
| 318 | + <div className="min-w-0 flex-1"> | |
| 319 | + {f.url ? ( | |
| 320 | + <a | |
| 321 | + href={f.url} | |
| 322 | + className="block truncate text-[13.5px] font-semibold underline-offset-2 hover:underline" | |
| 323 | + > | |
| 324 | + {f.title} | |
| 325 | + </a> | |
| 326 | + ) : ( | |
| 327 | + <p className="truncate text-[13.5px] font-semibold">{f.title}</p> | |
| 328 | + )} | |
| 329 | + <p className="truncate text-[12px] text-ink-2"> | |
| 330 | + {[f.price_label, f.subtitle].filter(Boolean).join(" · ") || "—"} | |
| 331 | + </p> | |
| 332 | + </div> | |
| 333 | + <RemoveFavorite app={f.app} itemId={f.item_id} /> | |
| 334 | + </li> | |
| 335 | + ))} | |
| 336 | + </ul> | |
| 337 | + </div> | |
| 338 | + ))} | |
| 339 | + </div> | |
| 340 | + )} | |
| 341 | + </section> | |
| 342 | + | |
| 343 | + {/* ——— Activité récente ——— */} | |
| 344 | + <section className="rise mt-10 [animation-delay:0.25s]"> | |
| 345 | + <p className="kicker">Activité récente</p> | |
| 346 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 347 | + Votre journal — visible de vous seul | |
| 348 | + </h2> | |
| 349 | + {!privacy.history ? ( | |
| 350 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 351 | + L'historique est désactivé : les plateformes ne journalisent | |
| 352 | + plus vos interactions. | |
| 353 | + </div> | |
| 354 | + ) : recent.length === 0 ? ( | |
| 355 | + <div className="gk-card mt-4 p-6 text-center text-[13.5px] text-ink-2"> | |
| 356 | + Aucune interaction enregistrée pour l'instant. | |
| 357 | + </div> | |
| 358 | + ) : ( | |
| 359 | + <ul className="gk-card mt-4 divide-y divide-[rgba(20,24,20,0.08)]"> | |
| 360 | + {recent.map((e) => ( | |
| 361 | + <li key={e.id} className="flex items-center gap-3 px-4 py-2.5"> | |
| 362 | + <AppDot app={e.app} /> | |
| 363 | + <span className="min-w-0 flex-1 truncate text-[13px]"> | |
| 364 | + {EVENT_LABELS[e.event_type] ?? e.event_type} | |
| 365 | + {e.query ? ` — « ${e.query} »` : ""} | |
| 366 | + {!e.query && e.entity_id ? ` — ${e.entity_id}` : ""} | |
| 367 | + </span> | |
| 368 | + <span className="flex-none text-[11.5px] text-ink-2"> | |
| 369 | + {fmtWhen(e.created_at)} | |
| 370 | + </span> | |
| 371 | + </li> | |
| 372 | + ))} | |
| 373 | + </ul> | |
| 374 | + )} | |
| 375 | + </section> | |
| 376 | + | |
| 377 | + {/* ——— Confidentialité ——— */} | |
| 378 | + <section className="rise mt-10 [animation-delay:0.3s]"> | |
| 379 | + <p className="kicker">Confidentialité · Loi 25</p> | |
| 380 | + <h2 className="gk-display mt-2 text-[20px] font-bold uppercase"> | |
| 381 | + Vous décidez, KA obéit | |
| 382 | + </h2> | |
| 383 | + <div className="gk-card mt-4 p-5 sm:p-6"> | |
| 384 | + <PrivacyPanel | |
| 385 | + initial={privacy} | |
| 386 | + eventsCount={nEvents} | |
| 387 | + /> | |
| 388 | + </div> | |
| 389 | + </section> | |
| 390 | + </main> | |
| 391 | + ); | |
| 392 | +} | |
added
src/lib/kaid-data.ts
+436 −0
@@ -0,0 +1,436 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — couche de données de la personnalisation Groupe KA. | |
| 3 | +// Le hub est LE feature store du groupe : événements comportementaux, | |
| 4 | +// recherches sauvegardées (+ alertes), éléments masqués, profil de | |
| 5 | +// préférences calculé (cache), réglages de confidentialité. | |
| 6 | +// Migrations idempotentes exécutées à l'import (même patron que db.ts). | |
| 7 | +import { db } from "./db"; | |
| 8 | + | |
| 9 | +/* ---------- migrations ---------- */ | |
| 10 | + | |
| 11 | +db.exec(` | |
| 12 | + CREATE TABLE IF NOT EXISTS user_events ( | |
| 13 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 14 | + user_id INTEGER NOT NULL, | |
| 15 | + app TEXT NOT NULL, | |
| 16 | + session_id TEXT, | |
| 17 | + event_type TEXT NOT NULL, | |
| 18 | + entity_type TEXT, | |
| 19 | + entity_id TEXT, | |
| 20 | + query TEXT, | |
| 21 | + filters TEXT, | |
| 22 | + position INTEGER, | |
| 23 | + metadata TEXT, | |
| 24 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 25 | + ); | |
| 26 | + CREATE INDEX IF NOT EXISTS user_events_user ON user_events(user_id, created_at); | |
| 27 | + CREATE INDEX IF NOT EXISTS user_events_user_app ON user_events(user_id, app); | |
| 28 | + | |
| 29 | + CREATE TABLE IF NOT EXISTS saved_searches ( | |
| 30 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 31 | + user_id INTEGER NOT NULL, | |
| 32 | + app TEXT NOT NULL, | |
| 33 | + label TEXT NOT NULL, | |
| 34 | + query TEXT, | |
| 35 | + filters TEXT, | |
| 36 | + location TEXT, | |
| 37 | + url TEXT, | |
| 38 | + alert_enabled INTEGER NOT NULL DEFAULT 0, | |
| 39 | + alert_frequency TEXT NOT NULL DEFAULT 'daily', | |
| 40 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 41 | + last_run_at TEXT, | |
| 42 | + last_triggered_at TEXT, | |
| 43 | + fingerprint TEXT, | |
| 44 | + UNIQUE(user_id, app, fingerprint) | |
| 45 | + ); | |
| 46 | + CREATE INDEX IF NOT EXISTS saved_searches_user ON saved_searches(user_id, app); | |
| 47 | + | |
| 48 | + CREATE TABLE IF NOT EXISTS hidden_items ( | |
| 49 | + user_id INTEGER NOT NULL, | |
| 50 | + app TEXT NOT NULL, | |
| 51 | + item_id TEXT NOT NULL, | |
| 52 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 53 | + PRIMARY KEY (user_id, app, item_id) | |
| 54 | + ); | |
| 55 | + | |
| 56 | + CREATE TABLE IF NOT EXISTS user_prefs ( | |
| 57 | + user_id INTEGER PRIMARY KEY, | |
| 58 | + profile TEXT NOT NULL, | |
| 59 | + events_n INTEGER NOT NULL DEFAULT 0, | |
| 60 | + updated_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 61 | + ); | |
| 62 | + | |
| 63 | + CREATE TABLE IF NOT EXISTS pref_overrides ( | |
| 64 | + user_id INTEGER NOT NULL, | |
| 65 | + app TEXT NOT NULL, | |
| 66 | + dim TEXT NOT NULL, | |
| 67 | + value TEXT NOT NULL, | |
| 68 | + mode TEXT NOT NULL, -- 'ban' (retirer) | 'boost' (renforcer) | |
| 69 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 70 | + PRIMARY KEY (user_id, app, dim, value) | |
| 71 | + ); | |
| 72 | +`); | |
| 73 | + | |
| 74 | +// Confidentialité — trois interrupteurs, tous ON par défaut (opt-out). | |
| 75 | +for (const col of [ | |
| 76 | + "personalization INTEGER DEFAULT 1", // reranking personnalisé | |
| 77 | + "history_enabled INTEGER DEFAULT 1", // journal d'interactions | |
| 78 | + "recos_enabled INTEGER DEFAULT 1", // recommandations « Pour vous » | |
| 79 | +]) { | |
| 80 | + try { | |
| 81 | + db.exec(`ALTER TABLE users ADD COLUMN ${col}`); | |
| 82 | + } catch { | |
| 83 | + /* colonne déjà présente */ | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/* ---------- types ---------- */ | |
| 88 | + | |
| 89 | +export type UserEventRow = { | |
| 90 | + id: number; | |
| 91 | + user_id: number; | |
| 92 | + app: string; | |
| 93 | + session_id: string | null; | |
| 94 | + event_type: string; | |
| 95 | + entity_type: string | null; | |
| 96 | + entity_id: string | null; | |
| 97 | + query: string | null; | |
| 98 | + filters: string | null; | |
| 99 | + position: number | null; | |
| 100 | + metadata: string | null; | |
| 101 | + created_at: string; | |
| 102 | +}; | |
| 103 | + | |
| 104 | +export type SavedSearchRow = { | |
| 105 | + id: number; | |
| 106 | + user_id: number; | |
| 107 | + app: string; | |
| 108 | + label: string; | |
| 109 | + query: string | null; | |
| 110 | + filters: string | null; | |
| 111 | + location: string | null; | |
| 112 | + url: string | null; | |
| 113 | + alert_enabled: number; | |
| 114 | + alert_frequency: string; | |
| 115 | + created_at: string; | |
| 116 | + last_run_at: string | null; | |
| 117 | + last_triggered_at: string | null; | |
| 118 | + fingerprint: string | null; | |
| 119 | +}; | |
| 120 | + | |
| 121 | +export type Privacy = { | |
| 122 | + personalization: boolean; | |
| 123 | + history: boolean; | |
| 124 | + recos: boolean; | |
| 125 | +}; | |
| 126 | + | |
| 127 | +export const EVENT_TYPES = new Set([ | |
| 128 | + "search", "impression", "click", "detail_view", "detail_dwell", | |
| 129 | + "favorite", "unfavorite", "share", "compare", "hide", "unhide", | |
| 130 | + "dismiss", "map_open", "map_marker_click", "filter_change", | |
| 131 | + "price_filter", "location_filter", "scroll_depth", "return_visit", | |
| 132 | + "alert_create", "alert_open", "external_click", "saved_search", | |
| 133 | +]); | |
| 134 | + | |
| 135 | +// Événements « forts » : ils invalident le profil calculé sur-le-champ. | |
| 136 | +const STRONG = new Set([ | |
| 137 | + "favorite", "unfavorite", "hide", "unhide", "alert_create", "saved_search", | |
| 138 | +]); | |
| 139 | + | |
| 140 | +const clip = (v: unknown, max: number): string | null => { | |
| 141 | + if (typeof v === "number") return String(v).slice(0, max); | |
| 142 | + return typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null; | |
| 143 | +}; | |
| 144 | + | |
| 145 | +/* ---------- confidentialité ---------- */ | |
| 146 | + | |
| 147 | +export function privacyOf(userId: number): Privacy { | |
| 148 | + const r = db | |
| 149 | + .prepare( | |
| 150 | + "SELECT personalization, history_enabled, recos_enabled FROM users WHERE id = ?", | |
| 151 | + ) | |
| 152 | + .get(userId) as | |
| 153 | + | { personalization: number; history_enabled: number; recos_enabled: number } | |
| 154 | + | undefined; | |
| 155 | + return { | |
| 156 | + personalization: (r?.personalization ?? 1) !== 0, | |
| 157 | + history: (r?.history_enabled ?? 1) !== 0, | |
| 158 | + recos: (r?.recos_enabled ?? 1) !== 0, | |
| 159 | + }; | |
| 160 | +} | |
| 161 | + | |
| 162 | +export function setPrivacy(userId: number, p: Partial<Privacy>): void { | |
| 163 | + if (p.personalization !== undefined) | |
| 164 | + db.prepare("UPDATE users SET personalization = ? WHERE id = ?").run( | |
| 165 | + p.personalization ? 1 : 0, userId); | |
| 166 | + if (p.history !== undefined) | |
| 167 | + db.prepare("UPDATE users SET history_enabled = ? WHERE id = ?").run( | |
| 168 | + p.history ? 1 : 0, userId); | |
| 169 | + if (p.recos !== undefined) | |
| 170 | + db.prepare("UPDATE users SET recos_enabled = ? WHERE id = ?").run( | |
| 171 | + p.recos ? 1 : 0, userId); | |
| 172 | + invalidatePrefs(userId); | |
| 173 | +} | |
| 174 | + | |
| 175 | +/* ---------- événements ---------- */ | |
| 176 | + | |
| 177 | +export function ingestEvents( | |
| 178 | + userId: number, | |
| 179 | + app: string, | |
| 180 | + events: Array<Record<string, unknown>>, | |
| 181 | +): number { | |
| 182 | + const ins = db.prepare( | |
| 183 | + `INSERT INTO user_events (user_id, app, session_id, event_type, | |
| 184 | + entity_type, entity_id, query, filters, position, metadata, created_at) | |
| 185 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, datetime('now')))`, | |
| 186 | + ); | |
| 187 | + let n = 0; | |
| 188 | + let strong = false; | |
| 189 | + const tx = db.transaction(() => { | |
| 190 | + for (const e of events.slice(0, 50)) { | |
| 191 | + const type = clip(e.type ?? e.event_type, 30) ?? ""; | |
| 192 | + if (!EVENT_TYPES.has(type)) continue; | |
| 193 | + const meta: Record<string, unknown> = {}; | |
| 194 | + if (e.features && typeof e.features === "object") | |
| 195 | + meta.features = e.features; | |
| 196 | + if (typeof e.dwell_ms === "number") meta.dwell_ms = e.dwell_ms; | |
| 197 | + const ts = | |
| 198 | + typeof e.ts === "number" && e.ts > 1_600_000_000 && e.ts < Date.now() / 1000 + 60 | |
| 199 | + ? new Date(e.ts * 1000).toISOString().replace("T", " ").slice(0, 19) | |
| 200 | + : null; | |
| 201 | + ins.run( | |
| 202 | + userId, app, clip(e.session_id, 60), type, | |
| 203 | + clip(e.entity_type, 30), clip(e.entity_id, 200), clip(e.query, 200), | |
| 204 | + e.filters && typeof e.filters === "object" | |
| 205 | + ? JSON.stringify(e.filters).slice(0, 2000) : null, | |
| 206 | + typeof e.position === "number" ? Math.floor(e.position) : null, | |
| 207 | + Object.keys(meta).length ? JSON.stringify(meta).slice(0, 4000) : null, | |
| 208 | + ts, | |
| 209 | + ); | |
| 210 | + if (STRONG.has(type)) strong = true; | |
| 211 | + n++; | |
| 212 | + } | |
| 213 | + }); | |
| 214 | + tx(); | |
| 215 | + if (strong) invalidatePrefs(userId); | |
| 216 | + // Rétention : purge douce des événements de plus de 180 jours (1 fois sur ~50). | |
| 217 | + if (Math.floor(Math.random() * 50) === 0) | |
| 218 | + db.prepare( | |
| 219 | + "DELETE FROM user_events WHERE created_at < datetime('now', '-180 days')", | |
| 220 | + ).run(); | |
| 221 | + return n; | |
| 222 | +} | |
| 223 | + | |
| 224 | +export function eventsOf( | |
| 225 | + userId: number, | |
| 226 | + opts?: { app?: string; limit?: number; sinceDays?: number }, | |
| 227 | +): UserEventRow[] { | |
| 228 | + const limit = Math.min(opts?.limit ?? 4000, 8000); | |
| 229 | + const since = `-${Math.min(opts?.sinceDays ?? 180, 365)} days`; | |
| 230 | + if (opts?.app) | |
| 231 | + return db.prepare( | |
| 232 | + `SELECT * FROM user_events WHERE user_id = ? AND app = ? | |
| 233 | + AND created_at > datetime('now', ?) | |
| 234 | + ORDER BY id DESC LIMIT ?`, | |
| 235 | + ).all(userId, opts.app, since, limit) as UserEventRow[]; | |
| 236 | + return db.prepare( | |
| 237 | + `SELECT * FROM user_events WHERE user_id = ? | |
| 238 | + AND created_at > datetime('now', ?) | |
| 239 | + ORDER BY id DESC LIMIT ?`, | |
| 240 | + ).all(userId, since, limit) as UserEventRow[]; | |
| 241 | +} | |
| 242 | + | |
| 243 | +export function eventsCount(userId: number): number { | |
| 244 | + return (db.prepare( | |
| 245 | + "SELECT COUNT(*) AS n FROM user_events WHERE user_id = ?", | |
| 246 | + ).get(userId) as { n: number }).n; | |
| 247 | +} | |
| 248 | + | |
| 249 | +export function clearHistory(userId: number): void { | |
| 250 | + db.prepare("DELETE FROM user_events WHERE user_id = ?").run(userId); | |
| 251 | + invalidatePrefs(userId); | |
| 252 | +} | |
| 253 | + | |
| 254 | +/* ---------- profil calculé (cache) ---------- */ | |
| 255 | + | |
| 256 | +export function cachedProfile( | |
| 257 | + userId: number, | |
| 258 | +): { profile: string; events_n: number; updated_at: string } | undefined { | |
| 259 | + return db.prepare("SELECT profile, events_n, updated_at FROM user_prefs WHERE user_id = ?") | |
| 260 | + .get(userId) as { profile: string; events_n: number; updated_at: string } | undefined; | |
| 261 | +} | |
| 262 | + | |
| 263 | +export function storeProfile(userId: number, profile: unknown, eventsN: number): void { | |
| 264 | + db.prepare( | |
| 265 | + `INSERT INTO user_prefs (user_id, profile, events_n, updated_at) | |
| 266 | + VALUES (?, ?, ?, datetime('now')) | |
| 267 | + ON CONFLICT(user_id) DO UPDATE SET profile = excluded.profile, | |
| 268 | + events_n = excluded.events_n, updated_at = datetime('now')`, | |
| 269 | + ).run(userId, JSON.stringify(profile), eventsN); | |
| 270 | +} | |
| 271 | + | |
| 272 | +export function invalidatePrefs(userId: number): void { | |
| 273 | + db.prepare("DELETE FROM user_prefs WHERE user_id = ?").run(userId); | |
| 274 | +} | |
| 275 | + | |
| 276 | +export function resetPersonalization(userId: number): void { | |
| 277 | + db.prepare("DELETE FROM user_events WHERE user_id = ?").run(userId); | |
| 278 | + db.prepare("DELETE FROM pref_overrides WHERE user_id = ?").run(userId); | |
| 279 | + db.prepare("DELETE FROM hidden_items WHERE user_id = ?").run(userId); | |
| 280 | + invalidatePrefs(userId); | |
| 281 | +} | |
| 282 | + | |
| 283 | +/* ---------- corrections utilisateur (« Ce que KA a appris ») ---------- */ | |
| 284 | + | |
| 285 | +export type OverrideRow = { | |
| 286 | + user_id: number; app: string; dim: string; value: string; mode: string; | |
| 287 | +}; | |
| 288 | + | |
| 289 | +export function overridesOf(userId: number): OverrideRow[] { | |
| 290 | + return db.prepare("SELECT * FROM pref_overrides WHERE user_id = ?") | |
| 291 | + .all(userId) as OverrideRow[]; | |
| 292 | +} | |
| 293 | + | |
| 294 | +export function setOverride( | |
| 295 | + userId: number, app: string, dim: string, value: string, | |
| 296 | + mode: "ban" | "boost" | "clear", | |
| 297 | +): void { | |
| 298 | + if (mode === "clear") | |
| 299 | + db.prepare( | |
| 300 | + "DELETE FROM pref_overrides WHERE user_id = ? AND app = ? AND dim = ? AND value = ?", | |
| 301 | + ).run(userId, app, dim, value); | |
| 302 | + else | |
| 303 | + db.prepare( | |
| 304 | + `INSERT INTO pref_overrides (user_id, app, dim, value, mode) | |
| 305 | + VALUES (?, ?, ?, ?, ?) | |
| 306 | + ON CONFLICT(user_id, app, dim, value) DO UPDATE SET mode = excluded.mode`, | |
| 307 | + ).run(userId, app, dim.slice(0, 40), value.slice(0, 120), mode); | |
| 308 | + invalidatePrefs(userId); | |
| 309 | +} | |
| 310 | + | |
| 311 | +/* ---------- éléments masqués (« Pas pour moi ») ---------- */ | |
| 312 | + | |
| 313 | +export function hiddenOf(userId: number, app?: string): { app: string; item_id: string }[] { | |
| 314 | + return ( | |
| 315 | + app | |
| 316 | + ? db.prepare("SELECT app, item_id FROM hidden_items WHERE user_id = ? AND app = ?") | |
| 317 | + .all(userId, app) | |
| 318 | + : db.prepare("SELECT app, item_id FROM hidden_items WHERE user_id = ?").all(userId) | |
| 319 | + ) as { app: string; item_id: string }[]; | |
| 320 | +} | |
| 321 | + | |
| 322 | +export function setHidden(userId: number, app: string, itemId: string, on: boolean): void { | |
| 323 | + if (on) | |
| 324 | + db.prepare( | |
| 325 | + `INSERT INTO hidden_items (user_id, app, item_id) VALUES (?, ?, ?) | |
| 326 | + ON CONFLICT DO NOTHING`, | |
| 327 | + ).run(userId, app, itemId.slice(0, 200)); | |
| 328 | + else | |
| 329 | + db.prepare( | |
| 330 | + "DELETE FROM hidden_items WHERE user_id = ? AND app = ? AND item_id = ?", | |
| 331 | + ).run(userId, app, itemId); | |
| 332 | + invalidatePrefs(userId); | |
| 333 | +} | |
| 334 | + | |
| 335 | +/* ---------- recherches sauvegardées ---------- */ | |
| 336 | + | |
| 337 | +import crypto from "crypto"; | |
| 338 | + | |
| 339 | +function fingerprint(app: string, query: string | null, filters: string | null): string { | |
| 340 | + return crypto.createHash("sha256") | |
| 341 | + .update(`${app}|${query ?? ""}|${filters ?? ""}`).digest("hex").slice(0, 24); | |
| 342 | +} | |
| 343 | + | |
| 344 | +export function savedSearchesOf(userId: number, app?: string): SavedSearchRow[] { | |
| 345 | + return ( | |
| 346 | + app | |
| 347 | + ? db.prepare( | |
| 348 | + "SELECT * FROM saved_searches WHERE user_id = ? AND app = ? ORDER BY created_at DESC", | |
| 349 | + ).all(userId, app) | |
| 350 | + : db.prepare( | |
| 351 | + "SELECT * FROM saved_searches WHERE user_id = ? ORDER BY created_at DESC", | |
| 352 | + ).all(userId) | |
| 353 | + ) as SavedSearchRow[]; | |
| 354 | +} | |
| 355 | + | |
| 356 | +export function addSavedSearch( | |
| 357 | + userId: number, | |
| 358 | + app: string, | |
| 359 | + s: { label: string; query?: string; filters?: unknown; location?: string; url?: string; | |
| 360 | + alert?: boolean; frequency?: string }, | |
| 361 | +): number { | |
| 362 | + const filters = | |
| 363 | + s.filters && typeof s.filters === "object" | |
| 364 | + ? JSON.stringify(s.filters).slice(0, 2000) | |
| 365 | + : typeof s.filters === "string" ? s.filters.slice(0, 2000) : null; | |
| 366 | + const query = clip(s.query, 300); | |
| 367 | + const fp = fingerprint(app, query, filters); | |
| 368 | + db.prepare( | |
| 369 | + `INSERT INTO saved_searches (user_id, app, label, query, filters, location, | |
| 370 | + url, alert_enabled, alert_frequency, fingerprint) | |
| 371 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 372 | + ON CONFLICT(user_id, app, fingerprint) DO UPDATE SET | |
| 373 | + label = excluded.label, url = excluded.url, location = excluded.location, | |
| 374 | + alert_enabled = excluded.alert_enabled, alert_frequency = excluded.alert_frequency`, | |
| 375 | + ).run( | |
| 376 | + userId, app, (s.label || "Recherche").slice(0, 120), query, filters, | |
| 377 | + clip(s.location, 120), clip(s.url, 600), | |
| 378 | + s.alert ? 1 : 0, | |
| 379 | + ["instant", "daily", "weekly"].includes(s.frequency ?? "") ? s.frequency! : "daily", | |
| 380 | + fp, | |
| 381 | + ); | |
| 382 | + return (db.prepare( | |
| 383 | + "SELECT id FROM saved_searches WHERE user_id = ? AND app = ? AND fingerprint = ?", | |
| 384 | + ).get(userId, app, fp) as { id: number }).id; | |
| 385 | +} | |
| 386 | + | |
| 387 | +export function removeSavedSearch(userId: number, id: number): void { | |
| 388 | + db.prepare("DELETE FROM saved_searches WHERE user_id = ? AND id = ?").run(userId, id); | |
| 389 | +} | |
| 390 | + | |
| 391 | +export function touchSavedSearch(userId: number, id: number): void { | |
| 392 | + db.prepare( | |
| 393 | + "UPDATE saved_searches SET last_run_at = datetime('now') WHERE user_id = ? AND id = ?", | |
| 394 | + ).run(userId, id); | |
| 395 | +} | |
| 396 | + | |
| 397 | +export function setSavedSearchAlert( | |
| 398 | + userId: number, id: number, enabled: boolean, frequency?: string, | |
| 399 | +): void { | |
| 400 | + db.prepare( | |
| 401 | + `UPDATE saved_searches SET alert_enabled = ?, | |
| 402 | + alert_frequency = COALESCE(?, alert_frequency) | |
| 403 | + WHERE user_id = ? AND id = ?`, | |
| 404 | + ).run(enabled ? 1 : 0, | |
| 405 | + ["instant", "daily", "weekly"].includes(frequency ?? "") ? frequency : null, | |
| 406 | + userId, id); | |
| 407 | +} | |
| 408 | + | |
| 409 | +/* ---------- export Loi 25 ---------- */ | |
| 410 | + | |
| 411 | +export function exportUserData(userId: number): Record<string, unknown> { | |
| 412 | + const user = db.prepare( | |
| 413 | + `SELECT ka_id, email, name, city, role, created_at, last_login, | |
| 414 | + personalization, history_enabled, recos_enabled | |
| 415 | + FROM users WHERE id = ?`, | |
| 416 | + ).get(userId); | |
| 417 | + const favorites = db.prepare( | |
| 418 | + "SELECT app, item_id, title, subtitle, price_label, url, created_at FROM favorites WHERE user_id = ?", | |
| 419 | + ).all(userId); | |
| 420 | + const searches = savedSearchesOf(userId); | |
| 421 | + const hidden = hiddenOf(userId); | |
| 422 | + const overrides = overridesOf(userId); | |
| 423 | + const events = db.prepare( | |
| 424 | + `SELECT app, session_id, event_type, entity_type, entity_id, query, | |
| 425 | + filters, position, metadata, created_at | |
| 426 | + FROM user_events WHERE user_id = ? ORDER BY id DESC LIMIT 8000`, | |
| 427 | + ).all(userId); | |
| 428 | + const prefs = cachedProfile(userId); | |
| 429 | + return { | |
| 430 | + exported_at: new Date().toISOString(), | |
| 431 | + user, favorites, saved_searches: searches, hidden_items: hidden, | |
| 432 | + pref_overrides: overrides, | |
| 433 | + preference_profile: prefs ? JSON.parse(prefs.profile) : null, | |
| 434 | + events, | |
| 435 | + }; | |
| 436 | +} | |
added
src/lib/personal.ts
+353 −0
@@ -0,0 +1,353 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID v2 — moteur de préférences du Groupe KA. | |
| 3 | +// Déduit un profil de préférences par univers (+ transversal) à partir du | |
| 4 | +// journal d'interactions et des favoris : pondération par type d'événement, | |
| 5 | +// décroissance temporelle (demi-vie 30 jours), signaux négatifs, plages | |
| 6 | +// numériques robustes (quartiles pondérés), corrections explicites de | |
| 7 | +// l'utilisateur (pref_overrides). Calcul paresseux, mis en cache dans | |
| 8 | +// user_prefs et invalidé par les événements forts (favori, masquage…). | |
| 9 | +import { db } from "./db"; | |
| 10 | +import { | |
| 11 | + eventsOf, eventsCount, cachedProfile, storeProfile, overridesOf, | |
| 12 | + type UserEventRow, | |
| 13 | +} from "./kaid-data"; | |
| 14 | + | |
| 15 | +/* ---------- pondérations ---------- */ | |
| 16 | + | |
| 17 | +// Tous les signaux ne se valent pas : un ♥ pèse 80× une impression. | |
| 18 | +export 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 fort | |
| 41 | + saved_search: 6, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const HALF_LIFE_DAYS = 30; | |
| 45 | +const LOCATION_DIMS = new Set([ | |
| 46 | + "city", "region", "sector", "quartier", "neighborhood", "location", "ville", | |
| 47 | +]); | |
| 48 | +const MAX_VALUES_PER_DIM = 12; | |
| 49 | +const MIN_AFFINITY = 0.05; | |
| 50 | + | |
| 51 | +/* ---------- types ---------- */ | |
| 52 | + | |
| 53 | +export type DimProfile = { values: Record<string, number>; conf: number }; | |
| 54 | +export type RangeProfile = { p25: number; p50: number; p75: number; n: number }; | |
| 55 | +export type AppProfile = { | |
| 56 | + n: number; // volume pondéré de signaux positifs | |
| 57 | + dims: Record<string, DimProfile>; | |
| 58 | + ranges: Record<string, RangeProfile>; | |
| 59 | +}; | |
| 60 | +export type Profile = { | |
| 61 | + version: 2; | |
| 62 | + computed_at: string; | |
| 63 | + events_n: number; | |
| 64 | + apps: Record<string, AppProfile>; | |
| 65 | + global: { location: DimProfile & { apps: number } }; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/* ---------- accumulation ---------- */ | |
| 69 | + | |
| 70 | +type Acc = { | |
| 71 | + dims: Map<string, Map<string, { score: number; pos: number }>>; | |
| 72 | + nums: Map<string, Array<{ v: number; w: number }>>; | |
| 73 | + posw: number; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +function newAcc(): Acc { | |
| 77 | + return { dims: new Map(), nums: new Map(), posw: 0 }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +function addFeature(acc: Acc, dim: string, value: unknown, w: number): void { | |
| 81 | + if (value == null) return; | |
| 82 | + if (typeof value === "number" && Number.isFinite(value)) { | |
| 83 | + if (w <= 0) return; // les plages n'apprennent que du positif | |
| 84 | + let arr = acc.nums.get(dim); | |
| 85 | + if (!arr) acc.nums.set(dim, (arr = [])); | |
| 86 | + if (arr.length < 2000) arr.push({ v: value, w }); | |
| 87 | + return; | |
| 88 | + } | |
| 89 | + const vals = Array.isArray(value) ? value : [value]; | |
| 90 | + for (const raw of vals.slice(0, 8)) { | |
| 91 | + if (typeof raw !== "string" && typeof raw !== "boolean") continue; | |
| 92 | + const val = String(raw).trim().toLowerCase().slice(0, 80); | |
| 93 | + if (!val) continue; | |
| 94 | + let m = acc.dims.get(dim); | |
| 95 | + if (!m) acc.dims.set(dim, (m = new Map())); | |
| 96 | + const cur = m.get(val) ?? { score: 0, pos: 0 }; | |
| 97 | + cur.score += w; | |
| 98 | + if (w > 0) cur.pos += w; | |
| 99 | + m.set(val, cur); | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +function decayOf(createdAt: string): number { | |
| 104 | + const t = Date.parse(createdAt.includes("T") ? createdAt : createdAt + "Z"); | |
| 105 | + if (Number.isNaN(t)) return 0.5; | |
| 106 | + const days = Math.max(0, (Date.now() - t) / 86_400_000); | |
| 107 | + return Math.pow(0.5, days / HALF_LIFE_DAYS); | |
| 108 | +} | |
| 109 | + | |
| 110 | +function eventWeight(e: UserEventRow): number { | |
| 111 | + let w = EVENT_WEIGHTS[e.event_type] ?? 0; | |
| 112 | + if (!w) return 0; | |
| 113 | + if (e.event_type === "detail_dwell") { | |
| 114 | + // gradue selon la durée réelle si transmise | |
| 115 | + try { | |
| 116 | + const meta = JSON.parse(e.metadata ?? "{}"); | |
| 117 | + const s = (meta.dwell_ms ?? 0) / 1000; | |
| 118 | + if (s >= 60) w = 5; | |
| 119 | + else if (s < 20) w = 2.5; | |
| 120 | + } catch { /* poids par défaut */ } | |
| 121 | + } | |
| 122 | + return w * decayOf(e.created_at); | |
| 123 | +} | |
| 124 | + | |
| 125 | +function featuresOfEvent(e: UserEventRow): Record<string, unknown> | null { | |
| 126 | + if (!e.metadata) return null; | |
| 127 | + try { | |
| 128 | + const f = JSON.parse(e.metadata).features; | |
| 129 | + return f && typeof f === "object" ? (f as Record<string, unknown>) : null; | |
| 130 | + } catch { | |
| 131 | + return null; | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +/* ---------- quartiles pondérés ---------- */ | |
| 136 | + | |
| 137 | +function weightedQuartiles(arr: Array<{ v: number; w: number }>): RangeProfile | null { | |
| 138 | + if (arr.length < 4) return null; | |
| 139 | + const sorted = [...arr].sort((a, b) => a.v - b.v); | |
| 140 | + const total = sorted.reduce((s, x) => s + x.w, 0); | |
| 141 | + if (total <= 0) return null; | |
| 142 | + const q = (p: number): number => { | |
| 143 | + let cum = 0; | |
| 144 | + for (const x of sorted) { | |
| 145 | + cum += x.w; | |
| 146 | + if (cum >= p * total) return x.v; | |
| 147 | + } | |
| 148 | + return sorted[sorted.length - 1].v; | |
| 149 | + }; | |
| 150 | + return { | |
| 151 | + p25: Math.round(q(0.25) * 100) / 100, | |
| 152 | + p50: Math.round(q(0.5) * 100) / 100, | |
| 153 | + p75: Math.round(q(0.75) * 100) / 100, | |
| 154 | + n: arr.length, | |
| 155 | + }; | |
| 156 | +} | |
| 157 | + | |
| 158 | +/* ---------- calcul du profil ---------- */ | |
| 159 | + | |
| 160 | +export function computeProfile(userId: number): Profile { | |
| 161 | + const events = eventsOf(userId); | |
| 162 | + const perApp = new Map<string, Acc>(); | |
| 163 | + const accOf = (app: string): Acc => { | |
| 164 | + let a = perApp.get(app); | |
| 165 | + if (!a) perApp.set(app, (a = newAcc())); | |
| 166 | + return a; | |
| 167 | + }; | |
| 168 | + | |
| 169 | + for (const e of events) { | |
| 170 | + const w = eventWeight(e); | |
| 171 | + if (!w) continue; | |
| 172 | + const acc = accOf(e.app); | |
| 173 | + if (w > 0) acc.posw += w; | |
| 174 | + const feats = featuresOfEvent(e); | |
| 175 | + if (feats) | |
| 176 | + for (const [dim, value] of Object.entries(feats)) | |
| 177 | + addFeature(acc, dim.slice(0, 40), value, w); | |
| 178 | + // la requête et les filtres d'une recherche sont eux-mêmes des signaux | |
| 179 | + if (e.event_type === "search" && e.filters) { | |
| 180 | + try { | |
| 181 | + const f = JSON.parse(e.filters) as Record<string, unknown>; | |
| 182 | + for (const [dim, value] of Object.entries(f)) | |
| 183 | + addFeature(acc, dim.slice(0, 40), value, w * 0.8); | |
| 184 | + } catch { /* filtres illisibles : ignorés */ } | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + // Les favoris encore actifs comptent aussi (avec leur méta si présente), | |
| 189 | + // même s'ils précèdent la mise en place du journal. | |
| 190 | + const favs = db.prepare( | |
| 191 | + "SELECT app, meta, created_at FROM favorites WHERE user_id = ?", | |
| 192 | + ).all(userId) as { app: string; meta: string | null; created_at: string }[]; | |
| 193 | + for (const f of favs) { | |
| 194 | + if (!f.meta) continue; | |
| 195 | + try { | |
| 196 | + const meta = JSON.parse(f.meta) as Record<string, unknown>; | |
| 197 | + const feats = (meta.features ?? meta) as Record<string, unknown>; | |
| 198 | + if (!feats || typeof feats !== "object") continue; | |
| 199 | + const w = (EVENT_WEIGHTS.favorite ?? 8) * | |
| 200 | + Math.max(0.35, decayOf(f.created_at)); // un ♥ vieillit lentement | |
| 201 | + const acc = accOf(f.app); | |
| 202 | + acc.posw += w; | |
| 203 | + for (const [dim, value] of Object.entries(feats)) | |
| 204 | + addFeature(acc, String(dim).slice(0, 40), value, w); | |
| 205 | + } catch { /* méta illisible */ } | |
| 206 | + } | |
| 207 | + | |
| 208 | + // Normalisation + agrégation transversale de la localisation. | |
| 209 | + const overrides = overridesOf(userId); | |
| 210 | + const banned = new Set( | |
| 211 | + overrides.filter((o) => o.mode === "ban") | |
| 212 | + .map((o) => `${o.app}|${o.dim}|${o.value.toLowerCase()}`), | |
| 213 | + ); | |
| 214 | + const boosted = overrides.filter((o) => o.mode === "boost"); | |
| 215 | + | |
| 216 | + const apps: Record<string, AppProfile> = {}; | |
| 217 | + const globalLoc = new Map<string, { score: number; apps: Set<string> }>(); | |
| 218 | + | |
| 219 | + for (const [app, acc] of perApp) { | |
| 220 | + const dims: Record<string, DimProfile> = {}; | |
| 221 | + for (const [dim, m] of acc.dims) { | |
| 222 | + let maxPos = 0; | |
| 223 | + for (const { pos } of m.values()) maxPos = Math.max(maxPos, pos); | |
| 224 | + if (maxPos <= 0) { | |
| 225 | + let maxAbs = 0; | |
| 226 | + for (const { score } of m.values()) maxAbs = Math.max(maxAbs, Math.abs(score)); | |
| 227 | + maxPos = maxAbs || 1; | |
| 228 | + } | |
| 229 | + const entries = [...m.entries()] | |
| 230 | + .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) | |
| 232 | + .filter(([, aff]) => Math.abs(aff) >= MIN_AFFINITY) | |
| 233 | + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) | |
| 234 | + .slice(0, MAX_VALUES_PER_DIM); | |
| 235 | + if (!entries.length) continue; | |
| 236 | + const values = Object.fromEntries( | |
| 237 | + entries.map(([v, a]) => [v, Math.round(a * 100) / 100]), | |
| 238 | + ); | |
| 239 | + const conf = Math.round((1 - Math.exp(-acc.posw / 8)) * 100) / 100; | |
| 240 | + dims[dim] = { values, conf }; | |
| 241 | + if (LOCATION_DIMS.has(dim)) | |
| 242 | + for (const [val, aff] of entries) { | |
| 243 | + if (aff <= 0) continue; | |
| 244 | + const g = globalLoc.get(val) ?? { score: 0, apps: new Set<string>() }; | |
| 245 | + g.score += aff; | |
| 246 | + g.apps.add(app); | |
| 247 | + globalLoc.set(val, g); | |
| 248 | + } | |
| 249 | + } | |
| 250 | + const ranges: Record<string, RangeProfile> = {}; | |
| 251 | + for (const [dim, arr] of acc.nums) { | |
| 252 | + const r = weightedQuartiles(arr); | |
| 253 | + if (r) ranges[dim] = r; | |
| 254 | + } | |
| 255 | + if (Object.keys(dims).length || Object.keys(ranges).length) | |
| 256 | + apps[app] = { n: Math.round(acc.posw * 10) / 10, dims, ranges }; | |
| 257 | + } | |
| 258 | + | |
| 259 | + // Renforcements explicites. | |
| 260 | + for (const o of boosted) { | |
| 261 | + const app = apps[o.app] ?? (apps[o.app] = { n: 1, dims: {}, ranges: {} }); | |
| 262 | + const dim = app.dims[o.dim] ?? (app.dims[o.dim] = { values: {}, conf: 1 }); | |
| 263 | + dim.values[o.value.toLowerCase()] = 1; | |
| 264 | + } | |
| 265 | + | |
| 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); | |
| 276 | + } | |
| 277 | + | |
| 278 | + return { | |
| 279 | + version: 2, | |
| 280 | + computed_at: new Date().toISOString(), | |
| 281 | + events_n: events.length, | |
| 282 | + apps, | |
| 283 | + global: { | |
| 284 | + location: { | |
| 285 | + values: locValues, | |
| 286 | + conf: Math.round(Math.min(1, locApps / 3) * 100) / 100, | |
| 287 | + apps: locApps, | |
| 288 | + }, | |
| 289 | + }, | |
| 290 | + }; | |
| 291 | +} | |
| 292 | + | |
| 293 | +/** Profil (avec cache 15 min, invalidé par les événements forts). */ | |
| 294 | +export function getProfile(userId: number): Profile { | |
| 295 | + const cached = cachedProfile(userId); | |
| 296 | + if (cached) { | |
| 297 | + const ageMin = | |
| 298 | + (Date.now() - Date.parse(cached.updated_at + "Z")) / 60000; | |
| 299 | + const n = eventsCount(userId); | |
| 300 | + if (ageMin < 15 || n === cached.events_n) { | |
| 301 | + try { | |
| 302 | + return JSON.parse(cached.profile) as Profile; | |
| 303 | + } catch { /* cache corrompu : recalcul */ } | |
| 304 | + } | |
| 305 | + } | |
| 306 | + const profile = computeProfile(userId); | |
| 307 | + storeProfile(userId, profile, eventsCount(userId)); | |
| 308 | + return profile; | |
| 309 | +} | |
| 310 | + | |
| 311 | +/* ---------- résumé lisible (« Ce que KA a appris ») ---------- */ | |
| 312 | + | |
| 313 | +const DIM_LABELS: Record<string, string> = { | |
| 314 | + city: "Ville", region: "Région", sector: "Secteur", quartier: "Quartier", | |
| 315 | + brand: "Marque", model: "Modèle", body_type: "Carrosserie", | |
| 316 | + transmission: "Transmission", fuel: "Carburant", category: "Catégorie", | |
| 317 | + categories: "Catégorie", cuisine: "Cuisine", store: "Enseigne", | |
| 318 | + employer: "Employeur", industry: "Industrie", work_mode: "Mode de travail", | |
| 319 | + seniority: "Séniorité", unit_type: "Type de logement", | |
| 320 | + property_type: "Type de propriété", bedrooms: "Chambres", platform: "Plateforme", | |
| 321 | + source: "Source", price: "Prix", salary: "Salaire", year: "Année", | |
| 322 | + mileage: "Kilométrage", venue: "Lieu", is_free: "Gratuit", | |
| 323 | +}; | |
| 324 | + | |
| 325 | +export function dimLabel(dim: string): string { | |
| 326 | + return DIM_LABELS[dim] ?? dim; | |
| 327 | +} | |
| 328 | + | |
| 329 | +export type LearnedItem = { | |
| 330 | + app: string; dim: string; label: string; value: string; | |
| 331 | + affinity: number; kind: "value" | "range"; | |
| 332 | +}; | |
| 333 | + | |
| 334 | +export function learnedSummary(profile: Profile): LearnedItem[] { | |
| 335 | + const out: LearnedItem[] = []; | |
| 336 | + for (const [app, p] of Object.entries(profile.apps)) { | |
| 337 | + for (const [dim, d] of Object.entries(p.dims)) { | |
| 338 | + for (const [value, aff] of Object.entries(d.values)) { | |
| 339 | + if (aff < 0.35) continue; // on ne montre que les préférences nettes | |
| 340 | + out.push({ app, dim, label: dimLabel(dim), value, affinity: aff, kind: "value" }); | |
| 341 | + } | |
| 342 | + } | |
| 343 | + for (const [dim, r] of Object.entries(p.ranges)) { | |
| 344 | + if (r.n < 5) continue; | |
| 345 | + out.push({ | |
| 346 | + app, dim, label: dimLabel(dim), | |
| 347 | + value: `${Math.round(r.p25).toLocaleString("fr-CA")} – ${Math.round(r.p75).toLocaleString("fr-CA")}`, | |
| 348 | + affinity: Math.min(1, r.n / 30), kind: "range", | |
| 349 | + }); | |
| 350 | + } | |
| 351 | + } | |
| 352 | + return out.sort((a, b) => b.affinity - a.affinity).slice(0, 60); | |
| 353 | +} | |
| 354 | ||