Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py
Claude Agent ca6b53e0ca feat(maria): rank hibrid, escaladare la suport si dictionar de erori Oracle
Trei probleme legate, gasite testand cu capturi reale.

1. RASPUNSURI GENERICE. Cand raspunsul nu era in documente, modelul primea
   oricum top-3 chunk-uri si compunea ceva plauzibil — utilizatorul pleca cu
   impresia ca a primit ajutor. Acum, daca nu avem acoperire, modelul nu mai e
   intrebat deloc: intrebarea pleaca la suport, cu referinta (M-260831-A1B2) si
   confirmare onesta — "am trimis" doar daca notificarea chiar a plecat, altfel
   "am inregistrat, dar nu am putut trimite". Jurnalul se scrie intotdeauna, in
   ~/.maria-bridge/escalations/, si se vede in dashboard. Daca a fost o captura,
   imaginea insasi se retrimite la suport (endpoint nou /send-image, limitat la
   MEDIA_DIR).

2. ORDONARE (rank.py). Un prag pe cosinus nu putea decide "avem raspunsul?":
   masurat pe indexul viu, intrebarile bune dau 0,600-0,816 si cele straine
   0,534-0,685 — intervale care SE SUPRAPUN. Lipseau codurile: ORA-01722, D406,
   CIF sunt tokeni exacti pe care cautarea semantica ii topeste. Acum se ordoneaza
   de doua ori — embeddings si BM25 — si se fuzioneaza clasamentele (RRF), iar
   decizia de acoperire se ia pe dovezi: cosinus mare, sau cosinus de mijloc cu o
   fractiune din termenii distinctivi gasita chiar in chunk-uri. Fractiunea conteaza:
   cu un singur termen, "cum imi resetez parola de la Windows" trecea drept
   acoperita fiindca "parola" apare in documente.
   Pragurile sunt masurate, nu alese: ops/calibrate-rank.py ruleaza un set de cazuri
   pe indexul real si matura grila. 15/15 la strong=0.70 weak=0.58 ratio=0.5.

3. DICTIONAR DE ERORI ORACLE. 29 de erori uzuale, scrise pentru utilizatorul din
   fata aplicatiei: ce inseamna, ce poate incerca singur, cand sa sune. Fara nume
   de servere, IP-uri sau pasi de administrare — Maria e chatbot pentru clienti.
   Fisierul din git e SAMANTA: depozitul e oglinda Drive-ului, deci trebuie pus in
   document_store ca sa nu dispara la sincronizare.

Pe drum, doua lucruri gasite in log:
- fiecare mesaj din self-chat sosea de DOUA ori, pe @s.whatsapp.net si pe @lid,
  deci Maria raspundea de doua ori. Dedup pe key.id in punte.
- reindexarea reface toate embeddings-urile (~7 s fiecare pe CPU): adaugarea unui
  document la 170 de chunk-uri insemna 20 de minute. Acum refoloseste vectorii
  chunk-urilor nemodificate din indexul precedent, fara vreun fisier nou de stare.

Textul OCR se logheaza acum INTEGRAL, cu interogarea de cautare si sursele alese
cu scorurile lor — fara asta un raspuns gresit nu se poate explica: nu stiai daca
a citit prost captura sau a cautat prost in documente.

6 fisiere de test noi/extinse, 68 pass (de la 42).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-08-31 22:08:50 +00:00

210 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Ordonarea chunk-urilor: embeddings + potrivire lexicala, fuzionate.
De ce nu doar embeddings. Masurat pe indexul viu (140 de chunk-uri), scorul
cosinus al celui mai bun chunk a fost 0,600-0,816 la intrebari care CHIAR au
raspuns in documente si 0,534-0,685 la intrebari straine de ROA. Intervalele SE
SUPRAPUN, deci niciun prag pe cosinus singur nu le separa: „bilant contabil"
(0,600, buna) sta sub „eroare 0x80070005 la instalarea unui joc pe Steam" (0,685,
straina).
Ce lipseste din cosinus e exact ce conteaza la suport: **codurile**. `ORA-01722`,
`D406`, `CIF`, `SPV` sunt tokeni exacti — o cautare lexicala ii prinde imediat, iar
una semantica ii topeste in "ceva despre facturi". Asa ca ordonam de doua ori,
independent, si fuzionam clasamentele (Reciprocal Rank Fusion): un chunk urcat de
ambele metode iese primul, iar unul urcat doar de una ramane in cursa.
Fuziunea da mereu un clasament, si la o intrebare complet straina — de aceea
decizia "avem sau nu acoperire in documente" se ia separat, in `assess()`, pe
dovezi (cat de sus e cosinusul, si daca termenii rari din intrebare chiar apar in
chunk), nu pe pozitia in clasament.
"""
from __future__ import annotations
import math
import re
import unicodedata
import config
# Praguri implicite, calibrate pe masuratoarea din docstring. Se pot suprascrie
# din env (vezi config.DEFAULTS) fara sa umbli in cod.
RRF_K = 60 # constanta standard din Reciprocal Rank Fusion
def strong_cosine() -> float:
"""Peste atat, singur cosinusul e de ajuns (RANK_STRONG_COSINE)."""
return float(config.get("RANK_STRONG_COSINE", "0.70"))
def weak_cosine() -> float:
"""Sub atat nu salvam raspunsul nici cu potrivire lexicala (RANK_WEAK_COSINE)."""
return float(config.get("RANK_WEAK_COSINE", "0.58"))
def recall_n() -> int:
"""Cate chunk-uri intra in fuziune, din fiecare metoda (RANK_RECALL_N)."""
return config.get_int("RANK_RECALL_N", 12)
def min_rare_ratio() -> float:
"""Ce fractiune din termenii distinctivi ai intrebarii trebuie gasita (RANK_MIN_RARE_RATIO).
Un singur termen gasit nu e o dovada: „cum imi resetez parola de la Windows" a
fost salvata gresit doar fiindca „parola" apare in documente. Cerand o
fractiune, intrebarea trebuie sa se suprapuna cu documentele, nu sa le atinga.
"""
return float(config.get("RANK_MIN_RARE_RATIO", "0.5"))
_TOKEN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
# Cuvinte prea frecvente ca sa spuna ceva despre subiect. Lista e scurta
# deliberat: la BM25 idf-ul se ocupa oricum de restul.
_STOP = {
"si", "sa", "se", "la", "de", "din", "in", "pe", "cu", "pentru", "ca", "care",
"este", "sunt", "am", "are", "nu", "un", "o", "al", "ale", "lui", "cum", "ce",
"mi", "imi", "ma", "eu", "va", "vrea", "vreau", "poate", "pot", "the", "to",
"a", "an", "of", "is", "it", "da", "dar", "sau", "mai", "fost", "face",
}
# Coduri de eroare si formulare: valoreaza cat zece cuvinte obisnuite.
_COD = re.compile(r"^(?:ora|pls|tns|sp2|imp|exp|ora)-\d+$|^d\d{3}$|^e\d{3,}$")
def fold(text: str) -> str:
"""Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token."""
norm = unicodedata.normalize("NFKD", text.lower())
return "".join(c for c in norm if not unicodedata.combining(c))
def tokenize(text: str) -> list[str]:
return [t for t in _TOKEN.findall(fold(text)) if t not in _STOP and len(t) > 1]
class Bm25:
"""Okapi BM25 peste chunk-urile indexului.
Se reconstruieste la fiecare recitire a indexului (la 140 de chunk-uri costa
milisecunde) — nu merita tinut in fisier si desincronizat de el.
"""
def __init__(self, documents: list[str], k1: float = 1.5, b: float = 0.75):
self.k1, self.b = k1, b
self.docs = [tokenize(d) for d in documents]
self.lens = [len(d) for d in self.docs]
self.avg_len = (sum(self.lens) / len(self.lens)) if self.lens else 0.0
self.freqs: list[dict[str, int]] = []
self.df: dict[str, int] = {}
for toks in self.docs:
f: dict[str, int] = {}
for t in toks:
f[t] = f.get(t, 0) + 1
self.freqs.append(f)
for t in f:
self.df[t] = self.df.get(t, 0) + 1
self.n = len(self.docs)
def idf(self, term: str) -> float:
df = self.df.get(term, 0)
# varianta cu +0.5 (Robertson): termenii din peste jumatate din documente
# ajung aproape de zero, ceea ce e exact ce vrem.
return math.log(1 + (self.n - df + 0.5) / (df + 0.5))
def scores(self, query: str) -> list[float]:
q = tokenize(query)
out = [0.0] * self.n
if not q or not self.avg_len:
return out
for i, f in enumerate(self.freqs):
s = 0.0
for t in q:
tf = f.get(t, 0)
if not tf:
continue
denom = tf + self.k1 * (1 - self.b + self.b * self.lens[i] / self.avg_len)
s += self.idf(t) * tf * (self.k1 + 1) / denom
out[i] = s
return out
def rare_terms(self, query: str, max_df_ratio: float = 0.25) -> list[str]:
"""Termenii din intrebare care chiar disting ceva: coduri, sau cuvinte rare in corpus."""
out = []
for t in dict.fromkeys(tokenize(query)):
if _COD.match(t):
out.append(t)
elif len(t) >= 4 and self.df.get(t, 0) <= max(1, int(self.n * max_df_ratio)):
out.append(t)
return out
def rrf(rankings: list[list[int]], k: int = RRF_K) -> dict[int, float]:
"""Reciprocal Rank Fusion: suma de 1/(k+pozitie) peste clasamente.
Fuzioneaza POZITII, nu scoruri — cosinusul (0..1) si BM25 (nemarginit) nu se
pot aduna direct fara o normalizare care ar depinde de corpus.
"""
fused: dict[int, float] = {}
for ranking in rankings:
for pos, idx in enumerate(ranking):
fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + pos + 1)
return fused
def rank(
cosines: list[float],
bm25_scores: list[float],
recall: int | None = None,
) -> list[int]:
"""Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea celor doua clasamente."""
recall = recall_n() if recall is None else recall
by_cos = sorted(range(len(cosines)), key=lambda i: cosines[i], reverse=True)[:recall]
# chunk-urile cu scor BM25 zero nu au niciun token comun cu intrebarea: nu au
# ce cauta in clasamentul lexical, doar ar dilua fuziunea cu pozitii arbitrare.
by_bm = [i for i in sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)
if bm25_scores[i] > 0][:recall]
fused = rrf([by_cos, by_bm])
return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True)
def assess(
query: str,
best_cosine: float,
top_texts: list[str],
bm25: Bm25,
*,
strong: float | None = None,
weak: float | None = None,
min_ratio: float | None = None,
) -> tuple[bool, str]:
"""(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada.
Trei cazuri, in ordine:
1. cosinus mare -> raspundem, indiferent de lexical;
2. cosinus la mijloc, dar o fractiune suficienta din termenii rari ai
intrebarii apare chiar in chunk-uri (tipic: un cod de eroare) -> raspundem;
3. altfel -> nu avem acoperire, intrebarea pleaca la suport.
"""
strong = strong_cosine() if strong is None else strong
weak = weak_cosine() if weak is None else weak
min_ratio = min_rare_ratio() if min_ratio is None else min_ratio
if best_cosine >= strong:
return True, f"cosinus {best_cosine:.3f} >= {strong}"
if best_cosine < weak:
return False, f"cosinus {best_cosine:.3f} < {weak}"
rare = bm25.rare_terms(query)
if not rare:
return False, f"cosinus {best_cosine:.3f} la limita, iar intrebarea nu are termeni distinctivi"
blob = fold("\n".join(top_texts))
gasiti = [t for t in rare if t in blob]
if gasiti and len(gasiti) / len(rare) >= min_ratio:
return True, (
f"cosinus {best_cosine:.3f} + {len(gasiti)}/{len(rare)} termeni distinctivi "
f"in context: {', '.join(gasiti)}"
)
lipsa = [t for t in rare if t not in blob]
return False, (
f"cosinus {best_cosine:.3f} la limita, iar termenii distinctivi "
f"({', '.join(lipsa[:5])}) nu apar in documente"
)