# Tests bout-en-bout KA ID v2 — exécuté sur M3U96b (accès hub local + secrets). # Crée 2 membres de test, déroule les scénarios du cahier (§29), nettoie tout. import hashlib import hmac import json import os import sqlite3 import sys import time import urllib.request HUB = "http://localhost:8110" DB = os.path.expanduser("~/apps/groupe-ka/data/ka-id.db") # secrets clients depuis les .env des apps du nœud def env_secret(path): for line in open(os.path.expanduser(path)): line = line.strip() if line.startswith("KA_SSO_SECRET="): return line.split("=", 1)[1].strip().strip('"').strip("'") raise SystemExit(f"KA_SSO_SECRET introuvable dans {path}") SECRETS = { "lou-ka": env_secret("~/apps/lou-ka/.env"), "crea-ka": env_secret("~/apps/crea-ka/.env"), } con = sqlite3.connect(DB) con.row_factory = sqlite3.Row # --- membres de test --------------------------------------------------------- TA, TB = "ka-9900000001", "ka-9900000002" for ka, email in [(TA, "test-kaid-a@test.local"), (TB, "test-kaid-b@test.local")]: con.execute("DELETE FROM users WHERE email=?", (email,)) con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)", (email, "Membre Test", ka)) con.commit() uid_a = con.execute("SELECT id FROM users WHERE ka_id=?", (TA,)).fetchone()["id"] uid_b = con.execute("SELECT id FROM users WHERE ka_id=?", (TB,)).fetchone()["id"] def call(client, method, path, body=None, extra_qs=None, ka=TA): ts = str(int(time.time())) sig = hmac.new(SECRETS[client].encode(), f"{client}.{ka}.{ts}".encode(), hashlib.sha256).hexdigest() qs = f"client_id={client}&ka_id={ka}&ts={ts}&sig={sig}" if extra_qs: qs += "&" + extra_qs url = f"{HUB}{path}?{qs}" data = None if body is not None: body.update({"client_id": client, "ka_id": ka, "ts": ts, "sig": sig}) data = json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=10) as r: return r.status, json.loads(r.read()) except urllib.error.HTTPError as e: return e.code, e.read().decode()[:200] results = [] def check(name, ok, detail=""): results.append((name, ok, detail)) print(("PASS " if ok else "FAIL ") + name + (f" — {detail}" if detail and not ok else "")) # 1. favori « auto-ka » (simulé via lou-ka pour le secret dispo sur ce nœud) # → visible dans les favoris cross-univers du hub + événement journalisé st, _ = call("lou-ka", "POST", "/api/sso/favorites", { "action": "add", "item": {"item_id": "e2e:fav1", "title": "Logement test E2E", "meta": {"features": {"city": "Gatineau", "unit_type": "4 1/2", "price": 1500}}}}) favs = con.execute("SELECT * FROM favorites WHERE user_id=?", (uid_a,)).fetchall() ev = con.execute("SELECT * FROM user_events WHERE user_id=? AND event_type='favorite'", (uid_a,)).fetchall() check("favori → magasin central + événement journalisé", st == 200 and len(favs) == 1 and len(ev) == 1, f"st={st} favs={len(favs)} ev={len(ev)}") # 2. interactions → préférence mise à jour (affinité ville) for i in range(3): call("lou-ka", "POST", "/api/sso/events", {"events": [ {"type": "detail_view", "entity_id": f"e2e:{i}", "features": {"city": "Gatineau", "unit_type": "4 1/2", "price": 1450 + i}}]}) st, prefs = call("lou-ka", "GET", "/api/sso/prefs") aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {}) check("interactions → affinité gatineau apprise", st == 200 and aff.get("gatineau", 0) >= 0.9, str(aff)) # 3. signal négatif : hide → item exclu + affinité négative st, _ = call("lou-ka", "POST", "/api/sso/hide", {"item_id": "e2e:bad", "on": True, "features": {"city": "Laval", "unit_type": "1 1/2"}}) st2, prefs = call("lou-ka", "GET", "/api/sso/prefs") hidden = prefs.get("hidden") or [] aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {}) check("hide → exclu des résultats + affinité négative", st == 200 and "e2e:bad" in hidden and aff.get("laval", 0) < 0, f"hidden={hidden} laval={aff.get('laval')}") # 4. séparation stricte des utilisateurs : B ne voit rien de A st, prefs_b = call("lou-ka", "GET", "/api/sso/prefs", ka=TB) check("séparation utilisateurs (B vierge)", st == 200 and prefs_b.get("profile", {}).get("app") is None and not prefs_b.get("hidden"), json.dumps(prefs_b)[:120]) # 5. cold start : profil vide → rerank neutre (module kaid de lou-ka) sys.path.insert(0, os.path.expanduser("~/apps/lou-ka")) os.environ.setdefault("KA_SSO_SECRET", SECRETS["lou-ka"]) from louka import kaid # noqa: E402 kaid.init("lou-ka") items = [{"uid": f"u{i}", "city": "Québec", "price": 1000 + i} for i in range(10)] out, personalized = kaid.rerank( list(items), {"ka_id": TB}, features_of=lambda it: {"city": it["city"], "price": it["price"]}) check("cold start → ordre de base intact, non personnalisé", [o["uid"] for o in out] == [i["uid"] for i in items] and not personalized) # 6. reranking réel : le profil de A fait remonter Gatineau (tri par défaut) kaid.invalidate_prefs(TA) items = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2", "price": 1500} for i in range(8)] + [{"uid": "g1", "city": "Gatineau", "unit_type": "4 1/2", "price": 1480}]) out, personalized = kaid.rerank( list(items), {"ka_id": TA}, features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"], "price": it["price"]}) pos = [o["uid"] for o in out].index("g1") badge = next((o.get("ka_reco") for o in out if o["uid"] == "g1"), None) others_badged = [o["uid"] for o in out if o.get("ka_reco") and o["uid"] != "g1"] check("reranking → l'annonce Gatineau remonte (bornée par blend) + seul badge", personalized and pos < 8 and badge is not None and not others_badged, f"pos={pos} badge={badge} autres={others_badged}") # 7. intention de session : filtre explicite city=Québec → affinité ville ignorée kaid.invalidate_prefs(TA) out2, _ = kaid.rerank( list(items), {"ka_id": TA}, features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"], "price": it["price"]}, active_dims={"city"}) pos2 = [o["uid"] for o in out2].index("g1") check("session intent → filtre explicite prime (Gatineau remonte moins)", pos2 >= pos, f"pos_sans_filtre={pos} pos_avec_filtre_city={pos2}") # 8. personnalisation OFF → aucun reranking (mais masqués toujours exclus) con.execute("UPDATE users SET personalization=0 WHERE id=?", (uid_a,)) con.commit() kaid.invalidate_prefs(TA) st, prefs = call("lou-ka", "GET", "/api/sso/prefs") items_h = list(items) + [{"uid": "e2e:bad", "city": "Laval", "unit_type": "1 1/2", "price": 900}] out3, personalized3 = kaid.rerank( list(items_h), {"ka_id": TA}, features_of=lambda it: {"city": it["city"], "price": it["price"]}) check("personnalisation OFF → pas de rerank, masqués exclus quand même", st == 200 and prefs.get("personalization") is False and not personalized3 and [o["uid"] for o in out3] == [i["uid"] for i in items_h if i["uid"] != "e2e:bad"], f"perso={prefs.get('personalization')} p3={personalized3}") # 9. historique OFF → événements jetés con.execute("UPDATE users SET personalization=1, history_enabled=0 WHERE id=?", (uid_a,)) con.commit() n_before = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?", (uid_a,)).fetchone()["c"] call("lou-ka", "POST", "/api/sso/events", {"events": [ {"type": "detail_view", "entity_id": "e2e:drop", "features": {"city": "Lévis"}}]}) time.sleep(0.5) n_after = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?", (uid_a,)).fetchone()["c"] check("historique OFF → événement jeté par le hub", n_before == n_after, f"{n_before} → {n_after}") # 10. recherches sauvegardées cross-app : posée via crea-ka, listée scope=all st, r = call("crea-ka", "POST", "/api/sso/saved-searches", {"action": "add", "search": {"label": "Créateurs food Québec", "filters": {"niche": "food"}, "alert": True}}) st2, lst = call("lou-ka", "GET", "/api/sso/saved-searches", extra_qs="scope=all") apps_seen = {s["app"] for s in lst.get("searches", [])} check("recherche sauvegardée cross-univers (scope=all)", st == 200 and st2 == 200 and "crea-ka" in apps_seen, str(apps_seen)) # 11. filtrage collaboratif : C partage un favori avec A → son AUTRE favori # apparaît dans similar de A et le rerank le booste (SIMILAR_USERS) con.execute("UPDATE users SET history_enabled=1 WHERE id=?", (uid_a,)) con.execute("DELETE FROM users WHERE email='test-kaid-c@test.local'") con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)", ("test-kaid-c@test.local", "Membre Test C", "ka-9900000003")) con.commit() uid_c = con.execute("SELECT id FROM users WHERE ka_id='ka-9900000003'").fetchone()["id"] con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title) VALUES (?,?,?,?)", (uid_c, "lou-ka", "e2e:fav1", "Logement test E2E")) con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title, url) VALUES (?,?,?,?,?)", (uid_c, "lou-ka", "e2e:cf-gem", "Perle co-favorite", "https://www.lou-ka.com/logement/e2e:cf-gem")) con.execute("DELETE FROM user_prefs WHERE user_id=?", (uid_a,)) con.commit() st, prefs = call("lou-ka", "GET", "/api/sso/prefs") similar = (prefs.get("profile") or {}).get("app", {}).get("similar") or [] kaid.invalidate_prefs(TA) items_cf = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2", "price": 1500} for i in range(6)] + [{"uid": "e2e:cf-gem", "city": "Québec", "unit_type": "5 1/2", "price": 1500}]) out_cf, _ = kaid.rerank( list(items_cf), {"ka_id": TA}, features_of=lambda it: {"city": it["city"], "price": it["price"]}) pos_cf = [o["uid"] for o in out_cf].index("e2e:cf-gem") reco_cf = next((o.get("ka_reco") for o in out_cf if o["uid"] == "e2e:cf-gem"), None) check("filtrage collaboratif → co-favori détecté + boosté (SIMILAR_USERS)", "e2e:cf-gem" in similar and pos_cf < 6 and reco_cf is not None and "SIMILAR_USERS" in (reco_cf or {}).get("reasons", []), f"similar={similar} pos={pos_cf} reco={reco_cf}") # 12. matrix factorization (ALS) : cohorte croisée → le modèle recommande à A # l'annonce aimée par les membres au profil proche, jusqu'au rerank import shutil import subprocess extra_uids = [] for ka, email in [("ka-9900000004", "test-kaid-d@test.local"), ("ka-9900000005", "test-kaid-e@test.local"), ("ka-9900000006", "test-kaid-f@test.local")]: con.execute("DELETE FROM users WHERE email=?", (email,)) con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)", (email, "Membre Test MF", ka)) extra_uids.append(con.execute("SELECT id FROM users WHERE ka_id=?", (ka,)).fetchone()["id"]) uid_d, uid_e, uid_f = extra_uids fav = "INSERT OR IGNORE INTO favorites (user_id, app, item_id, title) VALUES (?,?,?,?)" for uid, items in [(uid_a, ["e2e:x1", "e2e:x2"]), (uid_d, ["e2e:x1", "e2e:x2", "e2e:x3"]), (uid_e, ["e2e:x2", "e2e:x3", "e2e:x4"]), (uid_f, ["e2e:x1", "e2e:x3"])]: for it in items: con.execute(fav, (uid, "lou-ka", it, f"Annonce {it}")) con.commit() node = shutil.which("node") or "/opt/homebrew/bin/node" run = subprocess.run([node, "scripts/kaid-mf.mjs", "--app", "lou-ka"], cwd=os.path.expanduser("~/apps/groupe-ka"), capture_output=True, text=True, timeout=120) mf_rows = [r["item_id"] for r in con.execute( "SELECT item_id FROM mf_recs WHERE user_id=? AND app='lou-ka' ORDER BY rank", (uid_a,)).fetchall()] st, prefs = call("lou-ka", "GET", "/api/sso/prefs") mf_in_prefs = (prefs.get("profile") or {}).get("app", {}).get("mf") or [] kaid.invalidate_prefs(TA) items_mf = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2", "price": 1500} for i in range(6)] + [{"uid": "e2e:x3", "city": "Québec", "unit_type": "5 1/2", "price": 1500}]) out_mf, _ = kaid.rerank( list(items_mf), {"ka_id": TA}, features_of=lambda it: {"city": it["city"], "price": it["price"]}) pos_mf = [o["uid"] for o in out_mf].index("e2e:x3") reco_mf = next((o.get("ka_reco") for o in out_mf if o["uid"] == "e2e:x3"), None) mf_reason_ok = reco_mf is not None and any( r0 in ("COLLABORATIVE_MODEL", "SIMILAR_USERS") for r0 in reco_mf.get("reasons", [])) check("matrix factorization → ALS entraîné, reco x3 propagée + boostée", "e2e:x3" in mf_rows and "e2e:x3" in mf_in_prefs and pos_mf < 6 and mf_reason_ok, f"stdout={run.stdout.strip()[-160:]} mf_rows={mf_rows[:5]} " f"prefs_mf={mf_in_prefs[:5]} pos={pos_mf} reco={reco_mf}") # --- nettoyage --------------------------------------------------------------- for uid in (uid_a, uid_b, uid_c, uid_d, uid_e, uid_f): for table in ("user_events", "favorites", "saved_searches", "hidden_items", "user_prefs", "pref_overrides", "mf_recs"): con.execute(f"DELETE FROM {table} WHERE user_id=?", (uid,)) con.execute("DELETE FROM users WHERE email LIKE 'test-kaid-%@test.local'") con.execute("DELETE FROM favorites WHERE item_id LIKE 'e2e:%'") con.execute("DELETE FROM mf_recs WHERE item_id LIKE 'e2e:%'") con.commit() left = con.execute("SELECT COUNT(*) c FROM users WHERE ka_id IN (?,?)", (TA, TB)).fetchone()["c"] print(f"\nnettoyage : membres de test supprimés (restants={left})") fails = [n for n, ok, _ in results if not ok] print(f"\n== {len(results) - len(fails)}/{len(results)} tests PASS ==") sys.exit(1 if fails else 0)