SPB Git forge

spb/ka-ui

Public
30commits 1branches 0releases
145.7 MBsize
maindefault branch
27 days agolast push
Python 33.5% JavaScript 30.1% TypeScript 25% CSS 10% Shell 1.4%
13.9 KB · 276 lines python
Raw Blame History
1# Tests bout-en-bout KA ID v2 — exécuté sur M3U96b (accès hub local + secrets).2# Crée 2 membres de test, déroule les scénarios du cahier (§29), nettoie tout.3import hashlib4import hmac5import json6import os7import sqlite38import sys9import time10import urllib.request1112HUB = "http://localhost:8110"13DB = os.path.expanduser("~/apps/groupe-ka/data/ka-id.db")1415# secrets clients depuis les .env des apps du nœud16def env_secret(path):17    for line in open(os.path.expanduser(path)):18        line = line.strip()19        if line.startswith("KA_SSO_SECRET="):20            return line.split("=", 1)[1].strip().strip('"').strip("'")21    raise SystemExit(f"KA_SSO_SECRET introuvable dans {path}")2223SECRETS = {24    "lou-ka": env_secret("~/apps/lou-ka/.env"),25    "crea-ka": env_secret("~/apps/crea-ka/.env"),26}2728con = sqlite3.connect(DB)29con.row_factory = sqlite3.Row3031# --- membres de test ---------------------------------------------------------32TA, TB = "ka-9900000001", "ka-9900000002"33for ka, email in [(TA, "test-kaid-a@test.local"), (TB, "test-kaid-b@test.local")]:34    con.execute("DELETE FROM users WHERE email=?", (email,))35    con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)",36                (email, "Membre Test", ka))37con.commit()38uid_a = con.execute("SELECT id FROM users WHERE ka_id=?", (TA,)).fetchone()["id"]39uid_b = con.execute("SELECT id FROM users WHERE ka_id=?", (TB,)).fetchone()["id"]4041def call(client, method, path, body=None, extra_qs=None, ka=TA):42    ts = str(int(time.time()))43    sig = hmac.new(SECRETS[client].encode(), f"{client}.{ka}.{ts}".encode(),44                   hashlib.sha256).hexdigest()45    qs = f"client_id={client}&ka_id={ka}&ts={ts}&sig={sig}"46    if extra_qs:47        qs += "&" + extra_qs48    url = f"{HUB}{path}?{qs}"49    data = None50    if body is not None:51        body.update({"client_id": client, "ka_id": ka, "ts": ts, "sig": sig})52        data = json.dumps(body).encode()53    req = urllib.request.Request(url, data=data, method=method,54                                 headers={"Content-Type": "application/json"})55    try:56        with urllib.request.urlopen(req, timeout=10) as r:57            return r.status, json.loads(r.read())58    except urllib.error.HTTPError as e:59        return e.code, e.read().decode()[:200]6061results = []62def check(name, ok, detail=""):63    results.append((name, ok, detail))64    print(("PASS " if ok else "FAIL ") + name + (f" — {detail}" if detail and not ok else ""))6566# 1. favori « auto-ka » (simulé via lou-ka pour le secret dispo sur ce nœud)67#    → visible dans les favoris cross-univers du hub + événement journalisé68st, _ = call("lou-ka", "POST", "/api/sso/favorites", {69    "action": "add",70    "item": {"item_id": "e2e:fav1", "title": "Logement test E2E",71             "meta": {"features": {"city": "Gatineau", "unit_type": "4 1/2",72                                   "price": 1500}}}})73favs = con.execute("SELECT * FROM favorites WHERE user_id=?", (uid_a,)).fetchall()74ev = con.execute("SELECT * FROM user_events WHERE user_id=? AND event_type='favorite'",75                 (uid_a,)).fetchall()76check("favori → magasin central + événement journalisé",77      st == 200 and len(favs) == 1 and len(ev) == 1, f"st={st} favs={len(favs)} ev={len(ev)}")7879# 2. interactions → préférence mise à jour (affinité ville)80for i in range(3):81    call("lou-ka", "POST", "/api/sso/events", {"events": [82        {"type": "detail_view", "entity_id": f"e2e:{i}",83         "features": {"city": "Gatineau", "unit_type": "4 1/2", "price": 1450 + i}}]})84st, prefs = call("lou-ka", "GET", "/api/sso/prefs")85aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {})86check("interactions → affinité gatineau apprise",87      st == 200 and aff.get("gatineau", 0) >= 0.9, str(aff))8889# 3. signal négatif : hide → item exclu + affinité négative90st, _ = call("lou-ka", "POST", "/api/sso/hide",91             {"item_id": "e2e:bad", "on": True,92              "features": {"city": "Laval", "unit_type": "1 1/2"}})93st2, prefs = call("lou-ka", "GET", "/api/sso/prefs")94hidden = prefs.get("hidden") or []95aff = (prefs.get("profile") or {}).get("app", {}).get("dims", {}).get("city", {}).get("values", {})96check("hide → exclu des résultats + affinité négative",97      st == 200 and "e2e:bad" in hidden and aff.get("laval", 0) < 0,98      f"hidden={hidden} laval={aff.get('laval')}")99100# 4. séparation stricte des utilisateurs : B ne voit rien de A101st, prefs_b = call("lou-ka", "GET", "/api/sso/prefs", ka=TB)102check("séparation utilisateurs (B vierge)",103      st == 200 and prefs_b.get("profile", {}).get("app") is None104      and not prefs_b.get("hidden"), json.dumps(prefs_b)[:120])105106# 5. cold start : profil vide → rerank neutre (module kaid de lou-ka)107sys.path.insert(0, os.path.expanduser("~/apps/lou-ka"))108os.environ.setdefault("KA_SSO_SECRET", SECRETS["lou-ka"])109from louka import kaid  # noqa: E402110kaid.init("lou-ka")111items = [{"uid": f"u{i}", "city": "Québec", "price": 1000 + i} for i in range(10)]112out, personalized = kaid.rerank(113    list(items), {"ka_id": TB},114    features_of=lambda it: {"city": it["city"], "price": it["price"]})115check("cold start → ordre de base intact, non personnalisé",116      [o["uid"] for o in out] == [i["uid"] for i in items] and not personalized)117118# 6. reranking réel : le profil de A fait remonter Gatineau (tri par défaut)119kaid.invalidate_prefs(TA)120items = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2",121           "price": 1500} for i in range(8)]122         + [{"uid": "g1", "city": "Gatineau", "unit_type": "4 1/2", "price": 1480}])123out, personalized = kaid.rerank(124    list(items), {"ka_id": TA},125    features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"],126                            "price": it["price"]})127pos = [o["uid"] for o in out].index("g1")128badge = next((o.get("ka_reco") for o in out if o["uid"] == "g1"), None)129others_badged = [o["uid"] for o in out if o.get("ka_reco") and o["uid"] != "g1"]130check("reranking → l'annonce Gatineau remonte (bornée par blend) + seul badge",131      personalized and pos < 8 and badge is not None and not others_badged,132      f"pos={pos} badge={badge} autres={others_badged}")133134# 7. intention de session : filtre explicite city=Québec → affinité ville ignorée135kaid.invalidate_prefs(TA)136out2, _ = kaid.rerank(137    list(items), {"ka_id": TA},138    features_of=lambda it: {"city": it["city"], "unit_type": it["unit_type"],139                            "price": it["price"]},140    active_dims={"city"})141pos2 = [o["uid"] for o in out2].index("g1")142check("session intent → filtre explicite prime (Gatineau remonte moins)",143      pos2 >= pos, f"pos_sans_filtre={pos} pos_avec_filtre_city={pos2}")144145# 8. personnalisation OFF → aucun reranking (mais masqués toujours exclus)146con.execute("UPDATE users SET personalization=0 WHERE id=?", (uid_a,))147con.commit()148kaid.invalidate_prefs(TA)149st, prefs = call("lou-ka", "GET", "/api/sso/prefs")150items_h = list(items) + [{"uid": "e2e:bad", "city": "Laval",151                          "unit_type": "1 1/2", "price": 900}]152out3, personalized3 = kaid.rerank(153    list(items_h), {"ka_id": TA},154    features_of=lambda it: {"city": it["city"], "price": it["price"]})155check("personnalisation OFF → pas de rerank, masqués exclus quand même",156      st == 200 and prefs.get("personalization") is False157      and not personalized3158      and [o["uid"] for o in out3] == [i["uid"] for i in items_h if i["uid"] != "e2e:bad"],159      f"perso={prefs.get('personalization')} p3={personalized3}")160161# 9. historique OFF → événements jetés162con.execute("UPDATE users SET personalization=1, history_enabled=0 WHERE id=?", (uid_a,))163con.commit()164n_before = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?",165                       (uid_a,)).fetchone()["c"]166call("lou-ka", "POST", "/api/sso/events", {"events": [167    {"type": "detail_view", "entity_id": "e2e:drop", "features": {"city": "Lévis"}}]})168time.sleep(0.5)169n_after = con.execute("SELECT COUNT(*) c FROM user_events WHERE user_id=?",170                      (uid_a,)).fetchone()["c"]171check("historique OFF → événement jeté par le hub", n_before == n_after,172      f"{n_before}{n_after}")173174# 10. recherches sauvegardées cross-app : posée via crea-ka, listée scope=all175st, r = call("crea-ka", "POST", "/api/sso/saved-searches",176             {"action": "add", "search": {"label": "Créateurs food Québec",177              "filters": {"niche": "food"}, "alert": True}})178st2, lst = call("lou-ka", "GET", "/api/sso/saved-searches", extra_qs="scope=all")179apps_seen = {s["app"] for s in lst.get("searches", [])}180check("recherche sauvegardée cross-univers (scope=all)",181      st == 200 and st2 == 200 and "crea-ka" in apps_seen, str(apps_seen))182183# 11. filtrage collaboratif : C partage un favori avec A → son AUTRE favori184#     apparaît dans similar de A et le rerank le booste (SIMILAR_USERS)185con.execute("UPDATE users SET history_enabled=1 WHERE id=?", (uid_a,))186con.execute("DELETE FROM users WHERE email='test-kaid-c@test.local'")187con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)",188            ("test-kaid-c@test.local", "Membre Test C", "ka-9900000003"))189con.commit()190uid_c = con.execute("SELECT id FROM users WHERE ka_id='ka-9900000003'").fetchone()["id"]191con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title) VALUES (?,?,?,?)",192            (uid_c, "lou-ka", "e2e:fav1", "Logement test E2E"))193con.execute("INSERT OR IGNORE INTO favorites (user_id, app, item_id, title, url) VALUES (?,?,?,?,?)",194            (uid_c, "lou-ka", "e2e:cf-gem", "Perle co-favorite", "https://www.lou-ka.com/logement/e2e:cf-gem"))195con.execute("DELETE FROM user_prefs WHERE user_id=?", (uid_a,))196con.commit()197st, prefs = call("lou-ka", "GET", "/api/sso/prefs")198similar = (prefs.get("profile") or {}).get("app", {}).get("similar") or []199kaid.invalidate_prefs(TA)200items_cf = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2",201              "price": 1500} for i in range(6)]202            + [{"uid": "e2e:cf-gem", "city": "Québec", "unit_type": "5 1/2",203                "price": 1500}])204out_cf, _ = kaid.rerank(205    list(items_cf), {"ka_id": TA},206    features_of=lambda it: {"city": it["city"], "price": it["price"]})207pos_cf = [o["uid"] for o in out_cf].index("e2e:cf-gem")208reco_cf = next((o.get("ka_reco") for o in out_cf if o["uid"] == "e2e:cf-gem"), None)209check("filtrage collaboratif → co-favori détecté + boosté (SIMILAR_USERS)",210      "e2e:cf-gem" in similar and pos_cf < 6 and reco_cf is not None211      and "SIMILAR_USERS" in (reco_cf or {}).get("reasons", []),212      f"similar={similar} pos={pos_cf} reco={reco_cf}")213214# 12. matrix factorization (ALS) : cohorte croisée → le modèle recommande à A215#     l'annonce aimée par les membres au profil proche, jusqu'au rerank216import shutil217import subprocess218extra_uids = []219for ka, email in [("ka-9900000004", "test-kaid-d@test.local"),220                  ("ka-9900000005", "test-kaid-e@test.local"),221                  ("ka-9900000006", "test-kaid-f@test.local")]:222    con.execute("DELETE FROM users WHERE email=?", (email,))223    con.execute("INSERT INTO users (email, name, ka_id) VALUES (?,?,?)",224                (email, "Membre Test MF", ka))225    extra_uids.append(con.execute("SELECT id FROM users WHERE ka_id=?", (ka,)).fetchone()["id"])226uid_d, uid_e, uid_f = extra_uids227fav = "INSERT OR IGNORE INTO favorites (user_id, app, item_id, title) VALUES (?,?,?,?)"228for uid, items in [(uid_a, ["e2e:x1", "e2e:x2"]),229                   (uid_d, ["e2e:x1", "e2e:x2", "e2e:x3"]),230                   (uid_e, ["e2e:x2", "e2e:x3", "e2e:x4"]),231                   (uid_f, ["e2e:x1", "e2e:x3"])]:232    for it in items:233        con.execute(fav, (uid, "lou-ka", it, f"Annonce {it}"))234con.commit()235node = shutil.which("node") or "/opt/homebrew/bin/node"236run = subprocess.run([node, "scripts/kaid-mf.mjs", "--app", "lou-ka"],237                     cwd=os.path.expanduser("~/apps/groupe-ka"),238                     capture_output=True, text=True, timeout=120)239mf_rows = [r["item_id"] for r in con.execute(240    "SELECT item_id FROM mf_recs WHERE user_id=? AND app='lou-ka' ORDER BY rank",241    (uid_a,)).fetchall()]242st, prefs = call("lou-ka", "GET", "/api/sso/prefs")243mf_in_prefs = (prefs.get("profile") or {}).get("app", {}).get("mf") or []244kaid.invalidate_prefs(TA)245items_mf = ([{"uid": f"q{i}", "city": "Québec", "unit_type": "5 1/2",246              "price": 1500} for i in range(6)]247            + [{"uid": "e2e:x3", "city": "Québec", "unit_type": "5 1/2",248                "price": 1500}])249out_mf, _ = kaid.rerank(250    list(items_mf), {"ka_id": TA},251    features_of=lambda it: {"city": it["city"], "price": it["price"]})252pos_mf = [o["uid"] for o in out_mf].index("e2e:x3")253reco_mf = next((o.get("ka_reco") for o in out_mf if o["uid"] == "e2e:x3"), None)254mf_reason_ok = reco_mf is not None and any(255    r0 in ("COLLABORATIVE_MODEL", "SIMILAR_USERS") for r0 in reco_mf.get("reasons", []))256check("matrix factorization → ALS entraîné, reco x3 propagée + boostée",257      "e2e:x3" in mf_rows and "e2e:x3" in mf_in_prefs and pos_mf < 6 and mf_reason_ok,258      f"stdout={run.stdout.strip()[-160:]} mf_rows={mf_rows[:5]} "259      f"prefs_mf={mf_in_prefs[:5]} pos={pos_mf} reco={reco_mf}")260261# --- nettoyage ---------------------------------------------------------------262for uid in (uid_a, uid_b, uid_c, uid_d, uid_e, uid_f):263    for table in ("user_events", "favorites", "saved_searches", "hidden_items",264                  "user_prefs", "pref_overrides", "mf_recs"):265        con.execute(f"DELETE FROM {table} WHERE user_id=?", (uid,))266con.execute("DELETE FROM users WHERE email LIKE 'test-kaid-%@test.local'")267con.execute("DELETE FROM favorites WHERE item_id LIKE 'e2e:%'")268con.execute("DELETE FROM mf_recs WHERE item_id LIKE 'e2e:%'")269con.commit()270left = con.execute("SELECT COUNT(*) c FROM users WHERE ka_id IN (?,?)", (TA, TB)).fetchone()["c"]271print(f"\nnettoyage : membres de test supprimés (restants={left})")272273fails = [n for n, ok, _ in results if not ok]274print(f"\n== {len(results) - len(fails)}/{len(results)} tests PASS ==")275sys.exit(1 if fails else 0)276