SPB Git forge

spb/groupe-ka

Public

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

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent a115ae1

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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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