feat(ka-id v2): personnalisation Groupe KA — creaka/kaid.py (canonique ka-ui.git/kaid), journal serveur (search/detail_view/favorite), reranking du tri par défaut « reach » (niche/région/langue/plateforme/taille), routeur /api/kaid, SDK ka-id.js (cartes data-href), badge « Recommandé pour vous »
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +936 −8
added
creaka/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
creaka/web.py
+47 −6
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware | ||
| 16 | 16 | from fastapi.responses import FileResponse, RedirectResponse, Response |
| 17 | 17 | from pydantic import BaseModel, Field |
| 18 | 18 | |
| 19 | −from . import auth, db, ethics, hubfav, kapdf, seo | |
| 19 | +from . import auth, db, ethics, hubfav, kaid, kapdf, seo | |
| 20 | 20 | from . import stats as stats_mod |
| 21 | 21 | from .normalize import NICHES, PLATFORMS, REGIONS |
| 22 | 22 | |
@@ -47,6 +47,22 @@ def _db(): | ||
| 47 | 47 | # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py |
| 48 | 48 | app.include_router(auth.router) |
| 49 | 49 | |
| 50 | +# personnalisation KA ID v2 (creaka/kaid.py, canonique ka-ui.git/kaid) | |
| 51 | +kaid.init("crea-ka") | |
| 52 | +app.include_router(kaid.build_router(auth.current_user)) | |
| 53 | + | |
| 54 | + | |
| 55 | +def _kaid_features(d: dict) -> dict: | |
| 56 | + """Caractéristiques d'un créateur pour le profil de préférences KA ID.""" | |
| 57 | + return {k: v for k, v in { | |
| 58 | + "niche": d.get("niches") or None, | |
| 59 | + "region": d.get("region"), "city": d.get("city"), | |
| 60 | + "langue": d.get("languages") or None, | |
| 61 | + "tier": d.get("audience_tier"), | |
| 62 | + "platform": d.get("platforms") or d.get("primary_platform") or None, | |
| 63 | + "creator_type": d.get("creator_type"), | |
| 64 | + }.items() if v not in (None, "", [])} | |
| 65 | + | |
| 50 | 66 | |
| 51 | 67 | @app.get("/healthz") |
| 52 | 68 | def healthz(): |
@@ -61,23 +77,43 @@ def health(): | ||
| 61 | 77 | |
| 62 | 78 | |
| 63 | 79 | @app.get("/api/creators") |
| 64 | −def list_creators(q: str = "", niche: str = "", region: str = "", | |
| 80 | +def list_creators(request: Request, | |
| 81 | + q: str = "", niche: str = "", region: str = "", | |
| 65 | 82 | langue: str = "", plateforme: str = "", tier: str = "", |
| 66 | 83 | sort: str = "reach", |
| 67 | 84 | limit: int = Query(60, le=200), offset: int = Query(0, ge=0)): |
| 68 | − return db.search(_db(), q=q.strip(), niche=niche, region=region, | |
| 69 | − langue=langue, plateforme=plateforme, tier=tier, | |
| 70 | − sort=sort, limit=limit, offset=offset) | |
| 85 | + out = db.search(_db(), q=q.strip(), niche=niche, region=region, | |
| 86 | + langue=langue, plateforme=plateforme, tier=tier, | |
| 87 | + sort=sort, limit=limit, offset=offset) | |
| 88 | + # personnalisation KA ID : journal + reclassement (tri par défaut « reach » | |
| 89 | + # seulement ; intention de session > préférences long terme) | |
| 90 | + out["personalized"] = False | |
| 91 | + user = auth.current_user(request) | |
| 92 | + if user: | |
| 93 | + filters = {k: v for k, v in { | |
| 94 | + "niche": niche, "region": region, "langue": langue, | |
| 95 | + "platform": plateforme, "tier": tier, "q": q.strip()}.items() if v} | |
| 96 | + if filters and offset == 0: | |
| 97 | + kaid.track(user, "search", query=q.strip() or None, filters=filters) | |
| 98 | + if sort == "reach": | |
| 99 | + active = {k for k in ("niche", "region", "langue", "platform", | |
| 100 | + "tier") if filters.get(k)} | |
| 101 | + out["items"], out["personalized"] = kaid.rerank( | |
| 102 | + out["items"], user, features_of=_kaid_features, | |
| 103 | + uid_of=lambda it: it.get("id"), active_dims=active) | |
| 104 | + return out | |
| 71 | 105 | |
| 72 | 106 | |
| 73 | 107 | @app.get("/api/creators/{cid}") |
| 74 | −def get_creator(cid: str): | |
| 108 | +def get_creator(cid: str, request: Request): | |
| 75 | 109 | creator = db.get_creator(_db(), cid) |
| 76 | 110 | if creator is None: |
| 77 | 111 | raise HTTPException(404, "créateur introuvable") |
| 78 | 112 | # insights « légendaires » : croissance (snapshots), engagement pondéré, |
| 79 | 113 | # rythme de publication, Ka Score /100 + historique pour la sparkline |
| 80 | 114 | creator["insights"] = stats_mod.creator_insights(_db(), creator) |
| 115 | + kaid.track(auth.current_user(request), "detail_view", entity_type="creator", | |
| 116 | + entity_id=cid, features=_kaid_features(creator)) | |
| 81 | 117 | return creator |
| 82 | 118 | |
| 83 | 119 | |
@@ -234,6 +270,11 @@ def toggle_favorite(request: Request, body: dict = Body(...)): | ||
| 234 | 270 | raise HTTPException(422, "item.item_id requis") |
| 235 | 271 | if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): |
| 236 | 272 | raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") |
| 273 | + # signal fort du moteur de préférences KA ID | |
| 274 | + creator = db.get_creator(_db(), item["item_id"]) | |
| 275 | + kaid.track(user, "favorite" if on else "unfavorite", entity_type="creator", | |
| 276 | + entity_id=item["item_id"], | |
| 277 | + features=_kaid_features(creator) if creator else None) | |
| 237 | 278 | return {"ok": True, "on": on} |
| 238 | 279 | |
| 239 | 280 | |
modified
frontend/dist/index.html
+9 −1
@@ -843,7 +843,10 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo | ||
| 843 | 843 | .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700; |
| 844 | 844 | font-size:46px;letter-spacing:.02em} |
| 845 | 845 | .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} |
| 846 | −.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2} | |
| 846 | +.card-photo .kc-reco{position:absolute;bottom:8px;left:8px;padding:2px 8px 3px;border-radius:5px; | |
| 847 | + background:var(--ink);color:var(--accent);font-family:var(--font-mono);font-size:9px; | |
| 848 | + font-weight:700;text-transform:uppercase;letter-spacing:.07em;z-index:2} | |
| 849 | +.tier{position:absolute;top:10px;left:10px;z-index:2} | |
| 847 | 850 | .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink); |
| 848 | 851 | color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl); |
| 849 | 852 | padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700; |
@@ -1525,6 +1528,7 @@ function cardHtml(c){ | ||
| 1525 | 1528 | return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer"> |
| 1526 | 1529 | <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)} |
| 1527 | 1530 | <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span> |
| 1531 | + ${c.ka_reco?`<span class="kc-reco" title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka">Recommandé pour vous</span>`:""} | |
| 1528 | 1532 | <span class="nplat">${np} plat.</span>${favBtn(c)}</div> |
| 1529 | 1533 | <div class="card-body"> |
| 1530 | 1534 | <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div> |
@@ -2529,5 +2533,9 @@ route(); | ||
| 2529 | 2533 | </script> |
| 2530 | 2534 | <script>window.KA_AGENT={site:"crea-ka"}</script> |
| 2531 | 2535 | <script src="/ka-agent.js" defer></script> |
| 2536 | +<!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) --> | |
| 2537 | +<script>window.KAID_CONF={card:".card[data-href^='/createur/']",detail:"^/createur/(.+)$",entity:"creator", | |
| 2538 | + uid:function(el){var h=el.getAttribute("data-href")||"";var m=h.match(/^\/createur\/(.+)$/);return m?decodeURIComponent(m[1]):null;}}</script> | |
| 2539 | +<script src="/ka-id.js" defer></script> | |
| 2532 | 2540 | </body> |
| 2533 | 2541 | </html> |
added
frontend/dist/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 | +})(); | |
modified
frontend/src/index.template.html
+9 −1
@@ -205,7 +205,10 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo | ||
| 205 | 205 | .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700; |
| 206 | 206 | font-size:46px;letter-spacing:.02em} |
| 207 | 207 | .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} |
| 208 | −.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2} | |
| 208 | +.card-photo .kc-reco{position:absolute;bottom:8px;left:8px;padding:2px 8px 3px;border-radius:5px; | |
| 209 | + background:var(--ink);color:var(--accent);font-family:var(--font-mono);font-size:9px; | |
| 210 | + font-weight:700;text-transform:uppercase;letter-spacing:.07em;z-index:2} | |
| 211 | +.tier{position:absolute;top:10px;left:10px;z-index:2} | |
| 209 | 212 | .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink); |
| 210 | 213 | color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl); |
| 211 | 214 | padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700; |
@@ -887,6 +890,7 @@ function cardHtml(c){ | ||
| 887 | 890 | return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer"> |
| 888 | 891 | <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)} |
| 889 | 892 | <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span> |
| 893 | + ${c.ka_reco?`<span class="kc-reco" title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka">Recommandé pour vous</span>`:""} | |
| 890 | 894 | <span class="nplat">${np} plat.</span>${favBtn(c)}</div> |
| 891 | 895 | <div class="card-body"> |
| 892 | 896 | <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div> |
@@ -1891,5 +1895,9 @@ route(); | ||
| 1891 | 1895 | </script> |
| 1892 | 1896 | <script>window.KA_AGENT={site:"crea-ka"}</script> |
| 1893 | 1897 | <script src="/ka-agent.js" defer></script> |
| 1898 | +<!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) --> | |
| 1899 | +<script>window.KAID_CONF={card:".card[data-href^='/createur/']",detail:"^/createur/(.+)$",entity:"creator", | |
| 1900 | + uid:function(el){var h=el.getAttribute("data-href")||"";var m=h.match(/^\/createur\/(.+)$/);return m?decodeURIComponent(m[1]):null;}}</script> | |
| 1901 | +<script src="/ka-id.js" defer></script> | |
| 1894 | 1902 | </body> |
| 1895 | 1903 | </html> |
added
frontend/src/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 | +})(); | |
modified
scripts/build_frontend.py
+1 −0
@@ -40,6 +40,7 @@ def main() -> None: | ||
| 40 | 40 | # Widget KA Agent (bulle de chat IA Groupe KA) — source perenne dans |
| 41 | 41 | # frontend/src/, copie vers dist/ a chaque build (dist est regenere). |
| 42 | 42 | (DIST / "ka-agent.js").write_bytes((SRC / "ka-agent.js").read_bytes()) |
| 43 | + (DIST / "ka-id.js").write_bytes((SRC / "ka-id.js").read_bytes()) | |
| 43 | 44 | print(f"[crea-ka] frontend construit → {DIST/'index.html'} " |
| 44 | 45 | f"({len(html)//1024} Ko, {len(icons)} icônes, tokens ka-ui + écosystème injectés)") |
| 45 | 46 | |
| 46 | 47 | |