SPB Git forge

spb/trouve-ka

Public ★ Pinned

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent 76fa1eb

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