SPB Git forge

spb/ka-ui

Public
30commits 1branches 0releases
145.7 MBsize
maindefault branch
27 days agolast push
Python 33.5% JavaScript 30.1% TypeScript 25% CSS 10% Shell 1.4%

kaid/ : client KA ID v2 — personnalisation Groupe KA (kaid.py : track/fetch_prefs/rerank/routeur /api/kaid ; ka-id.js : clics positionnés, dwell, hide, saveSearch ; sync-kaid.sh + README-KAID.md, standard 2026-08-26)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 29 days ago (Aug 26, 2026) parent 9f4b1ad

4 changed files +779 −0

added kaid/README-KAID.md +82 −0
@@ -0,0 +1,82 @@
1 +# KA ID v2 — personnalisation Groupe KA (module satellite)
2 +
3 +Standard 2026-08-26. Le hub **groupe-ka.com** est le feature store du groupe :
4 +journal d'interactions, favoris, recherches sauvegardées + alertes, éléments
5 +masqués, **profil de préférences appris** (décroissance 30 j, signaux
6 +négatifs, corrections utilisateur sur `/mon-ka`). Chaque satellite le
7 +consomme par le canal s2s HMAC existant (`KA_SSO_SECRET`).
8 +
9 +## Fichiers canoniques
10 +
11 +- `kaid.py` — client backend (FastAPI) : `track()`, `fetch_prefs()`,
12 + `rerank()`, `build_router()`. Copié dans le paquet de l'app par
13 + `./sync-kaid.sh`.
14 +- `ka-id.js` — SDK navigateur : clics positionnés, dwell de fiche,
15 + visites de retour, `KAID.hide()`, `KAID.saveSearch()`. Copié dans
16 + `frontend/public/` (+ `dist/`).
17 +
18 +## Endpoints hub (s2s, sig = HMAC(secret, "client.ka_id.ts"))
19 +
20 +- `POST /api/sso/events` — lot d'événements `{type, entity_id?, query?,
21 + filters?, features?, dwell_ms?, position?}` (max 50)
22 +- `GET /api/sso/prefs` — `{personalization, profile: {app, global}, hidden}`
23 +- `GET/POST /api/sso/saved-searches`, `POST /api/sso/hide`
24 +
25 +## Intégration type (app FastAPI)
26 +
27 +```python
28 +# web.py
29 +from . import kaid
30 +kaid.init("job-ka")
31 +app.include_router(kaid.build_router(lambda req: current_user(req)))
32 +
33 +# endpoint listing — APRÈS le tri de base, seulement pour le tri par défaut :
34 +user = current_user(request)
35 +kaid.track(user, "search", query=q, filters={"city": city, ...})
36 +if sort == "recent": # jamais sur un tri explicite
37 + items, personalized = kaid.rerank(
38 + items, user,
39 + features_of=lambda it: {"city": it.get("city"), "price": it.get("price"), ...},
40 + active_dims={d for d in ("city",) if city}, # intention de session
41 + )
42 +
43 +# endpoint fiche :
44 +kaid.track(user, "detail_view", entity_type="job", entity_id=uid,
45 + features=_features_of(row))
46 +
47 +# favoris (accounts.py) :
48 +kaid.track(user, "favorite", entity_id=uid, features=_features_of(row))
49 +```
50 +
51 +```html
52 +<!-- index.html, avant le bundle -->
53 +<script>window.KAID_CONF={card:"a[href^='/emploi/']",detail:"^/emploi/(.+)$",entity:"job"};</script>
54 +<script src="/ka-id.js" defer></script>
55 +```
56 +
57 +## Règles
58 +
59 +1. **Jamais obligatoire** : sans session KA ID, zéro appel, zéro changement.
60 +2. **Fail-open** : hub injoignable ou profil vide → classement de base.
61 +3. **Pertinence d'abord** : `rerank()` reclasse APRÈS le tri de base
62 + (blend 0,35) et uniquement le tri par défaut — un tri explicite
63 + (prix, salaire…) n'est jamais altéré.
64 +4. **Session > long terme** : passer les dimensions filtrées par la requête
65 + dans `active_dims` — elles sont exclues du score personnel.
66 +5. **Badge parcimonieux** : `item["ka_reco"]` n'apparaît que si le score
67 + personnel ≥ 0,62, plafonné à ~1 carte sur 8. Frontend : petite
68 + étiquette « Recommandé pour vous » + raisons (`MATCH_LOCATION`,
69 + `MATCH_PRICE_RANGE`, `MATCH_<DIM>`…).
70 +6. **features riches** : envoyer les mêmes clés dans `track(features=…)` et
71 + `features_of()` — c'est le vocabulaire du profil (city, price, brand,
72 + category…). Valeurs texte OU nombres (les nombres apprennent des plages).
73 +7. Le masquage (« Pas pour moi ») retire l'annonce des résultats de TOUTES
74 + les sessions futures et compte comme signal négatif fort.
75 +
76 +## Confidentialité
77 +
78 +Réglages sur groupe-ka.com/mon-ka : Personnalisation / Historique /
79 +Recommandations (ON par défaut, opt-out), effacement de l'historique,
80 +réinitialisation des recommandations, export JSON (Loi 25).
81 +`prefs.personalization=false` ⇒ `rerank()` ne fait que l'exclusion des
82 +masqués ; Historique OFF ⇒ le hub jette les événements reçus.
added kaid/ka-id.js +223 −0
@@ -0,0 +1,223 @@
1 +/* Groupe KA — ka-id.js : SDK client KA ID v2 (personnalisation).
2 + * SOURCE CANONIQUE : ka-ui.git/kaid/ka-id.js — copié dans frontend/public/
3 + * (ou public/) de chaque app par sync-kaid.sh. Corriger ICI puis redistribuer.
4 + *
5 + * Rôle : signaux navigateur que le serveur ne voit pas — position des clics
6 + * dans les résultats, temps passé sur une fiche (dwell), visites de retour —
7 + * plus les aides d'interface : KAID.hide() (« Pas pour moi »),
8 + * KAID.saveSearch() (recherche sauvegardée), badge « Recommandé pour vous ».
9 + *
10 + * Intégration (index.html, AVANT le bundle) :
11 + * <script>
12 + * window.KAID_CONF = {
13 + * card: "a[href^='/emploi/']", // sélecteur des cartes résultat
14 + * detail: "^/emploi/(.+)$", // regex fiche → capture l'uid
15 + * entity: "job", // entity_type des événements
16 + * };
17 + * </script>
18 + * <script src="/ka-id.js" defer></script>
19 + *
20 + * Vie privée : ne fait RIEN si l'utilisateur n'est pas connecté via KA ID
21 + * (vérifié auprès de /api/kaid/status) ; le hub respecte ensuite ses
22 + * réglages Historique/Personnalisation (voir groupe-ka.com/mon-ka).
23 + */
24 +(function () {
25 + "use strict";
26 + var CONF = window.KAID_CONF || {};
27 + var API = CONF.api || "/api/kaid";
28 + var connected = null; // null = inconnu, sinon bool
29 + var queue = [];
30 + var flushTimer = null;
31 +
32 + /* ---------- transport (lots de 10, sendBeacon à la fermeture) ---------- */
33 +
34 + function post(events, useBeacon) {
35 + if (!events.length) return;
36 + var body = JSON.stringify({ events: events });
37 + if (useBeacon && navigator.sendBeacon) {
38 + navigator.sendBeacon(API + "/events",
39 + new Blob([body], { type: "application/json" }));
40 + return;
41 + }
42 + fetch(API + "/events", {
43 + method: "POST",
44 + headers: { "Content-Type": "application/json" },
45 + body: body,
46 + keepalive: true,
47 + credentials: "same-origin",
48 + }).catch(function () {});
49 + }
50 +
51 + function flush(useBeacon) {
52 + if (!queue.length) return;
53 + var batch = queue.splice(0, queue.length);
54 + post(batch, useBeacon);
55 + }
56 +
57 + function track(type, data) {
58 + if (connected === false) return;
59 + var e = { type: type };
60 + if (data) for (var k in data) if (data[k] != null) e[k] = data[k];
61 + queue.push(e);
62 + if (queue.length >= 10) flush(false);
63 + else if (!flushTimer)
64 + flushTimer = setTimeout(function () { flushTimer = null; flush(false); }, 4000);
65 + }
66 +
67 + /* ---------- connexion ---------- */
68 +
69 + function init(cb) {
70 + try {
71 + var cached = sessionStorage.getItem("kaid_status");
72 + if (cached) {
73 + var c = JSON.parse(cached);
74 + if (Date.now() - c.t < 600000) { connected = c.on; cb(); return; }
75 + }
76 + } catch (err) { /* stockage indisponible */ }
77 + fetch(API + "/status", { credentials: "same-origin" })
78 + .then(function (r) { return r.json(); })
79 + .then(function (d) {
80 + connected = !!d.connected;
81 + try {
82 + sessionStorage.setItem("kaid_status",
83 + JSON.stringify({ on: connected, t: Date.now() }));
84 + } catch (err) { /* stockage indisponible */ }
85 + cb();
86 + })
87 + .catch(function () { connected = false; });
88 + }
89 +
90 + /* ---------- clics sur les cartes résultat ---------- */
91 +
92 + function uidFromHref(el) {
93 + if (!CONF.detail) return null;
94 + var href = el.getAttribute("href") || "";
95 + var path = href.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
96 + var m = path.match(new RegExp(CONF.detail));
97 + return m ? decodeURIComponent(m[1]) : null;
98 + }
99 +
100 + function watchClicks() {
101 + if (!CONF.card) return;
102 + document.addEventListener("click", function (ev) {
103 + var el = ev.target && ev.target.closest && ev.target.closest(CONF.card);
104 + if (!el) return;
105 + var uid = (CONF.uid ? CONF.uid(el) : null) || uidFromHref(el);
106 + if (!uid) return;
107 + var cards = document.querySelectorAll(CONF.card);
108 + var pos = Array.prototype.indexOf.call(cards, el);
109 + track("click", {
110 + entity_type: CONF.entity, entity_id: uid,
111 + position: pos >= 0 ? pos : null,
112 + });
113 + }, true);
114 + }
115 +
116 + /* ---------- temps passé sur une fiche (dwell) ---------- */
117 +
118 + var dwell = { uid: null, t0: 0 };
119 +
120 + function detailUid() {
121 + if (!CONF.detail) return null;
122 + var m = location.pathname.match(new RegExp(CONF.detail));
123 + return m ? decodeURIComponent(m[1]) : null;
124 + }
125 +
126 + function endDwell(useBeacon) {
127 + if (!dwell.uid) return;
128 + var ms = Date.now() - dwell.t0;
129 + if (ms >= 8000)
130 + track("detail_dwell",
131 + { entity_type: CONF.entity, entity_id: dwell.uid, dwell_ms: ms });
132 + dwell.uid = null;
133 + if (useBeacon) flush(true);
134 + }
135 +
136 + function checkRoute() {
137 + var uid = detailUid();
138 + if (uid === dwell.uid) return;
139 + endDwell(false);
140 + if (uid) dwell = { uid: uid, t0: Date.now() };
141 + }
142 +
143 + function watchRoutes() {
144 + ["pushState", "replaceState"].forEach(function (fn) {
145 + var orig = history[fn];
146 + history[fn] = function () {
147 + var out = orig.apply(this, arguments);
148 + setTimeout(checkRoute, 50);
149 + return out;
150 + };
151 + });
152 + window.addEventListener("popstate", function () { setTimeout(checkRoute, 50); });
153 + document.addEventListener("visibilitychange", function () {
154 + if (document.visibilityState === "hidden") { endDwell(true); }
155 + else checkRoute();
156 + });
157 + window.addEventListener("pagehide", function () { endDwell(true); });
158 + checkRoute();
159 + }
160 +
161 + /* ---------- visite de retour ---------- */
162 +
163 + function returnVisit() {
164 + try {
165 + var last = Number(localStorage.getItem("kaid_last") || 0);
166 + var now = Date.now();
167 + if (last && now - last > 72e6) track("return_visit", {}); // > 20 h
168 + localStorage.setItem("kaid_last", String(now));
169 + } catch (err) { /* stockage indisponible */ }
170 + }
171 +
172 + /* ---------- API publique ---------- */
173 +
174 + window.KAID = {
175 + track: track,
176 + connected: function () { return connected; },
177 + /** « Pas pour moi » — masque l'annonce partout et nourrit le signal négatif. */
178 + hide: function (itemId, features) {
179 + return fetch(API + "/hide", {
180 + method: "POST",
181 + headers: { "Content-Type": "application/json" },
182 + credentials: "same-origin",
183 + body: JSON.stringify({ item_id: itemId, on: true, features: features || null }),
184 + }).then(function (r) { return r.json(); });
185 + },
186 + /** Sauvegarde la recherche courante (par défaut : URL + titre du document). */
187 + saveSearch: function (opts) {
188 + opts = opts || {};
189 + return fetch(API + "/saved-searches", {
190 + method: "POST",
191 + headers: { "Content-Type": "application/json" },
192 + credentials: "same-origin",
193 + body: JSON.stringify({
194 + action: "add",
195 + label: opts.label || document.title.split("|")[0].split("—")[0].trim(),
196 + query: opts.query || null,
197 + filters: opts.filters || null,
198 + url: opts.url || (location.origin + location.pathname + location.search),
199 + alert: !!opts.alert,
200 + frequency: opts.frequency || null,
201 + }),
202 + }).then(function (r) { return r.json(); });
203 + },
204 + external: function (itemId, features) {
205 + track("external_click",
206 + { entity_type: CONF.entity, entity_id: itemId, features: features });
207 + flush(true);
208 + },
209 + };
210 +
211 + /* ---------- démarrage ---------- */
212 +
213 + function start() {
214 + if (!connected) return;
215 + watchClicks();
216 + watchRoutes();
217 + returnVisit();
218 + }
219 +
220 + if (document.readyState === "loading")
221 + document.addEventListener("DOMContentLoaded", function () { init(start); });
222 + else init(start);
223 +})();
added kaid/kaid.py +424 −0
@@ -0,0 +1,424 @@
1 +# -----------------------------------------------------------------------------
2 +# Groupe KA — kaid.py : client KA ID v2 (personnalisation) pour les satellites.
3 +# SOURCE CANONIQUE : ka-ui.git/kaid/kaid.py — copié dans le paquet backend de
4 +# chaque app (louka/, jobka/, sortika/, …) par sync-kaid.sh. Ne pas diverger :
5 +# corriger ICI puis redistribuer.
6 +#
7 +# Rôle : relier l'app au feature store du hub (groupe-ka.com) —
8 +# · track() journal d'interactions (serveur, fil d'exécution dédié)
9 +# · fetch_prefs() profil de préférences appris (cache 90 s, fail-open)
10 +# · rerank() reclassement personnalisé APRÈS la pertinence de base
11 +# · build_router() routes /api/kaid/* (événements client, masquage,
12 +# recherches sauvegardées)
13 +#
14 +# Contrat s2s (identique à hubfav/hubprofile) : HMAC-SHA256 du secret SSO
15 +# partagé — sig = HMAC(KA_SSO_SECRET, f"{CLIENT_ID}.{ka_id}.{ts}").
16 +# Config .env : KA_SSO_SECRET (déjà présent), KA_HUB_URL (optionnel).
17 +#
18 +# Principes : la connexion n'est JAMAIS requise ; sans profil ou à la moindre
19 +# erreur réseau → classement de base inchangé (fail-open). La personnalisation
20 +# ne remplace pas la pertinence : elle reclasse (blend) et n'écrase jamais
21 +# l'intention de la session (les dimensions explicitement filtrées par la
22 +# requête courante sont ignorées dans le score).
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import hashlib
27 +import hmac
28 +import json
29 +import os
30 +import threading
31 +import time
32 +
33 +import requests
34 +from fastapi import APIRouter, HTTPException, Request
35 +from pydantic import BaseModel
36 +
37 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
38 +CLIENT_ID = os.environ.get("KA_CLIENT_ID", "") # fixé par init() dans web.py
39 +
40 +PREFS_TTL = 90 # secondes de cache du profil
41 +TIMEOUT = 5 # secondes par appel hub
42 +LOCATION_DIMS = {"city", "region", "sector", "quartier", "ville",
43 + "location", "neighborhood"}
44 +PRICE_DIMS = {"price", "rent", "salary", "salary_year", "price_min"}
45 +
46 +# Événements acceptés depuis le navigateur (le reste vient du serveur).
47 +CLIENT_EVENT_TYPES = {
48 + "click", "impression", "detail_dwell", "scroll_depth", "return_visit",
49 + "share", "compare", "external_click", "map_open", "map_marker_click",
50 + "alert_open",
51 +}
52 +
53 +_prefs_cache: dict[str, tuple[float, dict | None]] = {}
54 +_seen_searches: dict[str, float] = {} # anti-doublon des recherches (120 s)
55 +_lock = threading.Lock()
56 +
57 +
58 +def init(client_id: str) -> None:
59 + """À appeler une fois au démarrage de l'app (web.py)."""
60 + global CLIENT_ID
61 + CLIENT_ID = client_id
62 +
63 +
64 +def _sig(ka_id: str, ts: int) -> str | None:
65 + secret = os.environ.get("KA_SSO_SECRET")
66 + if not secret or not CLIENT_ID:
67 + return None
68 + return hmac.new(secret.encode(),
69 + f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
70 + hashlib.sha256).hexdigest()
71 +
72 +
73 +def _signed_params(ka_id: str) -> dict | None:
74 + ts = int(time.time())
75 + sig = _sig(ka_id, ts)
76 + if not sig:
77 + return None
78 + return {"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig}
79 +
80 +
81 +def _ka_id_of(user) -> str | None:
82 + """Extrait un ka_id exploitable d'un dict utilisateur (ou None)."""
83 + if not user:
84 + return None
85 + ka = (user.get("ka_id") or "").strip() if isinstance(user, dict) else ""
86 + return ka if ka.startswith("ka-") else None
87 +
88 +
89 +# ---------------------------------------------------------------- événements
90 +
91 +def _post_events(ka_id: str, events: list[dict]) -> None:
92 + p = _signed_params(ka_id)
93 + if not p:
94 + return
95 + try:
96 + requests.post(f"{KA_HUB_URL}/api/sso/events", timeout=TIMEOUT,
97 + json={**p, "events": events})
98 + except Exception:
99 + pass # best-effort : jamais bloquant, jamais fatal
100 +
101 +
102 +def track(user, etype: str, *, entity_type: str | None = None,
103 + entity_id: str | None = None, query: str | None = None,
104 + filters: dict | None = None, position: int | None = None,
105 + features: dict | None = None, dwell_ms: int | None = None,
106 + session_id: str | None = None) -> None:
107 + """Journalise un événement au hub (fil dédié, zéro latence ajoutée).
108 + No-op si l'utilisateur n'est pas connecté via KA ID."""
109 + ka_id = _ka_id_of(user)
110 + if not ka_id:
111 + return
112 + if etype == "search":
113 + # anti-rafale : la même recherche (mêmes filtres) < 120 s n'est
114 + # journalisée qu'une fois — une SPA relance l'API à chaque frappe.
115 + key = ka_id + "|" + hashlib.sha1(
116 + json.dumps([query, filters], sort_keys=True, default=str).encode()
117 + ).hexdigest()
118 + now = time.time()
119 + with _lock:
120 + if now - _seen_searches.get(key, 0) < 120:
121 + return
122 + _seen_searches[key] = now
123 + if len(_seen_searches) > 2000:
124 + cutoff = now - 300
125 + for k in [k for k, t in _seen_searches.items() if t < cutoff]:
126 + del _seen_searches[k]
127 + ev: dict = {"type": etype}
128 + if entity_type: ev["entity_type"] = entity_type
129 + if entity_id: ev["entity_id"] = str(entity_id)
130 + if query: ev["query"] = str(query)[:200]
131 + if filters: ev["filters"] = filters
132 + if position is not None: ev["position"] = int(position)
133 + if features: ev["features"] = features
134 + if dwell_ms is not None: ev["dwell_ms"] = int(dwell_ms)
135 + if session_id: ev["session_id"] = str(session_id)[:60]
136 + threading.Thread(target=_post_events, args=(ka_id, [ev]), daemon=True).start()
137 +
138 +
139 +# ------------------------------------------------------------------ profil
140 +
141 +def fetch_prefs(ka_id: str | None) -> dict | None:
142 + """Profil de personnalisation du membre (cache 90 s). None si non
143 + connecté, non configuré ou hub injoignable — l'appelant retombe alors
144 + sur le classement de base."""
145 + if not ka_id or not str(ka_id).startswith("ka-"):
146 + return None
147 + now = time.time()
148 + with _lock:
149 + hit = _prefs_cache.get(ka_id)
150 + if hit and now - hit[0] < PREFS_TTL:
151 + return hit[1]
152 + data: dict | None = None
153 + p = _signed_params(ka_id)
154 + if p:
155 + try:
156 + r = requests.get(f"{KA_HUB_URL}/api/sso/prefs", params=p,
157 + timeout=TIMEOUT)
158 + if r.status_code == 200:
159 + data = r.json()
160 + except Exception:
161 + data = None
162 + with _lock:
163 + _prefs_cache[ka_id] = (now, data)
164 + if len(_prefs_cache) > 500:
165 + for k in list(_prefs_cache)[:100]:
166 + del _prefs_cache[k]
167 + return data
168 +
169 +
170 +def invalidate_prefs(ka_id: str | None) -> None:
171 + if not ka_id:
172 + return
173 + with _lock:
174 + _prefs_cache.pop(ka_id, None)
175 +
176 +
177 +# ---------------------------------------------------------------- reranking
178 +
179 +def _norm(v) -> str:
180 + return str(v).strip().lower()
181 +
182 +
183 +def personal_score(feats: dict, app_profile: dict, global_profile: dict,
184 + active_dims: set[str]) -> tuple[float | None, list[str]]:
185 + """Score personnel [0,1] d'une annonce, ou None si le profil ne couvre
186 + aucune de ses caractéristiques. `active_dims` = dimensions explicitement
187 + filtrées par la requête courante (intention de session > long terme)."""
188 + dims = app_profile.get("dims") or {}
189 + ranges = app_profile.get("ranges") or {}
190 + gl = (global_profile or {}).get("location") or {}
191 + num = 0.0
192 + den = 0.0
193 + reasons: list[str] = []
194 + for dim, val in (feats or {}).items():
195 + if val is None or dim in active_dims:
196 + continue
197 + if isinstance(val, bool):
198 + val = str(val)
199 + if isinstance(val, (int, float)):
200 + r = ranges.get(dim)
201 + if r and r.get("n", 0) >= 5:
202 + p25, p75 = r["p25"], r["p75"]
203 + iqr = max(p75 - p25, abs(r.get("p50", 0)) * 0.1, 1.0)
204 + if p25 <= val <= p75:
205 + aff = 1.0
206 + elif p25 - 1.5 * iqr <= val <= p75 + 1.5 * iqr:
207 + aff = 0.3
208 + else:
209 + aff = -0.4
210 + num += aff
211 + den += 1.0
212 + if aff == 1.0:
213 + reasons.append("MATCH_PRICE_RANGE" if dim in PRICE_DIMS
214 + else f"MATCH_{dim.upper()}_RANGE")
215 + continue
216 + vals = val if isinstance(val, (list, tuple)) else [val]
217 + vals = [_norm(v) for v in vals if v not in (None, "")]
218 + if not vals:
219 + continue
220 + d = dims.get(dim)
221 + if d:
222 + vv = d.get("values") or {}
223 + affs = [vv[v] for v in vals if v in vv]
224 + if affs:
225 + aff = max(affs)
226 + w = float(d.get("conf") or 0.5)
227 + num += w * aff
228 + den += w
229 + if aff >= 0.6:
230 + reasons.append("MATCH_LOCATION" if dim in LOCATION_DIMS
231 + else f"MATCH_{dim.upper()}")
232 + if dim in LOCATION_DIMS:
233 + gv = gl.get("values") or {}
234 + affs = [gv[v] for v in vals if v in gv]
235 + if affs and max(affs) > 0:
236 + w = 0.6 * float(gl.get("conf") or 0.3)
237 + num += w * max(affs)
238 + den += w
239 + if max(affs) >= 0.6 and "MATCH_LOCATION" not in reasons:
240 + reasons.append("MATCH_LOCATION")
241 + if den < 0.8:
242 + return None, []
243 + score = (num / den + 1.0) / 2.0
244 + return max(0.0, min(1.0, score)), reasons[:4]
245 +
246 +
247 +def rerank(items: list, user, *, features_of, uid_of=None,
248 + active_dims: set[str] | None = None, blend: float = 0.35,
249 + badge: float = 0.62, max_considered: int = 300,
250 + reco_key: str = "ka_reco") -> tuple[list, bool]:
251 + """Reclassement personnalisé APRÈS la pertinence de base.
252 + · items : liste (dicts) déjà triée par la pertinence de base
253 + · features_of : item -> dict de caractéristiques {dim: valeur}
254 + · uid_of : item -> identifiant canonique (défaut : item["uid"])
255 + · active_dims : dimensions filtrées par la requête (ignorées du score)
256 + Retourne (items, personnalisé?). Les annonces masquées (« Pas pour moi »)
257 + sont retirées. Annote item[reco_key] = {score, reasons} quand le score
258 + personnel est net (badge « Recommandé pour vous » — parcimonieux)."""
259 + if uid_of is None:
260 + uid_of = lambda it: (it.get("uid") if isinstance(it, dict) else None)
261 + ka_id = _ka_id_of(user)
262 + if not ka_id or not items:
263 + return items, False
264 + prefs = fetch_prefs(ka_id)
265 + if not prefs:
266 + return items, False
267 + hidden = set(prefs.get("hidden") or [])
268 + if hidden:
269 + items = [it for it in items if str(uid_of(it)) not in hidden]
270 + if not prefs.get("personalization"):
271 + return items, False
272 + profile = prefs.get("profile") or {}
273 + app_p = profile.get("app")
274 + if not app_p or not items:
275 + return items, False
276 +
277 + head = items[:max_considered]
278 + tail = items[max_considered:]
279 + n = len(head)
280 + active = active_dims or set()
281 + scored = []
282 + badged = 0
283 + for i, it in enumerate(head):
284 + base = 1.0 - i / max(n, 1)
285 + try:
286 + p, reasons = personal_score(features_of(it) or {}, app_p,
287 + profile.get("global") or {}, active)
288 + except Exception:
289 + p, reasons = None, []
290 + if p is None:
291 + final = (1.0 - blend) * base + blend * 0.5
292 + else:
293 + final = (1.0 - blend) * base + blend * p
294 + if p >= badge and reasons and badged < max(2, n // 8) \
295 + and isinstance(it, dict):
296 + it[reco_key] = {"score": round(p, 2), "reasons": reasons}
297 + badged += 1
298 + scored.append((final, i, it))
299 + scored.sort(key=lambda t: (-t[0], t[1])) # stable : départage par rang
300 + return [it for _, _, it in scored] + tail, True
301 +
302 +
303 +# ------------------------------------------------------- proxys hub (s2s)
304 +
305 +def _hub_post(ka_id: str, path: str, payload: dict) -> dict:
306 + p = _signed_params(ka_id)
307 + if not p:
308 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
309 + try:
310 + r = requests.post(f"{KA_HUB_URL}{path}", timeout=TIMEOUT,
311 + json={**p, **payload})
312 + return r.json() if r.status_code == 200 else {"error": r.status_code}
313 + except Exception:
314 + raise HTTPException(502, "hub KA injoignable")
315 +
316 +
317 +def _hub_get(ka_id: str, path: str) -> dict:
318 + p = _signed_params(ka_id)
319 + if not p:
320 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
321 + try:
322 + r = requests.get(f"{KA_HUB_URL}{path}", params=p, timeout=TIMEOUT)
323 + return r.json() if r.status_code == 200 else {"error": r.status_code}
324 + except Exception:
325 + raise HTTPException(502, "hub KA injoignable")
326 +
327 +
328 +# ------------------------------------------------------------------ routeur
329 +
330 +class _EventsIn(BaseModel):
331 + events: list[dict]
332 +
333 +
334 +class _HideIn(BaseModel):
335 + item_id: str
336 + on: bool = True
337 + features: dict | None = None
338 +
339 +
340 +class _SearchIn(BaseModel):
341 + action: str = "add" # add | remove | alert | touch
342 + id: int | None = None
343 + label: str | None = None
344 + query: str | None = None
345 + filters: dict | None = None
346 + location: str | None = None
347 + url: str | None = None
348 + alert: bool = False
349 + frequency: str | None = None
350 +
351 +
352 +def build_router(get_user) -> APIRouter:
353 + """Routes /api/kaid/* de l'app. `get_user(request)` = current_user de
354 + l'app (dict avec ka_id, ou None)."""
355 + router = APIRouter(prefix="/api/kaid")
356 +
357 + def _require_ka(request: Request) -> tuple[dict, str]:
358 + user = get_user(request)
359 + ka_id = _ka_id_of(user)
360 + if not ka_id:
361 + raise HTTPException(401, "connexion KA ID requise")
362 + return user, ka_id
363 +
364 + @router.get("/status")
365 + def status(request: Request):
366 + user = get_user(request)
367 + ka_id = _ka_id_of(user)
368 + if not ka_id:
369 + return {"connected": False}
370 + prefs = fetch_prefs(ka_id)
371 + return {
372 + "connected": True,
373 + "personalization": bool(prefs and prefs.get("personalization")),
374 + "monka_url": f"{KA_HUB_URL}/mon-ka",
375 + }
376 +
377 + @router.post("/events")
378 + def client_events(request: Request, body: _EventsIn):
379 + user = get_user(request)
380 + ka_id = _ka_id_of(user)
381 + if not ka_id:
382 + return {"ok": True, "stored": 0}
383 + events = []
384 + for e in body.events[:20]:
385 + if e.get("type") in CLIENT_EVENT_TYPES:
386 + events.append({k: e[k] for k in
387 + ("type", "entity_type", "entity_id", "query",
388 + "filters", "position", "features", "dwell_ms",
389 + "session_id") if k in e})
390 + if events:
391 + threading.Thread(target=_post_events, args=(ka_id, events),
392 + daemon=True).start()
393 + return {"ok": True, "stored": len(events)}
394 +
395 + @router.post("/hide")
396 + def hide(request: Request, body: _HideIn):
397 + _, ka_id = _require_ka(request)
398 + out = _hub_post(ka_id, "/api/sso/hide", {
399 + "item_id": body.item_id, "on": body.on,
400 + "features": body.features,
401 + })
402 + invalidate_prefs(ka_id)
403 + return out
404 +
405 + @router.get("/saved-searches")
406 + def saved_list(request: Request):
407 + _, ka_id = _require_ka(request)
408 + return _hub_get(ka_id, "/api/sso/saved-searches")
409 +
410 + @router.post("/saved-searches")
411 + def saved_post(request: Request, body: _SearchIn):
412 + _, ka_id = _require_ka(request)
413 + search: dict = {k: v for k, v in {
414 + "id": body.id, "label": body.label, "query": body.query,
415 + "filters": body.filters, "location": body.location,
416 + "url": body.url, "alert": body.alert,
417 + "frequency": body.frequency,
418 + }.items() if v is not None}
419 + out = _hub_post(ka_id, "/api/sso/saved-searches",
420 + {"action": body.action, "search": search})
421 + invalidate_prefs(ka_id)
422 + return out
423 +
424 + return router
added kaid/sync-kaid.sh +50 −0
@@ -0,0 +1,50 @@
1 +#!/bin/bash
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +# ka-ui/kaid/sync-kaid.sh — redistribue le client KA ID v2 dans les apps,
4 +# directement SUR LES NŒUDS (remote-first). À lancer depuis M3U96a.
5 +# kaid.py → paquet backend de l'app (louka/, jobka/, …)
6 +# ka-id.js → frontend/public/ ET frontend/dist/ (si présents)
7 +# Usage : ./sync-kaid.sh [app…] (défaut : toutes)
8 +# Rebuild frontend + pm2 restart requis par app ensuite.
9 +set -uo pipefail
10 +cd "$(dirname "$0")"
11 +
12 +# app|node|dir_paquet_backend|dir_frontend (public/dist relatifs à celui-ci)
13 +MAP="
14 +lou-ka|M3U96b|~/apps/lou-ka/louka|~/apps/lou-ka/frontend
15 +crea-ka|M3U96b|~/apps/crea-ka/creaka|~/apps/crea-ka/frontend
16 +resto-ka|M3U96b|~/apps/resto-ka/restoka|~/apps/resto-ka/frontend
17 +immo-ka|M4M64a|~/apps/immo-ka/immoka|~/apps/immo-ka/frontend
18 +fabri-ka|M4M64a|~/fabri-ka/fabrika|~/fabri-ka/frontend
19 +auto-ka|M4M64b|~/auto-ka/autoka|~/auto-ka/frontend
20 +food-ka|M4M64b|~/apps/food-ka/foodka|~/apps/food-ka/frontend
21 +sorti-ka|M3U96a|~/apps/sorti-ka/sortika|~/apps/sorti-ka/frontend
22 +job-ka|M3U96a|~/apps/job-ka/jobka|~/apps/job-ka/frontend
23 +"
24 +
25 +only=("$@")
26 +for line in $MAP; do
27 + IFS='|' read -r app node pkg front <<<"$line"
28 + if [ ${#only[@]} -gt 0 ]; then
29 + case " ${only[*]} " in *" $app "*) ;; *) continue ;; esac
30 + fi
31 + echo "→ $app ($node)"
32 + if [ "$node" = "M3U96a" ]; then
33 + cp kaid.py "${pkg/#\~/$HOME}/" &&
34 + for d in public dist .; do
35 + dir="${front/#\~/$HOME}/$d"
36 + [ -d "$dir" ] && cp ka-id.js "$dir/" && echo " ka-id.js → $d"
37 + done
38 + else
39 + scp -q kaid.py "$node:$pkg/" &&
40 + ssh "$node" "for d in public dist .; do
41 + [ -d $front/\$d ] && cp /tmp/ka-id.js.$$ $front/\$d/ 2>/dev/null; done" 2>/dev/null
42 + scp -q ka-id.js "$node:/tmp/ka-id.js.$$" &&
43 + ssh "$node" "for d in public dist; do
44 + [ -d $front/\$d ] && cp /tmp/ka-id.js.$$ $front/\$d/ka-id.js && echo \" ka-id.js → \$d\"; done;
45 + [ -f $front/index.html ] && cp /tmp/ka-id.js.$$ $front/ka-id.js && echo ' ka-id.js → frontend/';
46 + rm -f /tmp/ka-id.js.$$"
47 + fi
48 + echo " ok"
49 +done
50 +echo "Terminé. Intégration par app : voir kaid/README-KAID.md."
51