SPB Git

spb/crea-ka

Public

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

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 (Aug 26, 2026) parent b82e0f8 Browse files

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 from fastapi.responses import FileResponse, RedirectResponse, Response 16 from fastapi.responses import FileResponse, RedirectResponse, Response
17 from pydantic import BaseModel, Field 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 from . import stats as stats_mod 20 from . import stats as stats_mod
21 from .normalize import NICHES, PLATFORMS, REGIONS 21 from .normalize import NICHES, PLATFORMS, REGIONS
22 22
@@ -47,6 +47,22 @@ def _db():
47 # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py 47 # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py
48 app.include_router(auth.router) 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 @app.get("/healthz") 67 @app.get("/healthz")
52 def healthz(): 68 def healthz():
@@ -61,23 +77,43 @@ def health():
61 77
62 78
63 @app.get("/api/creators") 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 langue: str = "", plateforme: str = "", tier: str = "", 82 langue: str = "", plateforme: str = "", tier: str = "",
66 sort: str = "reach", 83 sort: str = "reach",
67 limit: int = Query(60, le=200), offset: int = Query(0, ge=0)): 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, 85+ out = db.search(_db(), q=q.strip(), niche=niche, region=region,
69− langue=langue, plateforme=plateforme, tier=tier, 86+ langue=langue, plateforme=plateforme, tier=tier,
70− sort=sort, limit=limit, offset=offset) 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 @app.get("/api/creators/{cid}") 107 @app.get("/api/creators/{cid}")
74−def get_creator(cid: str): 108+def get_creator(cid: str, request: Request):
75 creator = db.get_creator(_db(), cid) 109 creator = db.get_creator(_db(), cid)
76 if creator is None: 110 if creator is None:
77 raise HTTPException(404, "créateur introuvable") 111 raise HTTPException(404, "créateur introuvable")
78 # insights « légendaires » : croissance (snapshots), engagement pondéré, 112 # insights « légendaires » : croissance (snapshots), engagement pondéré,
79 # rythme de publication, Ka Score /100 + historique pour la sparkline 113 # rythme de publication, Ka Score /100 + historique pour la sparkline
80 creator["insights"] = stats_mod.creator_insights(_db(), creator) 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 return creator 117 return creator
82 118
83 119
@@ -234,6 +270,11 @@ def toggle_favorite(request: Request, body: dict = Body(...)):
234 raise HTTPException(422, "item.item_id requis") 270 raise HTTPException(422, "item.item_id requis")
235 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): 271 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
236 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") 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 return {"ok": True, "on": on} 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 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700; 843 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;
844 font-size:46px;letter-spacing:.02em} 844 font-size:46px;letter-spacing:.02em}
845 .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} 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 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink); 850 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
848 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl); 851 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
849 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700; 852 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
@@ -1525,6 +1528,7 @@ function cardHtml(c){
1525 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer"> 1528 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
1526 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)} 1529 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
1527 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span> 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 <span class="nplat">${np} plat.</span>${favBtn(c)}</div> 1532 <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
1529 <div class="card-body"> 1533 <div class="card-body">
1530 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div> 1534 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
@@ -2529,5 +2533,9 @@ route();
2529 </script> 2533 </script>
2530 <script>window.KA_AGENT={site:"crea-ka"}</script> 2534 <script>window.KA_AGENT={site:"crea-ka"}</script>
2531 <script src="/ka-agent.js" defer></script> 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 </body> 2540 </body>
2533 </html> 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 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700; 205 .card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;
206 font-size:46px;letter-spacing:.02em} 206 font-size:46px;letter-spacing:.02em}
207 .card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} 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 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink); 212 .card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);
210 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl); 213 color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);
211 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700; 214 padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;
@@ -887,6 +890,7 @@ function cardHtml(c){
887 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer"> 890 return `<div class="card" data-href="/createur/${esc(c.id)}" role="link" tabindex="0" style="cursor:pointer">
888 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)} 891 <div class="card-photo"><span class="init">${esc(initials(c.display_name))}</span>${photoImg(c)}
889 <span class="tier ${esc(c.audience_tier)}">${TIER_LBL[c.audience_tier]||c.audience_tier}</span> 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 <span class="nplat">${np} plat.</span>${favBtn(c)}</div> 894 <span class="nplat">${np} plat.</span>${favBtn(c)}</div>
891 <div class="card-body"> 895 <div class="card-body">
892 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div> 896 <div class="card-name">${esc(c.display_name)}${verified?checkSvg:""}</div>
@@ -1891,5 +1895,9 @@ route();
1891 </script> 1895 </script>
1892 <script>window.KA_AGENT={site:"crea-ka"}</script> 1896 <script>window.KA_AGENT={site:"crea-ka"}</script>
1893 <script src="/ka-agent.js" defer></script> 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 </body> 1902 </body>
1895 </html> 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 # Widget KA Agent (bulle de chat IA Groupe KA) — source perenne dans 40 # Widget KA Agent (bulle de chat IA Groupe KA) — source perenne dans
41 # frontend/src/, copie vers dist/ a chaque build (dist est regenere). 41 # frontend/src/, copie vers dist/ a chaque build (dist est regenere).
42 (DIST / "ka-agent.js").write_bytes((SRC / "ka-agent.js").read_bytes()) 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 print(f"[crea-ka] frontend construit → {DIST/'index.html'} " 44 print(f"[crea-ka] frontend construit → {DIST/'index.html'} "
44 f"({len(html)//1024} Ko, {len(icons)} icônes, tokens ka-ui + écosystème injectés)") 45 f"({len(html)//1024} Ko, {len(icons)} icônes, tokens ka-ui + écosystème injectés)")
45 46
46 47
Menu

Theme