SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
39 min agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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 29 days ago (Aug 26, 2026) parent fa7b478

7 changed files +721 −3

modified frontend/index.html +3 −0
@@ -36,5 +36,8 @@ Créé : 2026-08-17 Modifié : 2026-08-17
36 36 <!-- KA Agent : assistant IA commun de l'écosystème Groupe KA -->
37 37 <script>window.KA_AGENT={site:"job-ka"}</script>
38 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 42 </body>
40 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 54 salary_context?: SalaryContext | null;
55 55 employer_jobs?: number; // offres actives du même employeur
56 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 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 43 <div className="jr-main">
44 44 <h3 className="jr-title">
45 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 54 {displayTitle(job)}
47 55 </h3>
48 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 703 .fsheet { transition: none; }
704 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 22 from fastapi.staticfiles import StaticFiles
23 23 from pydantic import BaseModel, Field
24 24
25 −from . import auth, db, hubfav, ingest
25 +from . import auth, db, hubfav, ingest, kaid
26 26 from .dedup import AGGREGATORS
27 27 from .schema import JobPosting
28 28
@@ -40,9 +40,26 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
40 40 # connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py
41 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 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 63 def _row_to_dict(row) -> dict:
47 64 d = dict(row)
48 65 d["benefits"] = json.loads(d.get("benefits") or "[]")
@@ -76,6 +93,7 @@ def health():
76 93
77 94 @app.get("/api/jobs")
78 95 def list_jobs(
96 + request: Request,
79 97 city: str | None = None,
80 98 region: str | None = None,
81 99 category: str | None = None,
@@ -145,7 +163,27 @@ def list_jobs(
145 163 d.pop("description", None) # liste allégée : la fiche a le texte complet
146 164 rows.append(d)
147 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 189 @app.get("/api/jobs.geojson")
@@ -270,7 +308,7 @@ def _salary_context(con, category: str) -> dict | None:
270 308
271 309
272 310 @app.get("/api/jobs/{uid}")
273 −def get_job(uid: str):
311 +def get_job(uid: str, request: Request):
274 312 con = db.connect()
275 313 row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone()
276 314 d = None
@@ -296,6 +334,8 @@ def get_job(uid: str):
296 334 con.close()
297 335 if d is None:
298 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 339 return d
300 340
301 341
@@ -618,6 +658,14 @@ def toggle_favorite(request: Request, body: dict = Body(...)):
618 658 raise HTTPException(422, "item.item_id requis")
619 659 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
620 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 669 return {"ok": True, "on": on}
622 670
623 671
624 672