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
210 lines
8.3 KiB
Python
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"
|
|
)
|