feat(ka-id v2): personnalisation Groupe KA — louka/kaid.py (canonique ka-ui.git/kaid), journal serveur (search/detail_view/favorite), reranking personnalisé du tri par défaut (blend 0,35, session > long terme, masqués exclus), enrichisseur ka_id (session legacy sans ka_id), routeur /api/kaid, SDK ka-id.js, badge « Recommandé pour vous » sur ListingCard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +738 −5
modified
frontend/index.html
+3 −0
@@ -31,5 +31,8 @@ | ||
| 31 | 31 | <script type="module" src="/src/main.tsx"></script> |
| 32 | 32 | <script>window.KA_AGENT={site:"lou-ka"}</script> |
| 33 | 33 | <script src="/ka-agent.js" defer></script> |
| 34 | + <!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) --> | |
| 35 | + <script>window.KAID_CONF={card:"a[href^='/logement/']",detail:"^/logement/(.+)$",entity:"listing"}</script> | |
| 36 | + <script src="/ka-id.js" defer></script> | |
| 34 | 37 | </body> |
| 35 | 38 | </html> |
added
frontend/public/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/api.ts
+3 −0
@@ -72,6 +72,9 @@ export interface Quartier { | ||
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | export interface Listing { |
| 75 | + /** personnalisation KA ID : posé par le serveur (louka/kaid.py) quand le | |
| 76 | + score personnel est net — affiche « Recommandé pour vous » */ | |
| 77 | + ka_reco?: { score: number; reasons: string[] } | null; | |
| 75 | 78 | uid: string; |
| 76 | 79 | source: string; |
| 77 | 80 | external_id: string; |
modified
frontend/src/components/ListingCard.tsx
+8 −0
@@ -21,6 +21,14 @@ export default function ListingCard({ l }: { l: Listing }) { | ||
| 21 | 21 | <SmartImg src={img} width_={480} fallbackLabel={l.unit_type || undefined} |
| 22 | 22 | alt={l.title} loading="lazy" decoding="async" /> |
| 23 | 23 | {l.unit_type && <span className="badge type">{l.unit_type}</span>} |
| 24 | + {l.ka_reco && ( | |
| 25 | + <span | |
| 26 | + className="badge ka-reco" | |
| 27 | + title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka" | |
| 28 | + > | |
| 29 | + Recommandé pour vous | |
| 30 | + </span> | |
| 31 | + )} | |
| 24 | 32 | {l.images.length > 1 && ( |
| 25 | 33 | <span className="badge right"><IcoCamera size={12} /> {l.images.length}</span> |
| 26 | 34 | )} |
modified
frontend/src/styles.css
+3 −0
@@ -2863,3 +2863,6 @@ body:has(.cta-sticky) .tabbar { display: none; } | ||
| 2863 | 2863 | body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; } |
| 2864 | 2864 | /* mobile : la puce « sans position » encombre l'en-tête du sheet */ |
| 2865 | 2865 | @media (max-width: 780px) { .ka-shell .ms-nopos { display: none; } } |
| 2866 | + | |
| 2867 | +/* --- KA ID v2 : badge « Recommandé pour vous » (parcimonieux, serveur) --- */ | |
| 2868 | +.badge.ka-reco { top: auto; bottom: 12px; background: var(--ink); color: var(--accent); border-color: var(--ink); } | |
modified
louka/accounts.py
+9 −1
@@ -18,7 +18,7 @@ from pathlib import Path | ||
| 18 | 18 | from fastapi import APIRouter, HTTPException, Request |
| 19 | 19 | from pydantic import BaseModel |
| 20 | 20 | |
| 21 | −from . import db, hubfav | |
| 21 | +from . import db, hubfav, kaid | |
| 22 | 22 | from .auth import current_user |
| 23 | 23 | |
| 24 | 24 | SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json" |
@@ -94,6 +94,12 @@ def add_favorite(request: Request, uid: str): | ||
| 94 | 94 | con.close() |
| 95 | 95 | # miroir « Mon univers Ka » sur le hub (arrière-plan, jamais bloquant) |
| 96 | 96 | hubfav.push_favorite(ka_id, "add", hubfav.listing_item(listing)) |
| 97 | + d = dict(listing) | |
| 98 | + kaid.track({"ka_id": ka_id}, "favorite", entity_type="listing", | |
| 99 | + entity_id=uid, features={k: v for k, v in { | |
| 100 | + "city": d.get("city"), "sector": d.get("sector"), | |
| 101 | + "unit_type": d.get("unit_type"), "price": d.get("price"), | |
| 102 | + "pets": d.get("pets")}.items() if v not in (None, "")}) | |
| 97 | 103 | return {"ok": True} |
| 98 | 104 | |
| 99 | 105 | |
@@ -107,6 +113,8 @@ def remove_favorite(request: Request, uid: str): | ||
| 107 | 113 | ka_id = _ka_id_of(con, user["uid"]) |
| 108 | 114 | con.close() |
| 109 | 115 | hubfav.push_favorite(ka_id, "remove", {"item_id": uid}) |
| 116 | + kaid.track({"ka_id": ka_id}, "unfavorite", entity_type="listing", | |
| 117 | + entity_id=uid) | |
| 110 | 118 | return {"ok": True} |
| 111 | 119 | |
| 112 | 120 | |
added
louka/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
louka/web.py
+65 −4
@@ -9,13 +9,13 @@ import json | ||
| 9 | 9 | import threading |
| 10 | 10 | from pathlib import Path |
| 11 | 11 | |
| 12 | −from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query | |
| 12 | +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request | |
| 13 | 13 | from fastapi.middleware.cors import CORSMiddleware |
| 14 | 14 | from fastapi.middleware.gzip import GZipMiddleware |
| 15 | 15 | from fastapi.responses import FileResponse, RedirectResponse |
| 16 | 16 | from fastapi.staticfiles import StaticFiles |
| 17 | 17 | |
| 18 | −from . import auth, db, ingest | |
| 18 | +from . import auth, db, ingest, kaid | |
| 19 | 19 | |
| 20 | 20 | ROOT = Path(__file__).resolve().parent.parent |
| 21 | 21 | SOURCES_PATH = ROOT / "data" / "sources.json" |
@@ -32,6 +32,42 @@ _sync_lock = threading.Lock() | ||
| 32 | 32 | # comptes utilisateurs (connexion Google) — voir louka/auth.py |
| 33 | 33 | app.include_router(auth.router) |
| 34 | 34 | |
| 35 | +# personnalisation KA ID v2 (louka/kaid.py, canonique ka-ui.git/kaid) | |
| 36 | +kaid.init("lou-ka") | |
| 37 | + | |
| 38 | +_kaid_ka_cache: dict[int, str] = {} | |
| 39 | + | |
| 40 | + | |
| 41 | +def _kaid_user(request) -> dict | None: | |
| 42 | + """Session enrichie du ka_id (absent du témoin legacy) — None sinon.""" | |
| 43 | + user = auth.current_user(request) | |
| 44 | + if not user: | |
| 45 | + return None | |
| 46 | + uid = user.get("uid") | |
| 47 | + ka = _kaid_ka_cache.get(uid) | |
| 48 | + if ka is None: | |
| 49 | + con = db.connect() | |
| 50 | + row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone() | |
| 51 | + con.close() | |
| 52 | + ka = (row["ka_id"] if row else "") or "" | |
| 53 | + _kaid_ka_cache[uid] = ka | |
| 54 | + return {**user, "ka_id": ka} if ka.startswith("ka-") else None | |
| 55 | + | |
| 56 | + | |
| 57 | +def _kaid_features(d: dict) -> dict: | |
| 58 | + """Caractéristiques d un logement pour le profil de préférences KA ID.""" | |
| 59 | + return {k: v for k, v in { | |
| 60 | + "city": d.get("city"), "sector": d.get("sector"), | |
| 61 | + "unit_type": d.get("unit_type"), "pets": d.get("pets"), | |
| 62 | + "furnished": (bool(d.get("furnished")) | |
| 63 | + if d.get("furnished") is not None else None), | |
| 64 | + "price": d.get("price"), "deal": d.get("fv_verdict"), | |
| 65 | + "source": d.get("source"), | |
| 66 | + }.items() if v not in (None, "")} | |
| 67 | + | |
| 68 | + | |
| 69 | +app.include_router(kaid.build_router(_kaid_user)) | |
| 70 | + | |
| 35 | 71 | # rôles, favoris, pages gestionnaires — voir louka/accounts.py |
| 36 | 72 | from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts) |
| 37 | 73 | app.include_router(accounts.router) |
@@ -83,6 +119,7 @@ def _row_to_dict(row) -> dict: | ||
| 83 | 119 | |
| 84 | 120 | @app.get("/api/listings") |
| 85 | 121 | def list_listings( |
| 122 | + request: Request, | |
| 86 | 123 | city: str | None = None, |
| 87 | 124 | sector: str | None = None, |
| 88 | 125 | unit_type: str | None = None, |
@@ -159,7 +196,29 @@ def list_listings( | ||
| 159 | 196 | args += [limit, offset] |
| 160 | 197 | rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] |
| 161 | 198 | con.close() |
| 162 | − return {"total": total, "count": len(rows), "listings": rows} | |
| 199 | + # personnalisation KA ID : journal + reclassement (tri par défaut seulement, | |
| 200 | + # jamais un tri explicite ; intention de session > préférences long terme) | |
| 201 | + personalized = False | |
| 202 | + user = _kaid_user(request) | |
| 203 | + if user: | |
| 204 | + filters = {k: v for k, v in { | |
| 205 | + "city": city, "sector": sector, "unit_type": unit_type, | |
| 206 | + "price_max": price_max, "price_min": price_min, "pets": pets, | |
| 207 | + "furnished": furnished, "deal": deal, "q": q}.items() | |
| 208 | + if v not in (None, "")} | |
| 209 | + if filters and offset == 0: | |
| 210 | + kaid.track(user, "search", query=q, filters=filters) | |
| 211 | + if sort in (None, "", "recent"): | |
| 212 | + active = {k for k in ("city", "sector", "unit_type", "pets", "deal") | |
| 213 | + if filters.get(k)} | |
| 214 | + if price_max is not None or price_min is not None: | |
| 215 | + active.add("price") | |
| 216 | + if furnished in (0, 1): | |
| 217 | + active.add("furnished") | |
| 218 | + rows, personalized = kaid.rerank( | |
| 219 | + rows, user, features_of=_kaid_features, active_dims=active) | |
| 220 | + return {"total": total, "count": len(rows), "listings": rows, | |
| 221 | + "personalized": personalized} | |
| 163 | 222 | |
| 164 | 223 | |
| 165 | 224 | @app.get("/api/listings.geojson") |
@@ -666,7 +725,7 @@ def manager_fiche(source_id: str): | ||
| 666 | 725 | |
| 667 | 726 | |
| 668 | 727 | @app.get("/api/listings/{uid}") |
| 669 | −def get_listing(uid: str): | |
| 728 | +def get_listing(uid: str, request: Request): | |
| 670 | 729 | con = db.connect() |
| 671 | 730 | row = con.execute( |
| 672 | 731 | "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high," |
@@ -721,6 +780,8 @@ def get_listing(uid: str): | ||
| 721 | 780 | con.close() |
| 722 | 781 | if d is None: |
| 723 | 782 | raise HTTPException(404, "Annonce introuvable") |
| 783 | + kaid.track(_kaid_user(request), "detail_view", entity_type="listing", | |
| 784 | + entity_id=uid, features=_kaid_features(d)) | |
| 724 | 785 | return d |
| 725 | 786 | |
| 726 | 787 | |
| 727 | 788 | |