SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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>
Simon-Pierre Boucher committed 29 days ago (Aug 26, 2026) parent 219ce60

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