SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
17.1 KB · 450 lines python
Raw Blame History
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"}44LANGUAGE_DIMS = {"language", "langue"}45PRICE_DIMS = {"price", "rent", "salary", "salary_year", "price_min"}4647# Événements acceptés depuis le navigateur (le reste vient du serveur).48CLIENT_EVENT_TYPES = {49    "click", "impression", "detail_dwell", "scroll_depth", "return_visit",50    "share", "compare", "external_click", "map_open", "map_marker_click",51    "alert_open",52}5354_prefs_cache: dict[str, tuple[float, dict | None]] = {}55_seen_searches: dict[str, float] = {}     # anti-doublon des recherches (120 s)56_lock = threading.Lock()575859def init(client_id: str) -> None:60    """À appeler une fois au démarrage de l'app (web.py)."""61    global CLIENT_ID62    CLIENT_ID = client_id636465def _sig(ka_id: str, ts: int) -> str | None:66    secret = os.environ.get("KA_SSO_SECRET")67    if not secret or not CLIENT_ID:68        return None69    return hmac.new(secret.encode(),70                    f"{CLIENT_ID}.{ka_id}.{ts}".encode(),71                    hashlib.sha256).hexdigest()727374def _signed_params(ka_id: str) -> dict | None:75    ts = int(time.time())76    sig = _sig(ka_id, ts)77    if not sig:78        return None79    return {"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig}808182def _ka_id_of(user) -> str | None:83    """Extrait un ka_id exploitable d'un dict utilisateur (ou None)."""84    if not user:85        return None86    ka = (user.get("ka_id") or "").strip() if isinstance(user, dict) else ""87    return ka if ka.startswith("ka-") else None888990# ---------------------------------------------------------------- événements9192def _post_events(ka_id: str, events: list[dict]) -> None:93    p = _signed_params(ka_id)94    if not p:95        return96    try:97        requests.post(f"{KA_HUB_URL}/api/sso/events", timeout=TIMEOUT,98                      json={**p, "events": events})99    except Exception:100        pass    # best-effort : jamais bloquant, jamais fatal101102103def track(user, etype: str, *, entity_type: str | None = None,104          entity_id: str | None = None, query: str | None = None,105          filters: dict | None = None, position: int | None = None,106          features: dict | None = None, dwell_ms: int | None = None,107          session_id: str | None = None) -> None:108    """Journalise un événement au hub (fil dédié, zéro latence ajoutée).109    No-op si l'utilisateur n'est pas connecté via KA ID."""110    ka_id = _ka_id_of(user)111    if not ka_id:112        return113    if etype == "search":114        # anti-rafale : la même recherche (mêmes filtres) < 120 s n'est115        # journalisée qu'une fois — une SPA relance l'API à chaque frappe.116        key = ka_id + "|" + hashlib.sha1(117            json.dumps([query, filters], sort_keys=True, default=str).encode()118        ).hexdigest()119        now = time.time()120        with _lock:121            if now - _seen_searches.get(key, 0) < 120:122                return123            _seen_searches[key] = now124            if len(_seen_searches) > 2000:125                cutoff = now - 300126                for k in [k for k, t in _seen_searches.items() if t < cutoff]:127                    del _seen_searches[k]128    ev: dict = {"type": etype}129    if entity_type: ev["entity_type"] = entity_type130    if entity_id: ev["entity_id"] = str(entity_id)131    if query: ev["query"] = str(query)[:200]132    if filters: ev["filters"] = filters133    if position is not None: ev["position"] = int(position)134    if features: ev["features"] = features135    if dwell_ms is not None: ev["dwell_ms"] = int(dwell_ms)136    if session_id: ev["session_id"] = str(session_id)[:60]137    threading.Thread(target=_post_events, args=(ka_id, [ev]), daemon=True).start()138139140# ------------------------------------------------------------------ profil141142def fetch_prefs(ka_id: str | None) -> dict | None:143    """Profil de personnalisation du membre (cache 90 s). None si non144    connecté, non configuré ou hub injoignable — l'appelant retombe alors145    sur le classement de base."""146    if not ka_id or not str(ka_id).startswith("ka-"):147        return None148    now = time.time()149    with _lock:150        hit = _prefs_cache.get(ka_id)151        if hit and now - hit[0] < PREFS_TTL:152            return hit[1]153    data: dict | None = None154    p = _signed_params(ka_id)155    if p:156        try:157            r = requests.get(f"{KA_HUB_URL}/api/sso/prefs", params=p,158                             timeout=TIMEOUT)159            if r.status_code == 200:160                data = r.json()161        except Exception:162            data = None163    with _lock:164        _prefs_cache[ka_id] = (now, data)165        if len(_prefs_cache) > 500:166            for k in list(_prefs_cache)[:100]:167                del _prefs_cache[k]168    return data169170171def invalidate_prefs(ka_id: str | None) -> None:172    if not ka_id:173        return174    with _lock:175        _prefs_cache.pop(ka_id, None)176177178# ---------------------------------------------------------------- reranking179180def _norm(v) -> str:181    return str(v).strip().lower()182183184def personal_score(feats: dict, app_profile: dict, global_profile: dict,185                   active_dims: set[str]) -> tuple[float | None, list[str]]:186    """Score personnel [0,1] d'une annonce, ou None si le profil ne couvre187    aucune de ses caractéristiques. `active_dims` = dimensions explicitement188    filtrées par la requête courante (intention de session > long terme)."""189    dims = app_profile.get("dims") or {}190    ranges = app_profile.get("ranges") or {}191    gl = (global_profile or {}).get("location") or {}192    num = 0.0193    den = 0.0194    reasons: list[str] = []195    for dim, val in (feats or {}).items():196        if val is None or dim in active_dims:197            continue198        if isinstance(val, bool):199            val = str(val)200        if isinstance(val, (int, float)):201            r = ranges.get(dim)202            if r and r.get("n", 0) >= 5:203                p25, p75 = r["p25"], r["p75"]204                iqr = max(p75 - p25, abs(r.get("p50", 0)) * 0.1, 1.0)205                if p25 <= val <= p75:206                    aff = 1.0207                elif p25 - 1.5 * iqr <= val <= p75 + 1.5 * iqr:208                    aff = 0.3209                else:210                    aff = -0.4211                # poids réduit : une plage numérique seule (prix…) ne doit212                # jamais suffire à personnaliser (0.6 < seuil den 0.8) —213                # sinon tout item au « bon prix » score 1.0 et noie les214                # correspondances réelles (ville, marque, type).215                w = 0.6216                num += w * aff217                den += w218                if aff == 1.0:219                    reasons.append("MATCH_PRICE_RANGE" if dim in PRICE_DIMS220                                   else f"MATCH_{dim.upper()}_RANGE")221            continue222        vals = val if isinstance(val, (list, tuple)) else [val]223        vals = [_norm(v) for v in vals if v not in (None, "")]224        if not vals:225            continue226        d = dims.get(dim)227        if d:228            vv = d.get("values") or {}229            affs = [vv[v] for v in vals if v in vv]230            if affs:231                aff = max(affs)232                w = float(d.get("conf") or 0.5)233                num += w * aff234                den += w235                if aff >= 0.6:236                    reasons.append("MATCH_LOCATION" if dim in LOCATION_DIMS237                                   else f"MATCH_{dim.upper()}")238        if dim in LOCATION_DIMS:239            gv = gl.get("values") or {}240            affs = [gv[v] for v in vals if v in gv]241            if affs and max(affs) > 0:242                w = 0.6 * float(gl.get("conf") or 0.3)243                num += w * max(affs)244                den += w245                if max(affs) >= 0.6 and "MATCH_LOCATION" not in reasons:246                    reasons.append("MATCH_LOCATION")247        if dim in LANGUAGE_DIMS:248            glang = (global_profile or {}).get("language") or {}249            gv = glang.get("values") or {}250            affs = [gv[v] for v in vals if v in gv]251            if affs and max(affs) > 0:252                w = 0.4 * float(glang.get("conf") or 0.3)253                num += w * max(affs)254                den += w255    if den < 0.8:256        return None, []257    score = (num / den + 1.0) / 2.0258    return max(0.0, min(1.0, score)), reasons[:4]259260261def rerank(items: list, user, *, features_of, uid_of=None,262           active_dims: set[str] | None = None, blend: float = 0.35,263           badge: float = 0.62, max_considered: int = 300,264           reco_key: str = "ka_reco") -> tuple[list, bool]:265    """Reclassement personnalisé APRÈS la pertinence de base.266    · items       : liste (dicts) déjà triée par la pertinence de base267    · features_of : item -> dict de caractéristiques {dim: valeur}268    · uid_of      : item -> identifiant canonique (défaut : item["uid"])269    · active_dims : dimensions filtrées par la requête (ignorées du score)270    Retourne (items, personnalisé?). Les annonces masquées (« Pas pour moi »)271    sont retirées. Annote item[reco_key] = {score, reasons} quand le score272    personnel est net (badge « Recommandé pour vous » — parcimonieux)."""273    if uid_of is None:274        uid_of = lambda it: (it.get("uid") if isinstance(it, dict) else None)275    ka_id = _ka_id_of(user)276    if not ka_id or not items:277        return items, False278    prefs = fetch_prefs(ka_id)279    if not prefs:280        return items, False281    hidden = set(prefs.get("hidden") or [])282    if hidden:283        items = [it for it in items if str(uid_of(it)) not in hidden]284    if not prefs.get("personalization"):285        return items, False286    profile = prefs.get("profile") or {}287    app_p = profile.get("app")288    if not app_p or not items:289        return items, False290291    head = items[:max_considered]292    tail = items[max_considered:]293    n = len(head)294    active = active_dims or set()295    # signaux collaboratifs du hub : co-favoris (item-item) et296    # recommandations du modèle de matrix factorization (ALS, batch quotidien)297    similar = {str(s) for s in (app_p.get("similar") or [])}298    mf = {str(s) for s in (app_p.get("mf") or [])}299    scored = []300    badged = 0301    for i, it in enumerate(head):302        base = 1.0 - i / max(n, 1)303        try:304            p, reasons = personal_score(features_of(it) or {}, app_p,305                                        profile.get("global") or {}, active)306        except Exception:307            p, reasons = None, []308        uid = str(uid_of(it))309        if similar and uid in similar:310            p = min(1.0, (p if p is not None else 0.55) + 0.25)311            reasons = (["SIMILAR_USERS"] + reasons)[:4]312        elif mf and uid in mf:313            p = min(1.0, (p if p is not None else 0.55) + 0.25)314            reasons = (["COLLABORATIVE_MODEL"] + reasons)[:4]315        if p is None:316            final = (1.0 - blend) * base + blend * 0.5317        else:318            final = (1.0 - blend) * base + blend * p319            if p >= badge and reasons and badged < max(2, n // 8) \320               and isinstance(it, dict):321                it[reco_key] = {"score": round(p, 2), "reasons": reasons}322                badged += 1323        scored.append((final, i, it))324    scored.sort(key=lambda t: (-t[0], t[1]))    # stable : départage par rang325    return [it for _, _, it in scored] + tail, True326327328# ------------------------------------------------------- proxys hub (s2s)329330def _hub_post(ka_id: str, path: str, payload: dict) -> dict:331    p = _signed_params(ka_id)332    if not p:333        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")334    try:335        r = requests.post(f"{KA_HUB_URL}{path}", timeout=TIMEOUT,336                          json={**p, **payload})337        return r.json() if r.status_code == 200 else {"error": r.status_code}338    except Exception:339        raise HTTPException(502, "hub KA injoignable")340341342def _hub_get(ka_id: str, path: str) -> dict:343    p = _signed_params(ka_id)344    if not p:345        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")346    try:347        r = requests.get(f"{KA_HUB_URL}{path}", params=p, timeout=TIMEOUT)348        return r.json() if r.status_code == 200 else {"error": r.status_code}349    except Exception:350        raise HTTPException(502, "hub KA injoignable")351352353# ------------------------------------------------------------------ routeur354355class _EventsIn(BaseModel):356    events: list[dict]357358359class _HideIn(BaseModel):360    item_id: str361    on: bool = True362    features: dict | None = None363364365class _SearchIn(BaseModel):366    action: str = "add"                    # add | remove | alert | touch367    id: int | None = None368    label: str | None = None369    query: str | None = None370    filters: dict | None = None371    location: str | None = None372    url: str | None = None373    alert: bool = False374    frequency: str | None = None375376377def build_router(get_user) -> APIRouter:378    """Routes /api/kaid/* de l'app. `get_user(request)` = current_user de379    l'app (dict avec ka_id, ou None)."""380    router = APIRouter(prefix="/api/kaid")381382    def _require_ka(request: Request) -> tuple[dict, str]:383        user = get_user(request)384        ka_id = _ka_id_of(user)385        if not ka_id:386            raise HTTPException(401, "connexion KA ID requise")387        return user, ka_id388389    @router.get("/status")390    def status(request: Request):391        user = get_user(request)392        ka_id = _ka_id_of(user)393        if not ka_id:394            return {"connected": False}395        prefs = fetch_prefs(ka_id)396        return {397            "connected": True,398            "personalization": bool(prefs and prefs.get("personalization")),399            "monka_url": f"{KA_HUB_URL}/mon-ka",400        }401402    @router.post("/events")403    def client_events(request: Request, body: _EventsIn):404        user = get_user(request)405        ka_id = _ka_id_of(user)406        if not ka_id:407            return {"ok": True, "stored": 0}408        events = []409        for e in body.events[:20]:410            if e.get("type") in CLIENT_EVENT_TYPES:411                events.append({k: e[k] for k in412                               ("type", "entity_type", "entity_id", "query",413                                "filters", "position", "features", "dwell_ms",414                                "session_id") if k in e})415        if events:416            threading.Thread(target=_post_events, args=(ka_id, events),417                             daemon=True).start()418        return {"ok": True, "stored": len(events)}419420    @router.post("/hide")421    def hide(request: Request, body: _HideIn):422        _, ka_id = _require_ka(request)423        out = _hub_post(ka_id, "/api/sso/hide", {424            "item_id": body.item_id, "on": body.on,425            "features": body.features,426        })427        invalidate_prefs(ka_id)428        return out429430    @router.get("/saved-searches")431    def saved_list(request: Request):432        _, ka_id = _require_ka(request)433        return _hub_get(ka_id, "/api/sso/saved-searches")434435    @router.post("/saved-searches")436    def saved_post(request: Request, body: _SearchIn):437        _, ka_id = _require_ka(request)438        search: dict = {k: v for k, v in {439            "id": body.id, "label": body.label, "query": body.query,440            "filters": body.filters, "location": body.location,441            "url": body.url, "alert": body.alert,442            "frequency": body.frequency,443        }.items() if v is not None}444        out = _hub_post(ka_id, "/api/sso/saved-searches",445                        {"action": body.action, "search": search})446        invalidate_prefs(ka_id)447        return out448449    return router450