SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
14.6 KB · 389 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Ora-Ka — Moteur de recherche transversal hybride (exact + sémantique)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4#5# Étage 1 : correspondances exactes (LIKE / FTS5) dans les 5 bases6# Étage 2 : découverte sémantique (embeddings OpenAI, oraka.semantic)7# Les deux étages sont fusionnés : l'exact d'abord, le sémantique ensuite.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import json12import re13import sqlite314from pathlib import Path1516from oraka import semantic1718ROOT = Path(__file__).resolve().parent.parent19APPS_DIR = ROOT / "apps"2021DBS = {22    "immo": APPS_DIR / "immo" / "data" / "immoka.db",23    "lou": APPS_DIR / "lou" / "data" / "louka.db",24    "fabri": APPS_DIR / "fabri" / "data" / "fabrika.db",25    "auto": APPS_DIR / "auto" / "data" / "autoka.db",26    "food": APPS_DIR / "food" / "data" / "foodka.db",27}2829META = {30    "immo": {"label": "Propriétés", "color": "#e23744", "more": "/immo/?q="},31    "lou": {"label": "Logements", "color": "#1c5c41", "more": "/lou/?q="},32    "fabri": {"label": "Produits QC", "color": "#c4532e", "more": "/fabri/produits?q="},33    "auto": {"label": "Véhicules", "color": "#ff5a2a", "more": "/auto/?q="},34    "food": {"label": "Épicerie", "color": "#1f7a4d", "more": "/food/?q="},35}3637APP_ORDER = ["immo", "lou", "auto", "fabri", "food"]3839_FTS_SAFE = re.compile(r"[^0-9A-Za-zÀ-ÖØ-öø-ÿ' -]")40_SEM_FLOOR = 0.32  # similarité minimale pour la découverte sémantique414243def _ro(db: Path) -> sqlite3.Connection:44    con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)45    con.row_factory = sqlite3.Row46    return con474849def _first_image(raw) -> str | None:50    try:51        imgs = json.loads(raw) if isinstance(raw, str) else raw52        if isinstance(imgs, list) and imgs:53            return str(imgs[0])54    except Exception:55        pass56    return None575859def _money(v) -> str | None:60    if v is None:61        return None62    try:63        v = float(v)64    except (TypeError, ValueError):65        return None66    if v == int(v):67        s = f"{int(v):,}".replace(",", " ")68    else:69        s = f"{v:,.2f}".replace(",", " ").replace(".", ",")70    return f"{s} $"717273def _fts_query(q: str) -> str:74    words = _FTS_SAFE.sub(" ", q).split()[:8]75    return " ".join(f'"{w}"*' for w in words if w)767778# ---------------------------------------------------------------------------79# Mise en forme d'une ligne SQL -> carte de résultat80# ---------------------------------------------------------------------------8182def _shape_immo(r) -> dict:83    bits = [b for b in (r["property_type"],84                        f"{int(r['bedrooms'])} ch." if r["bedrooms"] else None,85                        r["city"]) if b]86    return {"uid": r["uid"], "title": r["title"] or r["address"] or "Propriété",87            "sub": " · ".join(bits), "price": r["price_label"] or _money(r["price"]),88            "image": _first_image(r["images"]), "href": f"/immo/propriete/{r['uid']}"}899091def _shape_lou(r) -> dict:92    m = _money(r["price"])93    price = r["price_label"] or (f"{m}/mois" if m else None)94    bits = [b for b in (r["unit_type"], r["sector"] or r["city"]) if b]95    return {"uid": r["uid"], "title": r["title"] or r["address"] or "Logement",96            "sub": " · ".join(bits), "price": price,97            "image": _first_image(r["images"]), "href": f"/lou/logement/{r['uid']}"}9899100def _shape_fabri(r) -> dict:101    return {"uid": r["uid"], "title": r["title"],102            "sub": " · ".join(b for b in (r["store_name"], r["region"]) if b),103            "price": _money(r["price"]), "image": _first_image(r["images"]),104            "href": f"/fabri/produits/{r['uid']}"}105106107def _shape_auto(r) -> dict:108    km = f"{int(r['mileage_km']):,} km".replace(",", " ") if r["mileage_km"] else None109    bits = [b for b in (km, r["city"] or r["dealer_name"]) if b]110    title = r["title"] or f"{r['year'] or ''} {r['make'] or ''} {r['model'] or ''}".strip()111    return {"uid": r["uid"], "title": title, "sub": " · ".join(bits),112            "price": r["price_label"] or _money(r["price"]),113            "image": _first_image(r["images"]), "href": f"/auto/vehicule/{r['uid']}"}114115116def _shape_food(r) -> dict:117    bits = [b for b in (r["brand"], r["size_label"],118                        r["source"].replace("_", " ").title() if r["source"] else None) if b]119    price = _money(r["price"])120    if price and r["on_sale"]:121        price += " 🔥"122    return {"uid": r["uid"], "title": r["name"], "sub": " · ".join(bits),123            "price": price, "image": _first_image(r["images"]),124            "href": f"/food/produit/{r['uid']}"}125126127_FIELDS = {128    "immo": ("listings", "uid, title, address, city, sector, property_type, bedrooms,"129             " bathrooms, price, price_label, images", _shape_immo),130    "lou": ("listings", "uid, title, address, city, sector, unit_type, price,"131            " price_label, images", _shape_lou),132    "auto": ("vehicles", "uid, title, make, model, year, price, price_label,"133             " mileage_km, city, dealer_name, images", _shape_auto),134    "food": ("products", "uid, name, brand, source, size_label, price, on_sale,"135             " images", _shape_food),136}137138139# ---------------------------------------------------------------------------140# Étage 1 — recherche exacte par univers (LIKE / FTS5)141# ---------------------------------------------------------------------------142143_KW_COLS = {144    "immo": ("title", "address", "city", "sector", "mls"),145    "lou": ("title", "address", "sector", "city"),146    "auto": ("title", "make", "model", "dealer_name", "city"),147    "food": ("name", "brand", "category_raw"),148}149150_KW_BASE = {151    "immo": "active=1 AND dup_hidden=0 AND price IS NOT NULL",152    "lou": "active=1",153    "auto": "active=1",154    "food": "active=1",155}156157_KW_ORDER = {158    "immo": "ORDER BY last_seen DESC",159    "lou": "ORDER BY price IS NULL, last_seen DESC",160    "auto": "ORDER BY price IS NULL, last_seen DESC",161    "food": "ORDER BY price IS NULL, price ASC",162}163164165def _strip_accents(s: str) -> str:166    import unicodedata167    return "".join(c for c in unicodedata.normalize("NFD", s)168                   if unicodedata.category(c) != "Mn")169170171_STOP = {"a", "à", "au", "aux", "de", "des", "du", "en", "et", "la", "le", "les",172         "un", "une", "avec", "pour", "près", "pres", "sur", "dans", "d", "l"}173174175def _tokens(q: str) -> list[str]:176    words = re.split(r"[^0-9A-Za-zÀ-ÖØ-öø-ÿ½'-]+", q)177    out = []178    for w in words:179        w = w.strip("'-")180        if len(w) >= 2 and w.lower() not in _STOP:181            out.append(w)182    return out[:8]183184185def _kw_search(app: str, q: str, limit: int) -> tuple[int, list[dict]]:186    """Recherche exacte par jetons : chaque mot significatif doit apparaître."""187    toks = _tokens(q)188    if app == "fabri":189        match = _fts_query(q)190        if not match:191            return 0, []192        con = _ro(DBS["fabri"])193        try:194            total = con.execute(195                "SELECT COUNT(*) FROM products_fts f JOIN products p ON p.uid=f.uid"196                " WHERE products_fts MATCH ? AND p.active=1", (match,)).fetchone()[0]197            rows = con.execute(198                "SELECT p.uid, p.title, p.price, p.images, s.name AS store_name,"199                " s.region FROM products_fts f JOIN products p ON p.uid=f.uid"200                " LEFT JOIN stores s ON s.id=p.store_id"201                " WHERE products_fts MATCH ? AND p.active=1 ORDER BY rank LIMIT ?",202                (match, limit)).fetchall()203        finally:204            con.close()205        return total, [_shape_fabri(r) for r in rows]206    if not toks:207        return 0, []208    table, fields, shape = _FIELDS[app]209    cols = _KW_COLS[app]210    clauses, args = [], []211    for t in toks:212        clauses.append("(" + " OR ".join(f"{c} LIKE ?" for c in cols) + ")")213        args.extend([f"%{t}%"] * len(cols))214    where = _KW_BASE[app] + " AND " + " AND ".join(clauses)215    con = _ro(DBS[app])216    try:217        total = con.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}",218                            args).fetchone()[0]219        rows = con.execute(f"SELECT {fields} FROM {table} WHERE {where}"220                           f" {_KW_ORDER[app]} LIMIT ?", args + [limit]).fetchall()221    finally:222        con.close()223    return total, [shape(r) for r in rows]224225226# ---------------------------------------------------------------------------227# Étage 2 — hydratation des uid retenus par l'index sémantique228# ---------------------------------------------------------------------------229230def _hydrate(app: str, uids: list[str]) -> dict[str, dict]:231    if not uids:232        return {}233    marks = ",".join("?" * len(uids))234    con = _ro(DBS[app])235    try:236        if app == "fabri":237            rows = con.execute(238                f"SELECT p.uid, p.title, p.price, p.images, s.name AS store_name,"239                f" s.region FROM products p LEFT JOIN stores s ON s.id=p.store_id"240                f" WHERE p.uid IN ({marks}) AND p.active=1", uids).fetchall()241            return {r["uid"]: _shape_fabri(r) for r in rows}242        table, fields, shape = _FIELDS[app]243        rows = con.execute(f"SELECT {fields} FROM {table}"244                           f" WHERE uid IN ({marks}) AND active=1", uids).fetchall()245        return {r["uid"]: shape(r) for r in rows}246    finally:247        con.close()248249250# ---------------------------------------------------------------------------251# Fusion hybride252# ---------------------------------------------------------------------------253254def _coverage(it: dict, toks_norm: list[str]) -> float:255    """Fraction des jetons de la requête présents dans le titre/sous-titre."""256    if not toks_norm:257        return 0.0258    hay = _strip_accents(f"{it.get('title', '')} {it.get('sub', '')}").lower()259    return sum(1 for t in toks_norm if t in hay) / len(toks_norm)260261262def search_hits(q: str, scope: str | None = None, limit: int = 60) -> dict:263    q = (q or "").strip()264    apps = [scope] if scope in DBS else APP_ORDER265    apps = [a for a in apps if DBS[a].exists()]266    if not q:267        return {"q": q, "semantic": False, "hits": [], "counts": {},268                "totals": {}, "more": {}}269270    toks = _tokens(q)271    toks_norm = [_strip_accents(t).lower() for t in toks]272    q_norm = _strip_accents(q).lower()273274    # --- Candidats exacts (tous les jetons requis) ---275    kw_limit = 60 if scope else 30276    totals: dict[str, int] = {}277    cands: dict[tuple[str, str], dict] = {}278    kw_set: set[tuple[str, str]] = set()279    for app in apps:280        try:281            total, items = _kw_search(app, q, kw_limit)282        except Exception:283            total, items = 0, []284        totals[app] = total285        for it in items:286            key = (app, it["uid"])287            it = dict(it)288            it["app"] = app289            cands[key] = it290            kw_set.add(key)291292    # --- Candidats sémantiques ---293    sem_used = False294    qv = semantic.query_vector(q) if semantic.available() else None295    sem_scores: dict[tuple[str, str], float] = {}296    if qv is not None:297        per_app = 50 if scope else 30298        sem = semantic.semantic_top(q, apps, per_app=per_app)299        by_app: dict[str, list[str]] = {}300        for app, pairs in sem.items():301            for uid, score in pairs:302                if score >= _SEM_FLOOR:303                    sem_scores[(app, uid)] = score304                    if (app, uid) not in cands:305                        by_app.setdefault(app, []).append(uid)306        for app, uids in by_app.items():307            for uid, it in _hydrate(app, uids).items():308                it = dict(it)309                it["app"] = app310                cands[(app, uid)] = it311        # similarité des hits exacts absents du top sémantique (lookup par uid)312        missing: dict[str, list[str]] = {}313        for (app, uid) in kw_set:314            if (app, uid) not in sem_scores:315                missing.setdefault(app, []).append(uid)316        for app, uids in missing.items():317            for uid, s in semantic.sims_for(app, uids, qv).items():318                sem_scores[(app, uid)] = s319        sem_used = bool(sem_scores)320321    if not cands:322        return {"q": q, "semantic": sem_used, "hits": [], "counts": {},323                "totals": totals,324                "more": {a: META[a]["more"] + q.replace(" ", "+") for a in apps},325                "meta": {a: {"label": META[a]["label"], "color": META[a]["color"]}326                         for a in DBS}}327328    # --- Affinité d'univers (intention) : meilleur score sémantique par app ---329    aff: dict[str, float] = {}330    for (app, uid), s in sem_scores.items():331        aff[app] = max(aff.get(app, 0.0), s)332    best_aff = max(aff.values()) if aff else 0.0333334    # --- Score unifié ---335    scored: list[tuple[float, dict]] = []336    for key, it in cands.items():337        app, uid = key338        sem_s = sem_scores.get(key)339        cov = _coverage(it, toks_norm)340        exact_kw = key in kw_set341        phrase = 1.0 if q_norm and q_norm in _strip_accents(342            str(it.get("title", ""))).lower() else 0.0343        base = sem_s if sem_s is not None else (0.42 if exact_kw else 0.0)344        score = (base345                 + 0.22 * cov346                 + 0.10 * phrase347                 + (0.05 if exact_kw else 0.0)348                 + (0.08 * (aff.get(app, best_aff) - best_aff) if best_aff else 0.0))349        it["match"] = "exact" if exact_kw and cov >= 0.99 else (350            "exact" if exact_kw and sem_s is None else351            ("exact" if exact_kw else "semantique"))352        it["score"] = round(max(score, 0.0), 3)353        scored.append((score, it))354355    scored.sort(key=lambda x: -x[0])356    best = scored[0][0]357    hits = [it for s, it in scored if s >= best - 0.22][:limit]358359    # Garantie de diversité : tout univers pertinent (à ≤0.30 du meilleur) est360    # représenté dans la première douzaine, même si un univers volumineux domine.361    head_apps = {it["app"] for it in hits[:12]}362    inserts = []363    for app in apps:364        if app in head_apps:365            continue366        cand = next((it for s, it in scored367                     if it["app"] == app and s >= best - 0.30), None)368        if cand is not None:369            inserts.append(cand)370    for i, cand in enumerate(inserts):371        pos = min(6 + i * 3, len(hits))372        if cand in hits:373            hits.remove(cand)374        hits.insert(pos, cand)375    hits = hits[:limit]376377    counts: dict[str, int] = {}378    for it in hits:379        counts[it["app"]] = counts.get(it["app"], 0) + 1380    return {381        "q": q,382        "semantic": sem_used,383        "hits": hits,384        "counts": counts,385        "totals": totals,386        "more": {a: META[a]["more"] + q.replace(" ", "+") for a in apps},387        "meta": {a: {"label": META[a]["label"], "color": META[a]["color"]} for a in DBS},388    }389