SPB Git

Repository Public

Commit

feat(ka-id v2): personnalisation Groupe KA — sortika/kaid.py (canonique ka-ui.git/kaid), journal serveur (search/detail_view/favorite), mode annotation (ordre chronologique préservé, blend=0 : badge « Recommandé pour vous » + exclusion des masqués), routeur /api/kaid, SDK ka-id.js

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 26, 2026 parent 68a0ce3
4 changed files +700 −4 Browse files
modified frontend/index.html +7 −1
@@ -286,6 +286,9 @@ html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}
286 .ev-body{display:flex;flex-direction:column;gap:4px;min-width:0} 286 .ev-body{display:flex;flex-direction:column;gap:4px;min-width:0}
287 .ev-date{font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase; 287 .ev-date{font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;
288 letter-spacing:.05em;color:var(--accent-deep)} 288 letter-spacing:.05em;color:var(--accent-deep)}
289+/* KA ID v2 : badge « Recommandé pour vous » (parcimonieux, posé par le serveur) */
290+.ev-reco{display:inline-block;margin-left:6px;padding:1px 7px 2px;border-radius:5px;
291+ background:var(--ink);color:var(--accent);font-size:9px;letter-spacing:.07em}
289 .ev h3{font-family:var(--font-display);font-size:16.5px;font-weight:700;line-height:1.22; 292 .ev h3{font-family:var(--font-display);font-size:16.5px;font-weight:700;line-height:1.22;
290 letter-spacing:-.015em;margin:0;display:-webkit-box;-webkit-line-clamp:2; 293 letter-spacing:-.015em;margin:0;display:-webkit-box;-webkit-line-clamp:2;
291 -webkit-box-orient:vertical;overflow:hidden} 294 -webkit-box-orient:vertical;overflow:hidden}
@@ -1219,7 +1222,7 @@ function card(ev){
1219 return `<a class="ev" data-nav href="/evenement/${esc(ev.uid)}"> 1222 return `<a class="ev" data-nav href="/evenement/${esc(ev.uid)}">
1220 <div class="ev-img${ev.image?"":" noimg"}">${evImg(ev)}${evTag(ev)}${favBtn(ev)}</div> 1223 <div class="ev-img${ev.image?"":" noimg"}">${evImg(ev)}${evTag(ev)}${favBtn(ev)}</div>
1221 <div class="ev-body"> 1224 <div class="ev-body">
1222− <span class="ev-date">${evDate(ev)}</span> 1225+ <span class="ev-date">${evDate(ev)}${ev.ka_reco?` <span class="ev-reco" title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka">Recommandé pour vous</span>`:""}</span>
1223 <h3>${esc(ev.title)}</h3> 1226 <h3>${esc(ev.title)}</h3>
1224 <p class="ev-where">${where || esc(ev.region || "Québec")}</p> 1227 <p class="ev-where">${where || esc(ev.region || "Québec")}</p>
1225 <p class="ev-meta">${cat?`<span>${esc(CAT_LABELS[cat]||cat)}</span>`:""} 1228 <p class="ev-meta">${cat?`<span>${esc(CAT_LABELS[cat]||cat)}</span>`:""}
@@ -2816,6 +2819,9 @@ init();
2816 </script> 2819 </script>
2817 <script>window.KA_AGENT={site:"sorti-ka"}</script> 2820 <script>window.KA_AGENT={site:"sorti-ka"}</script>
2818 <script src="/ka-agent.js" defer></script> 2821 <script src="/ka-agent.js" defer></script>
2822+ <!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) -->
2823+ <script>window.KAID_CONF={card:"a[href^='/evenement/']",detail:"^/evenement/(.+)$",entity:"event"}</script>
2824+ <script src="/ka-id.js" defer></script>
2819 2825
2820 <!-- ======== KA Tabbar v1 — navigation mobile commune Groupe KA (ka-ui/nav) ======== --> 2826 <!-- ======== KA Tabbar v1 — navigation mobile commune Groupe KA (ka-ui/nav) ======== -->
2821 <nav class="ka-tabbar" id="ka-tabbar" aria-label="Navigation mobile"> 2827 <nav class="ka-tabbar" id="ka-tabbar" aria-label="Navigation mobile">
added frontend/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 sortika/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
modified sortika/web.py +46 −3
@@ -17,7 +17,7 @@ from fastapi.middleware.gzip import GZipMiddleware
17 from fastapi.responses import FileResponse, Response 17 from fastapi.responses import FileResponse, Response
18 from fastapi.staticfiles import StaticFiles 18 from fastapi.staticfiles import StaticFiles
19 19
20−from . import auth, db, hubfav, kapdf, seo 20+from . import auth, db, hubfav, kaid, kapdf, seo
21 from . import stats as kstats 21 from . import stats as kstats
22 from .normalize import CATEGORIES 22 from .normalize import CATEGORIES
23 from .regions import REGIONS 23 from .regions import REGIONS
@@ -32,6 +32,24 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"],
32 allow_methods=["*"], allow_headers=["*"]) 32 allow_methods=["*"], allow_headers=["*"])
33 app.add_middleware(GZipMiddleware, minimum_size=1000) 33 app.add_middleware(GZipMiddleware, minimum_size=1000)
34 34
35+# personnalisation KA ID v2 (sortika/kaid.py, canonique ka-ui.git/kaid).
36+# Particularité Sorti·Ka : le tri par défaut est CHRONOLOGIQUE — on ne
37+# reclasse pas (blend=0), on annote (« Recommandé pour vous »), on exclut
38+# les masqués et on journalise ; le profil appris profite aux autres univers.
39+kaid.init("sorti-ka")
40+app.include_router(kaid.build_router(auth.current_user))
41+
42+
43+def _kaid_features(d: dict) -> dict:
44+ """Caractéristiques d'un événement pour le profil de préférences KA ID."""
45+ return {k: v for k, v in {
46+ "city": d.get("city"), "region": d.get("region"),
47+ "category": d.get("categories") or None,
48+ "venue": d.get("venue"),
49+ "is_free": bool(d.get("is_free")) if d.get("is_free") is not None else None,
50+ "price": d.get("price_min"),
51+ }.items() if v not in (None, "", [])}
52+
35 53
36 def _row_to_dict(row) -> dict: 54 def _row_to_dict(row) -> dict:
37 d = dict(row) 55 d = dict(row)
@@ -53,6 +71,7 @@ def health() -> dict:
53 71
54 @app.get("/api/events") 72 @app.get("/api/events")
55 def list_events( 73 def list_events(
74+ request: Request,
56 q: str = Query("", description="recherche plein texte (titre, lieu, description)"), 75 q: str = Query("", description="recherche plein texte (titre, lieu, description)"),
57 region: str = Query("", description="région administrative"), 76 region: str = Query("", description="région administrative"),
58 city: str = Query("", description="ville"), 77 city: str = Query("", description="ville"),
@@ -147,17 +166,33 @@ def list_events(
147 d = _row_to_dict(dict(r) | {}) 166 d = _row_to_dict(dict(r) | {})
148 d.pop("rn", None) 167 d.pop("rn", None)
149 events.append(d) 168 events.append(d)
169+ # personnalisation KA ID : journal + annotation (ordre chronologique
170+ # préservé — blend=0 ; masqués exclus, badge sur les correspondances nettes)
171+ user = auth.current_user(request)
172+ if user:
173+ filters = {k: v for k, v in {"q": q, "region": region, "city": city,
174+ "category": category, "free": free}.items() if v}
175+ if filters and offset == 0:
176+ kaid.track(user, "search", query=q or None, filters=filters)
177+ active = {k for k in ("region", "city", "category") if filters.get(k)}
178+ if free:
179+ active.add("is_free")
180+ events, _ = kaid.rerank(events, user, features_of=_kaid_features,
181+ active_dims=active, blend=0.0)
150 return {"total": total, "limit": limit, "offset": offset, "events": events} 182 return {"total": total, "limit": limit, "offset": offset, "events": events}
151 183
152 184
153 @app.get("/api/events/{uid}") 185 @app.get("/api/events/{uid}")
154−def get_event(uid: str) -> dict: 186+def get_event(uid: str, request: Request) -> dict:
155 con = db.connect() 187 con = db.connect()
156 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone() 188 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()
157 con.close() 189 con.close()
158 if row is None: 190 if row is None:
159 raise HTTPException(404, "événement introuvable") 191 raise HTTPException(404, "événement introuvable")
160− return _row_to_dict(row) 192+ d = _row_to_dict(row)
193+ kaid.track(auth.current_user(request), "detail_view", entity_type="event",
194+ entity_id=uid, features=_kaid_features(d))
195+ return d
161 196
162 197
163 @app.get("/api/stats") 198 @app.get("/api/stats")
@@ -342,6 +377,14 @@ def toggle_favorite(request: Request, body: dict = Body(...)):
342 raise HTTPException(422, "item.item_id requis") 377 raise HTTPException(422, "item.item_id requis")
343 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): 378 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
344 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") 379 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
380+ # signal fort du moteur de préférences (features lues de la BD)
381+ uid = item["item_id"]
382+ con = db.connect()
383+ row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()
384+ con.close()
385+ kaid.track(user, "favorite" if on else "unfavorite", entity_type="event",
386+ entity_id=uid,
387+ features=_kaid_features(_row_to_dict(row)) if row else None)
345 return {"ok": True, "on": on} 388 return {"ok": True, "on": on}
346 389
347 390
348 391
Menu

Theme