feat(ka-id v2): journal des recherches vers le feature store KA ID (session tk_ka_session vérifiée côté API, événement search signé HMAC poussé au hub, best-effort stdlib) — nourrit les affinités transversales du membre
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +85 −1
modified
apps/api/main.py
+85 −1
@@ -19,7 +19,7 @@ from contextlib import asynccontextmanager | ||
| 19 | 19 | from typing import Annotated |
| 20 | 20 | |
| 21 | 21 | import anyio.to_thread |
| 22 | −from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Response | |
| 22 | +from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request, Response | |
| 23 | 23 | from fastapi.middleware.cors import CORSMiddleware |
| 24 | 24 | from pydantic import BaseModel |
| 25 | 25 | |
@@ -137,8 +137,85 @@ async def api_suggest(q: str = Query(..., min_length=1, max_length=120)): | ||
| 137 | 137 | return {"suggestions": suggestions[:8]} |
| 138 | 138 | |
| 139 | 139 | |
| 140 | +# --------------------------------------------------------------------------- | |
| 141 | +# KA ID v2 — journal des recherches vers le feature store du hub Groupe KA. | |
| 142 | +# La session « tk_ka_session » est signée par le front Next (ka-auth.ts) avec | |
| 143 | +# sha256("trouve-ka-local|" + KA_SSO_SECRET) ; on la vérifie ici (stdlib) pour | |
| 144 | +# extraire le ka_id, puis on pousse l'événement signé HMAC au hub — fil | |
| 145 | +# dédié, best-effort, jamais bloquant. Zéro dépendance nouvelle. | |
| 146 | +import base64 as _b64 | |
| 147 | +import hashlib as _hashlib | |
| 148 | +import hmac as _hmac | |
| 149 | +import json as _json | |
| 150 | +import os as _os | |
| 151 | +import threading as _threading | |
| 152 | +import time as _time | |
| 153 | +import urllib.request as _urlreq | |
| 154 | + | |
| 155 | +_KAID_CLIENT = "trouve-ka" | |
| 156 | +_KA_HUB = (_os.environ.get("KA_HUB_URL") or "https://www.groupe-ka.com").rstrip("/") | |
| 157 | + | |
| 158 | + | |
| 159 | +def _tk_local_secret() -> bytes | None: | |
| 160 | + local = _os.environ.get("KA_AUTH_SECRET") | |
| 161 | + if local: | |
| 162 | + return local.encode() | |
| 163 | + shared = _os.environ.get("KA_SSO_SECRET") | |
| 164 | + if not shared: | |
| 165 | + return None | |
| 166 | + return _hashlib.sha256(f"trouve-ka-local|{shared}".encode()).digest() | |
| 167 | + | |
| 168 | + | |
| 169 | +def _tk_ka_id(request: Request) -> str | None: | |
| 170 | + """ka_id de la session Next (cookie tk_ka_session), ou None.""" | |
| 171 | + token = request.cookies.get("tk_ka_session") or "" | |
| 172 | + secret = _tk_local_secret() | |
| 173 | + if not token or "." not in token or not secret: | |
| 174 | + return None | |
| 175 | + try: | |
| 176 | + raw, sig = token.split(".", 1) | |
| 177 | + expected = _hmac.new(secret, raw.encode(), _hashlib.sha256).hexdigest() | |
| 178 | + if not _hmac.compare_digest(sig, expected): | |
| 179 | + return None | |
| 180 | + pad = raw + "=" * (-len(raw) % 4) | |
| 181 | + payload = _json.loads(_b64.urlsafe_b64decode(pad)) | |
| 182 | + if payload.get("exp", 0) < _time.time(): | |
| 183 | + return None | |
| 184 | + ka = str(payload.get("ka_id") or "") | |
| 185 | + return ka if ka.startswith("ka-") else None | |
| 186 | + except Exception: | |
| 187 | + return None | |
| 188 | + | |
| 189 | + | |
| 190 | +def _kaid_push(ka_id: str, event: dict) -> None: | |
| 191 | + secret = _os.environ.get("KA_SSO_SECRET") | |
| 192 | + if not secret: | |
| 193 | + return | |
| 194 | + ts = str(int(_time.time())) | |
| 195 | + sig = _hmac.new(secret.encode(), f"{_KAID_CLIENT}.{ka_id}.{ts}".encode(), | |
| 196 | + _hashlib.sha256).hexdigest() | |
| 197 | + body = _json.dumps({"client_id": _KAID_CLIENT, "ka_id": ka_id, "ts": ts, | |
| 198 | + "sig": sig, "events": [event]}).encode() | |
| 199 | + try: | |
| 200 | + req = _urlreq.Request(f"{_KA_HUB}/api/sso/events", data=body, | |
| 201 | + headers={"Content-Type": "application/json"}) | |
| 202 | + _urlreq.urlopen(req, timeout=5) | |
| 203 | + except Exception: | |
| 204 | + pass # best-effort | |
| 205 | + | |
| 206 | + | |
| 207 | +def _kaid_track_search(request: Request, q: str, filters: dict) -> None: | |
| 208 | + ka_id = _tk_ka_id(request) | |
| 209 | + if not ka_id: | |
| 210 | + return | |
| 211 | + event = {"type": "search", "query": q[:200], | |
| 212 | + "filters": {k: v for k, v in filters.items() if v}} | |
| 213 | + _threading.Thread(target=_kaid_push, args=(ka_id, event), daemon=True).start() | |
| 214 | + | |
| 215 | + | |
| 140 | 216 | @app.get("/api/search") |
| 141 | 217 | async def api_search( |
| 218 | + request: Request, | |
| 142 | 219 | q: str = Query(..., min_length=1, max_length=200), |
| 143 | 220 | page: int = Query(1, ge=1, le=100), |
| 144 | 221 | limit: int = Query(10, ge=1, le=50), |
@@ -253,6 +330,13 @@ async def api_search( | ||
| 253 | 330 | await db.record_search_query(q, analysis["language"], total, took_ms) |
| 254 | 331 | except Exception: |
| 255 | 332 | log.exception("analytics de recherche sautées") |
| 333 | + # KA ID v2 : journal de la recherche du membre connecté (page 1 seulement) | |
| 334 | + if page == 1: | |
| 335 | + _kaid_track_search(request, q, { | |
| 336 | + "language": language, "location": location, | |
| 337 | + "category": category, "site": site, | |
| 338 | + "quebec_only": quebec_only or None, | |
| 339 | + }) | |
| 256 | 340 | |
| 257 | 341 | # Recherches liées (page 1 seulement) — best-effort, jamais bloquant |
| 258 | 342 | related: list[str] = [] |
| 259 | 343 | |