SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%

feat(ka-id v2): personnalisation Groupe KA — creaka/kaid.py (canonique ka-ui.git/kaid), journal serveur (search/detail_view/favorite), reranking du tri par défaut « reach » (niche/région/langue/plateforme/taille), routeur /api/kaid, SDK ka-id.js (cartes data-href), badge « Recommandé pour vous »

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 28 days ago (Aug 26, 2026) parent b82e0f8

7 changed files +936 −8

added creaka/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 creaka/web.py +47 −6
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware
16 16 from fastapi.responses import FileResponse, RedirectResponse, Response
17 17 from pydantic import BaseModel, Field
18 18
19 from . import auth, db, ethics, hubfav, kapdf, seo
19 +from . import auth, db, ethics, hubfav, kaid, kapdf, seo
20 20 from . import stats as stats_mod
21 21 from .normalize import NICHES, PLATFORMS, REGIONS
22 22
@@ -47,6 +47,22 @@ def _db():
47 47 # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py
48 48 app.include_router(auth.router)
49 49
50 +# personnalisation KA ID v2 (creaka/kaid.py, canonique ka-ui.git/kaid)
51 +kaid.init("crea-ka")
52 +app.include_router(kaid.build_router(auth.current_user))
53 +
54 +
55 +def _kaid_features(d: dict) -> dict:
56 + """Caractéristiques d'un créateur pour le profil de préférences KA ID."""
57 + return {k: v for k, v in {
58 + "niche": d.get("niches") or None,
59 + "region": d.get("region"), "city": d.get("city"),
60 + "langue": d.get("languages") or None,
61 + "tier": d.get("audience_tier"),
62 + "platform": d.get("platforms") or d.get("primary_platform") or None,
63 + "creator_type": d.get("creator_type"),
64 + }.items() if v not in (None, "", [])}
65 +
50 66
51 67 @app.get("/healthz")
52 68 def healthz():
@@ -61,23 +77,43 @@ def health():
61 77
62 78
63 79 @app.get("/api/creators")
64 def list_creators(q: str = "", niche: str = "", region: str = "",
80 +def list_creators(request: Request,
81 + q: str = "", niche: str = "", region: str = "",
65 82 langue: str = "", plateforme: str = "", tier: str = "",
66 83 sort: str = "reach",
67 84 limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):
68 return db.search(_db(), q=q.strip(), niche=niche, region=region,
69 langue=langue, plateforme=plateforme, tier=tier,
70 sort=sort, limit=limit, offset=offset)
85 + out = db.search(_db(), q=q.strip(), niche=niche, region=region,
86 + langue=langue, plateforme=plateforme, tier=tier,
87 + sort=sort, limit=limit, offset=offset)
88 + # personnalisation KA ID : journal + reclassement (tri par défaut « reach »
89 + # seulement ; intention de session > préférences long terme)
90 + out["personalized"] = False
91 + user = auth.current_user(request)
92 + if user:
93 + filters = {k: v for k, v in {
94 + "niche": niche, "region": region, "langue": langue,
95 + "platform": plateforme, "tier": tier, "q": q.strip()}.items() if v}
96 + if filters and offset == 0:
97 + kaid.track(user, "search", query=q.strip() or None, filters=filters)
98 + if sort == "reach":
99 + active = {k for k in ("niche", "region", "langue", "platform",
100 + "tier") if filters.get(k)}
101 + out["items"], out["personalized"] = kaid.rerank(
102 + out["items"], user, features_of=_kaid_features,
103 + uid_of=lambda it: it.get("id"), active_dims=active)
104 + return out
71 105
72 106
73 107 @app.get("/api/creators/{cid}")
74 def get_creator(cid: str):
108 +def get_creator(cid: str, request: Request):
75 109 creator = db.get_creator(_db(), cid)
76 110 if creator is None:
77 111 raise HTTPException(404, "créateur introuvable")
78 112 # insights « légendaires » : croissance (snapshots), engagement pondéré,
79 113 # rythme de publication, Ka Score /100 + historique pour la sparkline
80 114 creator["insights"] = stats_mod.creator_insights(_db(), creator)
115 + kaid.track(auth.current_user(request), "detail_view", entity_type="creator",
116 + entity_id=cid, features=_kaid_features(creator))
81 117 return creator
82 118
83 119
@@ -234,6 +270,11 @@ def toggle_favorite(request: Request, body: dict = Body(...)):
234 270 raise HTTPException(422, "item.item_id requis")
235 271 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
236 272 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
273 + # signal fort du moteur de préférences KA ID
274 + creator = db.get_creator(_db(), item["item_id"])
275 + kaid.track(user, "favorite" if on else "unfavorite", entity_type="creator",
276 + entity_id=item["item_id"],
277 + features=_kaid_features(creator) if creator else None)
237 278 return {"ok": True, "on": on}
238 279
239 280
modified frontend/dist/index.html +9 −1
@@ -843,7 +843,10 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
843 843 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;
844 844 font-size:46px;letter-spacing:.02em}
845 845 .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
846 .card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}
846 +.card-photo .kc-reco{position:absolute;bottom:8px;left:8px;padding:2px 8px 3px;border-radius:5px;
847 + background:var(--ink);color:var(--accent);font-family:var(--font-mono);font-size:9px;
848 + font-weight:700;text-transform:uppercase;letter-spacing:.07em;z-index:2}
849 +.tier{position:absolute;top:10px;left:10px;z-index:2}
847 850 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
848 851 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
849 852 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
@@ -1525,6 +1528,7 @@ function cardHtml(c){
1525 1528 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
1526 1529 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
1527 1530 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
1531 + ${c.ka_reco?`<span class="kc-reco" title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka">Recommandé pour vous</span>`:""}
1528 1532 <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
1529 1533 <div class="card-body">
1530 1534 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
@@ -2529,5 +2533,9 @@ route();
2529 2533 </script>
2530 2534 <script>window.KA_AGENT={site:"crea-ka"}</script>
2531 2535 <script src="/ka-agent.js" defer></script>
2536 +<!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) -->
2537 +<script>window.KAID_CONF={card:".card[data-href^='/createur/']",detail:"^/createur/(.+)$",entity:"creator",
2538 + uid:function(el){var h=el.getAttribute("data-href")||"";var m=h.match(/^\/createur\/(.+)$/);return m?decodeURIComponent(m[1]):null;}}</script>
2539 +<script src="/ka-id.js" defer></script>
2532 2540 </body>
2533 2541 </html>
added frontend/dist/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/index.template.html +9 −1
@@ -205,7 +205,10 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
205 205 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;
206 206 font-size:46px;letter-spacing:.02em}
207 207 .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
208 .card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}
208 +.card-photo .kc-reco{position:absolute;bottom:8px;left:8px;padding:2px 8px 3px;border-radius:5px;
209 + background:var(--ink);color:var(--accent);font-family:var(--font-mono);font-size:9px;
210 + font-weight:700;text-transform:uppercase;letter-spacing:.07em;z-index:2}
211 +.tier{position:absolute;top:10px;left:10px;z-index:2}
209 212 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
210 213 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
211 214 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
@@ -887,6 +890,7 @@ function cardHtml(c){
887 890 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
888 891 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
889 892 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>
893 + ${c.ka_reco?`<span class="kc-reco" title="Correspond à vos préférences KA ID — gérez-les sur groupe-ka.com/mon-ka">Recommandé pour vous</span>`:""}
890 894 <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
891 895 <div class="card-body">
892 896 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
@@ -1891,5 +1895,9 @@ route();
1891 1895 </script>
1892 1896 <script>window.KA_AGENT={site:"crea-ka"}</script>
1893 1897 <script src="/ka-agent.js" defer></script>
1898 +<!-- KA ID v2 : signaux navigateur de la personnalisation (clics, dwell) -->
1899 +<script>window.KAID_CONF={card:".card[data-href^='/createur/']",detail:"^/createur/(.+)$",entity:"creator",
1900 + uid:function(el){var h=el.getAttribute("data-href")||"";var m=h.match(/^\/createur\/(.+)$/);return m?decodeURIComponent(m[1]):null;}}</script>
1901 +<script src="/ka-id.js" defer></script>
1894 1902 </body>
1895 1903 </html>
added frontend/src/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 scripts/build_frontend.py +1 −0
@@ -40,6 +40,7 @@ def main() -> None:
40 40 # Widget KA Agent (bulle de chat IA Groupe KA) — source perenne dans
41 41 # frontend/src/, copie vers dist/ a chaque build (dist est regenere).
42 42 (DIST / "ka-agent.js").write_bytes((SRC / "ka-agent.js").read_bytes())
43 + (DIST / "ka-id.js").write_bytes((SRC / "ka-id.js").read_bytes())
43 44 print(f"[crea-ka] frontend construit → {DIST/'index.html'} "
44 45 f"({len(html)//1024} Ko, {len(icons)} icônes, tokens ka-ui + écosystème injectés)")
45 46
46 47