# ----------------------------------------------------------------------------- # Ora-Ka — Index sémantique (embeddings OpenAI) sur les 5 univers # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # # Construction : python run.py index (incrémental — n'embedde que le neuf) # Requête : semantic_top(q) (repli silencieux si index/clé absents) # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import sqlite3 import sys import time from pathlib import Path import numpy as np import requests ROOT = Path(__file__).resolve().parent.parent APPS_DIR = ROOT / "apps" INDEX_DIR = ROOT / "data" / "semantic" MODEL = "text-embedding-3-small" DIMS = 256 BATCH = 800 DBS = { "immo": APPS_DIR / "immo" / "data" / "immoka.db", "lou": APPS_DIR / "lou" / "data" / "louka.db", "fabri": APPS_DIR / "fabri" / "data" / "fabrika.db", "auto": APPS_DIR / "auto" / "data" / "autoka.db", "food": APPS_DIR / "food" / "data" / "foodka.db", } def _ro(db: Path) -> sqlite3.Connection: con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=10) con.row_factory = sqlite3.Row return con def _clean(*parts) -> str: return " · ".join(str(p).strip() for p in parts if p not in (None, "", 0))[:400] # --------------------------------------------------------------------------- # Corpus : (uid, texte) par univers — le texte décrit l'inscription en clair # --------------------------------------------------------------------------- def _corpus_immo() -> dict[str, str]: con = _ro(DBS["immo"]) try: rows = con.execute( "SELECT uid, title, address, sector, city, region, property_type," " bedrooms, bathrooms, price FROM listings" " WHERE active=1 AND dup_hidden=0 AND price IS NOT NULL").fetchall() finally: con.close() out = {} for r in rows: ch = f"{int(r['bedrooms'])} chambres" if r["bedrooms"] else None sdb = f"{int(r['bathrooms'])} salles de bain" if r["bathrooms"] else None px = f"{int(r['price'])} $" if r["price"] else None out[r["uid"]] = _clean(f"{r['property_type'] or 'Propriété'} à vendre", r["title"], r["address"], r["sector"], r["city"], r["region"], ch, sdb, px) return out def _corpus_lou() -> dict[str, str]: con = _ro(DBS["lou"]) try: rows = con.execute( "SELECT uid, title, address, sector, city, unit_type, price, furnished" " FROM listings WHERE active=1").fetchall() finally: con.close() out = {} for r in rows: px = f"{int(r['price'])} $ par mois" if r["price"] else None out[r["uid"]] = _clean(f"{r['unit_type'] or 'Logement'} à louer, appartement", r["title"], r["address"], r["sector"], r["city"], px, "meublé" if r["furnished"] else None) return out def _corpus_fabri() -> dict[str, str]: con = _ro(DBS["fabri"]) try: rows = con.execute( "SELECT p.uid, p.title, p.category, p.vendor, p.product_type," " s.name AS store_name, s.region FROM products p" " LEFT JOIN stores s ON s.id=p.store_id WHERE p.active=1").fetchall() finally: con.close() return {r["uid"]: _clean("Produit québécois", r["title"], r["product_type"], r["category"], r["vendor"], r["store_name"], r["region"]) for r in rows} def _corpus_auto() -> dict[str, str]: con = _ro(DBS["auto"]) try: rows = con.execute( "SELECT uid, kind, title, make, model, trim, year, body_type, fuel," " transmission, mileage_km, city, region, price FROM vehicles" " WHERE active=1").fetchall() finally: con.close() out = {} for r in rows: km = f"{int(r['mileage_km'])} km" if r["mileage_km"] else None px = f"{int(r['price'])} $" if r["price"] else None kind = {"auto": "Voiture d'occasion", "moto": "Moto", "scooter": "Scooter"}.get(r["kind"], "Véhicule") out[r["uid"]] = _clean(kind, r["year"], r["make"], r["model"], r["trim"], r["body_type"], r["fuel"], r["transmission"], km, r["city"], r["region"], px) return out def _corpus_food() -> dict[str, str]: con = _ro(DBS["food"]) try: rows = con.execute( "SELECT uid, name, brand, category, category_raw, size_label, source" " FROM products WHERE active=1").fetchall() finally: con.close() return {r["uid"]: _clean("Épicerie", r["name"], r["brand"], r["category"], r["category_raw"], r["size_label"], (r["source"] or "").replace("_", " ")) for r in rows} _CORPUS = { "immo": _corpus_immo, "lou": _corpus_lou, "fabri": _corpus_fabri, "auto": _corpus_auto, "food": _corpus_food, } # --------------------------------------------------------------------------- # OpenAI # --------------------------------------------------------------------------- def _embed(texts: list[str], api_key: str, retries: int = 4) -> np.ndarray: for attempt in range(retries): try: resp = requests.post( "https://api.openai.com/v1/embeddings", headers={"Authorization": f"Bearer {api_key}"}, json={"model": MODEL, "input": texts, "dimensions": DIMS}, timeout=120, ) if resp.status_code == 429: time.sleep(5 * (attempt + 1)) continue resp.raise_for_status() data = resp.json()["data"] vecs = np.array([d["embedding"] for d in data], dtype=np.float32) norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms[norms == 0] = 1.0 return vecs / norms except requests.RequestException: if attempt == retries - 1: raise time.sleep(3 * (attempt + 1)) raise RuntimeError("embeddings: échec après retries") # --------------------------------------------------------------------------- # Construction incrémentale de l'index # --------------------------------------------------------------------------- def build_index(apps: list[str] | None = None) -> None: api_key = os.environ.get("OPENAI_API_KEY") if not api_key: print("[ora-ka] OPENAI_API_KEY manquant (.env) — index sémantique impossible", file=sys.stderr) sys.exit(1) INDEX_DIR.mkdir(parents=True, exist_ok=True) for app in apps or list(_CORPUS): if not DBS[app].exists(): print(f"[index] {app}: base absente, sauté") continue t0 = time.time() corpus = _CORPUS[app]() path = INDEX_DIR / f"{app}.npz" old_uids: list[str] = [] old_vecs = None if path.exists(): z = np.load(path, allow_pickle=False) old_uids = [str(u) for u in z["uids"]] old_vecs = z["vecs"] keep_mask = [u in corpus for u in old_uids] kept_uids = [u for u, k in zip(old_uids, keep_mask) if k] kept_vecs = old_vecs[np.array(keep_mask, dtype=bool)] if old_vecs is not None and old_uids else None known = set(kept_uids) new_uids = [u for u in corpus if u not in known] print(f"[index] {app}: {len(corpus)} items — {len(kept_uids)} déjà indexés," f" {len(new_uids)} à embedder") new_chunks = [] for i in range(0, len(new_uids), BATCH): chunk = new_uids[i:i + BATCH] vecs = _embed([corpus[u] for u in chunk], api_key) new_chunks.append(vecs.astype(np.float16)) done = i + len(chunk) if done % (BATCH * 10) < BATCH or done == len(new_uids): print(f"[index] {app}: {done}/{len(new_uids)} embeddés" f" ({time.time() - t0:.0f}s)", flush=True) parts = [] if kept_vecs is not None and len(kept_uids): parts.append(kept_vecs.astype(np.float16)) parts.extend(new_chunks) all_uids = kept_uids + new_uids if not all_uids: print(f"[index] {app}: rien à indexer") continue all_vecs = np.concatenate(parts, axis=0) np.savez_compressed(path, uids=np.array(all_uids), vecs=all_vecs) meta = {"model": MODEL, "dims": DIMS, "count": len(all_uids), "built_at": time.time()} (INDEX_DIR / f"{app}.meta.json").write_text(json.dumps(meta)) print(f"[index] {app}: OK — {len(all_uids)} vecteurs," f" {path.stat().st_size / 1e6:.0f} Mo, {time.time() - t0:.0f}s") # --------------------------------------------------------------------------- # Requête # --------------------------------------------------------------------------- _loaded: dict[str, tuple[list[str], np.ndarray]] = {} _loaded_at: dict[str, float] = {} _uid_idx: dict[str, dict[str, int]] = {} _qcache: dict[str, np.ndarray] = {} def _load(app: str): path = INDEX_DIR / f"{app}.npz" if not path.exists(): return None mtime = path.stat().st_mtime if app not in _loaded or _loaded_at.get(app) != mtime: z = np.load(path, allow_pickle=False) uids = [str(u) for u in z["uids"]] _loaded[app] = (uids, z["vecs"].astype(np.float32)) _uid_idx[app] = {u: i for i, u in enumerate(uids)} _loaded_at[app] = mtime return _loaded[app] def query_vector(q: str) -> np.ndarray | None: """Vecteur normalisé de la requête (cache LRU simple), None si indisponible.""" api_key = os.environ.get("OPENAI_API_KEY") if not api_key: return None key = q.strip().lower() qv = _qcache.get(key) if qv is None: try: qv = _embed([q], api_key)[0] except Exception: return None if len(_qcache) > 512: _qcache.clear() _qcache[key] = qv return qv def sims_for(app: str, uids: list[str], qv: np.ndarray) -> dict[str, float]: """Similarité requête↔item pour une liste d'uid (lookup direct dans l'index).""" if _load(app) is None or not uids: return {} idx_map = _uid_idx[app] pairs = [(u, idx_map[u]) for u in uids if u in idx_map] if not pairs: return {} arr = np.array([i for _, i in pairs]) sims = _loaded[app][1][arr] @ qv return {u: float(s) for (u, _), s in zip(pairs, sims)} def available() -> bool: return bool(os.environ.get("OPENAI_API_KEY")) and any( (INDEX_DIR / f"{a}.npz").exists() for a in _CORPUS) def semantic_top(q: str, apps: list[str] | None = None, per_app: int = 30 ) -> dict[str, list[tuple[str, float]]]: """Retourne {app: [(uid, score), ...]} trié par similarité décroissante.""" qv = query_vector(q) if qv is None: return {} out: dict[str, list[tuple[str, float]]] = {} for app in apps or list(_CORPUS): loaded = _load(app) if not loaded: continue uids, vecs = loaded sims = vecs @ qv k = min(per_app, len(uids)) if k <= 0: continue idx = np.argpartition(-sims, k - 1)[:k] idx = idx[np.argsort(-sims[idx])] out[app] = [(uids[i], float(sims[i])) for i in idx] return out