SPB Git

spb/job-ka

Public

feat(ka-id v2): personnalisation Groupe KA — jobka/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), routeur /api/kaid, SDK ka-id.js (clics, dwell), badge « Recommandé pour vous » sur JobCard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 26, 2026) parent fa7b478 Browse files

7 changed files +721 −3

modified frontend/index.html +3 −0
@@ -36,5 +36,8 @@ Créé : 2026-08-17 Modifié : 2026-08-17
36 <!-- KA Agent : assistant IA commun de l'écosystème Groupe KA --> 36 <!-- KA Agent : assistant IA commun de l'écosystème Groupe KA -->
37 <script>window.KA_AGENT={site:"job-ka"}</script> 37 <script>window.KA_AGENT={site:"job-ka"}</script>
38 <script src="/ka-agent.js" defer></script> 38 <script src="/ka-agent.js" defer></script>
39+ <!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) -->
40+ <script>window.KAID_CONF={card:"a[href^='/emploi/']",detail:"^/emploi/(.+)$",entity:"job"}</script>
41+ <script src="/ka-id.js" defer></script>
39 </body> 42 </body>
40 </html> 43 </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
@@ -54,6 +54,9 @@ export interface Job {
54 salary_context?: SalaryContext | null; 54 salary_context?: SalaryContext | null;
55 employer_jobs?: number; // offres actives du même employeur 55 employer_jobs?: number; // offres actives du même employeur
56 salary_history?: SalaryPoint[]; 56 salary_history?: SalaryPoint[];
57+ // personnalisation KA ID : posé par le serveur quand le score personnel
58+ // est net (voir jobka/kaid.py) — affiche « Recommandé pour vous »
59+ ka_reco?: { score: number; reasons: string[] } | null;
57 } 60 }
58 61
59 /** Exigences structurées extraites de l'offre (clés présentes si explicites). */ 62 /** Exigences structurées extraites de l'offre (clés présentes si explicites). */
modified frontend/src/components/JobCard.tsx +8 −0
@@ -43,6 +43,14 @@ export default function JobCard({ job }: { job: Job }) {
43 <div className="jr-main"> 43 <div className="jr-main">
44 <h3 className="jr-title"> 44 <h3 className="jr-title">
45 {isNew && <span className="jr-new">Nouveau</span>} 45 {isNew && <span className="jr-new">Nouveau</span>}
46+ {job.ka_reco && (
47+ <span
48+ className="jr-reco"
49+ title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka"
50+ >
51+ Recommandé pour vous
52+ </span>
53+ )}
46 {displayTitle(job)} 54 {displayTitle(job)}
47 </h3> 55 </h3>
48 <p className="jr-org"> 56 <p className="jr-org">
modified frontend/src/styles.css +9 −0
@@ -703,3 +703,12 @@ body.ka-hide-agent .kaa-btn { opacity: 0 !important; pointer-events: none !impor
703 .fsheet { transition: none; } 703 .fsheet { transition: none; }
704 .job-row, .job-row::before, .searchbar { transition: none; } 704 .job-row, .job-row::before, .searchbar { transition: none; }
705 } 705 }
706+
707+/* --- KA ID v2 : badge « Recommandé pour vous » (parcimonieux, serveur) --- */
708+.jr-reco {
709+ display: inline-block; vertical-align: 2px; margin-right: 9px;
710+ padding: 2px 8px 3px; border-radius: 5px;
711+ background: var(--ink); color: var(--accent);
712+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
713+ text-transform: uppercase; letter-spacing: 0.07em;
714+}
added jobka/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 jobka/web.py +51 −3
@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, Response
22 from fastapi.staticfiles import StaticFiles 22 from fastapi.staticfiles import StaticFiles
23 from pydantic import BaseModel, Field 23 from pydantic import BaseModel, Field
24 24
25−from . import auth, db, hubfav, ingest 25+from . import auth, db, hubfav, ingest, kaid
26 from .dedup import AGGREGATORS 26 from .dedup import AGGREGATORS
27 from .schema import JobPosting 27 from .schema import JobPosting
28 28
@@ -40,9 +40,26 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
40 # connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py 40 # connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py
41 app.include_router(auth.router) 41 app.include_router(auth.router)
42 42
43+# personnalisation KA ID v2 (jobka/kaid.py, canonique ka-ui.git/kaid)
44+kaid.init("job-ka")
45+app.include_router(kaid.build_router(auth.current_user))
46+
43 _sync_lock = threading.Lock() 47 _sync_lock = threading.Lock()
44 48
45 49
50+def _kaid_features(d: dict) -> dict:
51+ """Caractéristiques d'une offre pour le profil de préférences KA ID."""
52+ salary = d.get("salary_year_max") or d.get("salary_year_min")
53+ return {k: v for k, v in {
54+ "city": d.get("city"), "region": d.get("region"),
55+ "category": d.get("category"), "employer": d.get("employer"),
56+ "work_mode": d.get("work_mode"),
57+ "employment_type": d.get("employment_type"),
58+ "seniority": d.get("seniority"), "language": d.get("language"),
59+ "salary": salary,
60+ }.items() if v not in (None, "")}
61+
62+
46 def _row_to_dict(row) -> dict: 63 def _row_to_dict(row) -> dict:
47 d = dict(row) 64 d = dict(row)
48 d["benefits"] = json.loads(d.get("benefits") or "[]") 65 d["benefits"] = json.loads(d.get("benefits") or "[]")
@@ -76,6 +93,7 @@ def health():
76 93
77 @app.get("/api/jobs") 94 @app.get("/api/jobs")
78 def list_jobs( 95 def list_jobs(
96+ request: Request,
79 city: str | None = None, 97 city: str | None = None,
80 region: str | None = None, 98 region: str | None = None,
81 category: str | None = None, 99 category: str | None = None,
@@ -145,7 +163,27 @@ def list_jobs(
145 d.pop("description", None) # liste allégée : la fiche a le texte complet 163 d.pop("description", None) # liste allégée : la fiche a le texte complet
146 rows.append(d) 164 rows.append(d)
147 con.close() 165 con.close()
148− return {"total": total, "count": len(rows), "jobs": rows} 166+ # personnalisation KA ID : journal + reclassement (tri par défaut seulement,
167+ # jamais un tri explicite ; intention de session > préférences long terme)
168+ personalized = False
169+ user = auth.current_user(request)
170+ if user:
171+ active_filters = {"city": city, "region": region, "category": category,
172+ "employer": employer, "work_mode": work_mode,
173+ "employment_type": employment_type,
174+ "seniority": seniority, "language": language,
175+ "salary_min": salary_min, "q": q}
176+ active_filters = {k: v for k, v in active_filters.items() if v}
177+ if (q or active_filters) and offset == 0:
178+ kaid.track(user, "search", query=q, filters=active_filters)
179+ if sort == "recent":
180+ active = set(active_filters) - {"q", "salary_min"}
181+ if salary_min is not None:
182+ active.add("salary")
183+ rows, personalized = kaid.rerank(
184+ rows, user, features_of=_kaid_features, active_dims=active)
185+ return {"total": total, "count": len(rows), "jobs": rows,
186+ "personalized": personalized}
149 187
150 188
151 @app.get("/api/jobs.geojson") 189 @app.get("/api/jobs.geojson")
@@ -270,7 +308,7 @@ def _salary_context(con, category: str) -> dict | None:
270 308
271 309
272 @app.get("/api/jobs/{uid}") 310 @app.get("/api/jobs/{uid}")
273−def get_job(uid: str): 311+def get_job(uid: str, request: Request):
274 con = db.connect() 312 con = db.connect()
275 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() 313 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()
276 d = None 314 d = None
@@ -296,6 +334,8 @@ def get_job(uid: str):
296 con.close() 334 con.close()
297 if d is None: 335 if d is None:
298 raise HTTPException(404, "Offre introuvable") 336 raise HTTPException(404, "Offre introuvable")
337+ kaid.track(auth.current_user(request), "detail_view", entity_type="job",
338+ entity_id=uid, features=_kaid_features(d))
299 return d 339 return d
300 340
301 341
@@ -618,6 +658,14 @@ def toggle_favorite(request: Request, body: dict = Body(...)):
618 raise HTTPException(422, "item.item_id requis") 658 raise HTTPException(422, "item.item_id requis")
619 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): 659 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
620 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") 660 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
661+ # signal fort du moteur de préférences (features lues de la BD)
662+ uid = item["item_id"]
663+ con = db.connect()
664+ row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()
665+ con.close()
666+ kaid.track(user, "favorite" if on else "unfavorite", entity_type="job",
667+ entity_id=uid,
668+ features=_kaid_features(_row_to_dict(row)) if row else None)
621 return {"ok": True, "on": on} 669 return {"ok": True, "on": on}
622 670
623 671
624 672
Menu

Theme