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 de4# 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 base11# · build_router() routes /api/kaid/* (événements client, masquage,12# recherches sauvegardées)13#14# Contrat s2s (identique à hubfav/hubprofile) : HMAC-SHA256 du secret SSO15# 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 moindre19# erreur réseau → classement de base inchangé (fail-open). La personnalisation20# ne remplace pas la pertinence : elle reclasse (blend) et n'écrase jamais21# l'intention de la session (les dimensions explicitement filtrées par la22# requête courante sont ignorées dans le score).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import hashlib27import hmac28import json29import os30import threading31import time3233import requests34from fastapi import APIRouter, HTTPException, Request35from pydantic import BaseModel3637KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")38CLIENT_ID = os.environ.get("KA_CLIENT_ID", "") # fixé par init() dans web.py3940PREFS_TTL = 90 # secondes de cache du profil41TIMEOUT = 5 # secondes par appel hub42LOCATION_DIMS = {"city", "region", "sector", "quartier", "ville",43 "location", "neighborhood"}44PRICE_DIMS = {"price", "rent", "salary", "salary_year", "price_min"}4546# Événements acceptés depuis le navigateur (le reste vient du serveur).47CLIENT_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}5253_prefs_cache: dict[str, tuple[float, dict | None]] = {}54_seen_searches: dict[str, float] = {} # anti-doublon des recherches (120 s)55_lock = threading.Lock()565758def init(client_id: str) -> None:59 """À appeler une fois au démarrage de l'app (web.py)."""60 global CLIENT_ID61 CLIENT_ID = client_id626364def _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 None68 return hmac.new(secret.encode(),69 f"{CLIENT_ID}.{ka_id}.{ts}".encode(),70 hashlib.sha256).hexdigest()717273def _signed_params(ka_id: str) -> dict | None:74 ts = int(time.time())75 sig = _sig(ka_id, ts)76 if not sig:77 return None78 return {"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig}798081def _ka_id_of(user) -> str | None:82 """Extrait un ka_id exploitable d'un dict utilisateur (ou None)."""83 if not user:84 return None85 ka = (user.get("ka_id") or "").strip() if isinstance(user, dict) else ""86 return ka if ka.startswith("ka-") else None878889# ---------------------------------------------------------------- événements9091def _post_events(ka_id: str, events: list[dict]) -> None:92 p = _signed_params(ka_id)93 if not p:94 return95 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 fatal100101102def 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 return112 if etype == "search":113 # anti-rafale : la même recherche (mêmes filtres) < 120 s n'est114 # 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 return122 _seen_searches[key] = now123 if len(_seen_searches) > 2000:124 cutoff = now - 300125 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_type129 if entity_id: ev["entity_id"] = str(entity_id)130 if query: ev["query"] = str(query)[:200]131 if filters: ev["filters"] = filters132 if position is not None: ev["position"] = int(position)133 if features: ev["features"] = features134 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()137138139# ------------------------------------------------------------------ profil140141def fetch_prefs(ka_id: str | None) -> dict | None:142 """Profil de personnalisation du membre (cache 90 s). None si non143 connecté, non configuré ou hub injoignable — l'appelant retombe alors144 sur le classement de base."""145 if not ka_id or not str(ka_id).startswith("ka-"):146 return None147 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 = None153 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 = None162 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 data168169170def invalidate_prefs(ka_id: str | None) -> None:171 if not ka_id:172 return173 with _lock:174 _prefs_cache.pop(ka_id, None)175176177# ---------------------------------------------------------------- reranking178179def _norm(v) -> str:180 return str(v).strip().lower()181182183def 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 couvre186 aucune de ses caractéristiques. `active_dims` = dimensions explicitement187 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.0192 den = 0.0193 reasons: list[str] = []194 for dim, val in (feats or {}).items():195 if val is None or dim in active_dims:196 continue197 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.0206 elif p25 - 1.5 * iqr <= val <= p75 + 1.5 * iqr:207 aff = 0.3208 else:209 aff = -0.4210 # poids réduit : une plage numérique seule (prix…) ne doit211 # jamais suffire à personnaliser (0.6 < seuil den 0.8) —212 # sinon tout item au « bon prix » score 1.0 et noie les213 # correspondances réelles (ville, marque, type).214 w = 0.6215 num += w * aff216 den += w217 if aff == 1.0:218 reasons.append("MATCH_PRICE_RANGE" if dim in PRICE_DIMS219 else f"MATCH_{dim.upper()}_RANGE")220 continue221 vals = val if isinstance(val, (list, tuple)) else [val]222 vals = [_norm(v) for v in vals if v not in (None, "")]223 if not vals:224 continue225 d = dims.get(dim)226 if d:227 vv = d.get("values") or {}228 affs = [vv[v] for v in vals if v in vv]229 if affs:230 aff = max(affs)231 w = float(d.get("conf") or 0.5)232 num += w * aff233 den += w234 if aff >= 0.6:235 reasons.append("MATCH_LOCATION" if dim in LOCATION_DIMS236 else f"MATCH_{dim.upper()}")237 if dim in LOCATION_DIMS:238 gv = gl.get("values") or {}239 affs = [gv[v] for v in vals if v in gv]240 if affs and max(affs) > 0:241 w = 0.6 * float(gl.get("conf") or 0.3)242 num += w * max(affs)243 den += w244 if max(affs) >= 0.6 and "MATCH_LOCATION" not in reasons:245 reasons.append("MATCH_LOCATION")246 if den < 0.8:247 return None, []248 score = (num / den + 1.0) / 2.0249 return max(0.0, min(1.0, score)), reasons[:4]250251252def rerank(items: list, user, *, features_of, uid_of=None,253 active_dims: set[str] | None = None, blend: float = 0.35,254 badge: float = 0.62, max_considered: int = 300,255 reco_key: str = "ka_reco") -> tuple[list, bool]:256 """Reclassement personnalisé APRÈS la pertinence de base.257 · items : liste (dicts) déjà triée par la pertinence de base258 · features_of : item -> dict de caractéristiques {dim: valeur}259 · uid_of : item -> identifiant canonique (défaut : item["uid"])260 · active_dims : dimensions filtrées par la requête (ignorées du score)261 Retourne (items, personnalisé?). Les annonces masquées (« Pas pour moi »)262 sont retirées. Annote item[reco_key] = {score, reasons} quand le score263 personnel est net (badge « Recommandé pour vous » — parcimonieux)."""264 if uid_of is None:265 uid_of = lambda it: (it.get("uid") if isinstance(it, dict) else None)266 ka_id = _ka_id_of(user)267 if not ka_id or not items:268 return items, False269 prefs = fetch_prefs(ka_id)270 if not prefs:271 return items, False272 hidden = set(prefs.get("hidden") or [])273 if hidden:274 items = [it for it in items if str(uid_of(it)) not in hidden]275 if not prefs.get("personalization"):276 return items, False277 profile = prefs.get("profile") or {}278 app_p = profile.get("app")279 if not app_p or not items:280 return items, False281282 head = items[:max_considered]283 tail = items[max_considered:]284 n = len(head)285 active = active_dims or set()286 scored = []287 badged = 0288 for i, it in enumerate(head):289 base = 1.0 - i / max(n, 1)290 try:291 p, reasons = personal_score(features_of(it) or {}, app_p,292 profile.get("global") or {}, active)293 except Exception:294 p, reasons = None, []295 if p is None:296 final = (1.0 - blend) * base + blend * 0.5297 else:298 final = (1.0 - blend) * base + blend * p299 if p >= badge and reasons and badged < max(2, n // 8) \300 and isinstance(it, dict):301 it[reco_key] = {"score": round(p, 2), "reasons": reasons}302 badged += 1303 scored.append((final, i, it))304 scored.sort(key=lambda t: (-t[0], t[1])) # stable : départage par rang305 return [it for _, _, it in scored] + tail, True306307308# ------------------------------------------------------- proxys hub (s2s)309310def _hub_post(ka_id: str, path: str, payload: dict) -> dict:311 p = _signed_params(ka_id)312 if not p:313 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")314 try:315 r = requests.post(f"{KA_HUB_URL}{path}", timeout=TIMEOUT,316 json={**p, **payload})317 return r.json() if r.status_code == 200 else {"error": r.status_code}318 except Exception:319 raise HTTPException(502, "hub KA injoignable")320321322def _hub_get(ka_id: str, path: str) -> dict:323 p = _signed_params(ka_id)324 if not p:325 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")326 try:327 r = requests.get(f"{KA_HUB_URL}{path}", params=p, timeout=TIMEOUT)328 return r.json() if r.status_code == 200 else {"error": r.status_code}329 except Exception:330 raise HTTPException(502, "hub KA injoignable")331332333# ------------------------------------------------------------------ routeur334335class _EventsIn(BaseModel):336 events: list[dict]337338339class _HideIn(BaseModel):340 item_id: str341 on: bool = True342 features: dict | None = None343344345class _SearchIn(BaseModel):346 action: str = "add" # add | remove | alert | touch347 id: int | None = None348 label: str | None = None349 query: str | None = None350 filters: dict | None = None351 location: str | None = None352 url: str | None = None353 alert: bool = False354 frequency: str | None = None355356357def build_router(get_user) -> APIRouter:358 """Routes /api/kaid/* de l'app. `get_user(request)` = current_user de359 l'app (dict avec ka_id, ou None)."""360 router = APIRouter(prefix="/api/kaid")361362 def _require_ka(request: Request) -> tuple[dict, str]:363 user = get_user(request)364 ka_id = _ka_id_of(user)365 if not ka_id:366 raise HTTPException(401, "connexion KA ID requise")367 return user, ka_id368369 @router.get("/status")370 def status(request: Request):371 user = get_user(request)372 ka_id = _ka_id_of(user)373 if not ka_id:374 return {"connected": False}375 prefs = fetch_prefs(ka_id)376 return {377 "connected": True,378 "personalization": bool(prefs and prefs.get("personalization")),379 "monka_url": f"{KA_HUB_URL}/mon-ka",380 }381382 @router.post("/events")383 def client_events(request: Request, body: _EventsIn):384 user = get_user(request)385 ka_id = _ka_id_of(user)386 if not ka_id:387 return {"ok": True, "stored": 0}388 events = []389 for e in body.events[:20]:390 if e.get("type") in CLIENT_EVENT_TYPES:391 events.append({k: e[k] for k in392 ("type", "entity_type", "entity_id", "query",393 "filters", "position", "features", "dwell_ms",394 "session_id") if k in e})395 if events:396 threading.Thread(target=_post_events, args=(ka_id, events),397 daemon=True).start()398 return {"ok": True, "stored": len(events)}399400 @router.post("/hide")401 def hide(request: Request, body: _HideIn):402 _, ka_id = _require_ka(request)403 out = _hub_post(ka_id, "/api/sso/hide", {404 "item_id": body.item_id, "on": body.on,405 "features": body.features,406 })407 invalidate_prefs(ka_id)408 return out409410 @router.get("/saved-searches")411 def saved_list(request: Request):412 _, ka_id = _require_ka(request)413 return _hub_get(ka_id, "/api/sso/saved-searches")414415 @router.post("/saved-searches")416 def saved_post(request: Request, body: _SearchIn):417 _, ka_id = _require_ka(request)418 search: dict = {k: v for k, v in {419 "id": body.id, "label": body.label, "query": body.query,420 "filters": body.filters, "location": body.location,421 "url": body.url, "alert": body.alert,422 "frequency": body.frequency,423 }.items() if v is not None}424 out = _hub_post(ka_id, "/api/sso/saved-searches",425 {"action": body.action, "search": search})426 invalidate_prefs(ka_id)427 return out428429 return router430