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
This commit is contained in:
154
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py
Executable file
154
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Calibreaza pragurile de escaladare pe indexul REAL, nu pe intuitie.
|
||||
|
||||
De ce exista: pragul care decide "avem raspunsul in documente" nu se poate alege
|
||||
din burta. Marginea dintre o intrebare buna si una straina e de cateva sutimi de
|
||||
cosinus si se MUTA cand se schimba documentele — un prag bun azi poate escalada
|
||||
maine jumatate din intrebarile legitime.
|
||||
|
||||
Cum se foloseste:
|
||||
|
||||
MARIA_BRIDGE_DIR=$HOME/.maria-bridge ~/.maria-bridge/venv/bin/python \\
|
||||
ops/calibrate-rank.py # raport pe setul de cazuri
|
||||
... ops/calibrate-rank.py --sweep # matura grila de praguri
|
||||
... ops/calibrate-rank.py --cases fisier.json
|
||||
|
||||
Embeddings-urile intrebarilor se tin in cache pe disc (`~/.maria-bridge/
|
||||
calibrare-cache.json`): pe CPU, o intrebare costa secunde, iar maturarea grilei
|
||||
le-ar recalcula de zeci de ori degeaba.
|
||||
|
||||
Setul de cazuri e deliberat mic si scris de mana. Nu e un benchmark, e o plasa de
|
||||
siguranta: daca un prag nou strica un caz care mergea, se vede imediat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "rag"))
|
||||
|
||||
import config # noqa: E402
|
||||
import consumer # noqa: E402
|
||||
import rank # noqa: E402
|
||||
|
||||
# (intrebare, are raspuns in documente?)
|
||||
CAZURI: list[tuple[str, bool]] = [
|
||||
("token efactura expirat", True),
|
||||
("Nu exista nici un CIF pentru care sa aveti drept in SPV", True),
|
||||
("cum trimit declaratia D406 SAF-T", True),
|
||||
("eroare la semnatura electronica in SPV", True),
|
||||
("nu pot incarca factura in SPV, imi da eroare de certificat", True),
|
||||
("cum inregistrez o factura de achizitie in ROA", True),
|
||||
("bilant contabil", True),
|
||||
("import de date", True),
|
||||
("care e capitala Frantei", False),
|
||||
("cat costa un bilet de avion la Paris", False),
|
||||
("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False),
|
||||
("cum schimb uleiul la masina", False),
|
||||
("vremea de maine in Bucuresti", False),
|
||||
("cum imi resetez parola de la Windows", False),
|
||||
("imi da eroare 0x80070005 la instalarea unui joc pe Steam", False),
|
||||
]
|
||||
|
||||
CACHE = config.STATE_DIR / "calibrare-cache.json"
|
||||
|
||||
|
||||
def incarca_cache() -> dict[str, list[float]]:
|
||||
try:
|
||||
return json.loads(CACHE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dict]]:
|
||||
index = consumer.load_index()
|
||||
if not len(index):
|
||||
sys.exit(f"index gol sau lipsa: {config.INDEX_FILE}")
|
||||
cache = incarca_cache()
|
||||
date = []
|
||||
nou = 0
|
||||
for intrebare, asteptat in cazuri:
|
||||
vec = cache.get(intrebare)
|
||||
if vec is None:
|
||||
print(f" embed: {intrebare[:50]}…", file=sys.stderr)
|
||||
vec = consumer.embed(intrebare)
|
||||
cache[intrebare] = vec
|
||||
nou += 1
|
||||
cos = [consumer.cosine(vec, e["embedding"]) for e in index.entries]
|
||||
bm = index.bm25.scores(intrebare)
|
||||
ordine = rank.rank(cos, bm)[: config.get_int("TOP_K", 3)]
|
||||
date.append({
|
||||
"intrebare": intrebare,
|
||||
"asteptat": asteptat,
|
||||
"best": max(cos),
|
||||
"top_texts": [index.texts[i] for i in ordine],
|
||||
"top_surse": [index.sources[i] for i in ordine],
|
||||
})
|
||||
if nou:
|
||||
CACHE.write_text(json.dumps(cache), encoding="utf-8")
|
||||
print(f" ({nou} embeddings noi puse in cache)", file=sys.stderr)
|
||||
return index, date
|
||||
|
||||
|
||||
def evalueaza(index: consumer.Index, date: list[dict], strong: float, weak: float,
|
||||
min_ratio: float) -> tuple[int, list[str]]:
|
||||
corecte, gresite = 0, []
|
||||
for d in date:
|
||||
ok, _ = rank.assess(
|
||||
d["intrebare"], d["best"], d["top_texts"], index.bm25,
|
||||
strong=strong, weak=weak, min_ratio=min_ratio,
|
||||
)
|
||||
if ok == d["asteptat"]:
|
||||
corecte += 1
|
||||
else:
|
||||
gresite.append(f"{'ar fi raspuns' if ok else 'ar fi escaladat'}: {d['intrebare']}")
|
||||
return corecte, gresite
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--sweep", action="store_true", help="matura grila de praguri")
|
||||
ap.add_argument("--cases", help="JSON cu [[intrebare, true/false], ...]")
|
||||
args = ap.parse_args()
|
||||
|
||||
cazuri = CAZURI
|
||||
if args.cases:
|
||||
cazuri = [(q, bool(a)) for q, a in json.loads(pathlib.Path(args.cases).read_text())]
|
||||
|
||||
index, date = pregateste(cazuri)
|
||||
print(f"index: {len(index)} chunk-uri | cazuri: {len(date)}\n")
|
||||
|
||||
if args.sweep:
|
||||
rezultate = []
|
||||
for strong in (0.62, 0.66, 0.68, 0.70, 0.72, 0.75):
|
||||
for weak in (0.54, 0.58, 0.60):
|
||||
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
|
||||
if weak >= strong:
|
||||
continue
|
||||
corecte, _ = evalueaza(index, date, strong, weak, ratio)
|
||||
rezultate.append((corecte, strong, weak, ratio))
|
||||
rezultate.sort(key=lambda r: (-r[0], r[1]))
|
||||
print(f"{'corecte':>8} strong weak ratio")
|
||||
for corecte, strong, weak, ratio in rezultate[:15]:
|
||||
print(f"{corecte:>5}/{len(date)} {strong:.2f} {weak:.2f} {ratio:.2f}")
|
||||
return
|
||||
|
||||
strong, weak = rank.strong_cosine(), rank.weak_cosine()
|
||||
ratio = rank.min_rare_ratio()
|
||||
print(f"praguri curente: strong={strong} weak={weak} ratio={ratio}\n")
|
||||
for d in date:
|
||||
ok, motiv = rank.assess(d["intrebare"], d["best"], d["top_texts"], index.bm25)
|
||||
semn = "OK " if ok == d["asteptat"] else "GRESIT"
|
||||
stare = "ACOPERIT " if ok else "ESCALADAT"
|
||||
print(f"{semn} [{stare}] {d['intrebare'][:44]:46} {motiv}")
|
||||
corecte, gresite = evalueaza(index, date, strong, weak, ratio)
|
||||
print(f"\n{corecte}/{len(date)} corecte")
|
||||
for g in gresite:
|
||||
print(f" - {g}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,6 +21,29 @@ OCR_LANGS=ron+eng
|
||||
OCR_MAX_CHARS=1500
|
||||
OCR_TIMEOUT_S=60
|
||||
|
||||
# --- Ordonare (rank) si pragul de escaladare ---------------------------------
|
||||
# Chunk-urile se ordoneaza de doua ori — semantic (embeddings) si lexical (BM25) —
|
||||
# iar clasamentele se fuzioneaza. Vezi rag/rank.py; valorile de mai jos sunt
|
||||
# calibrate pe indexul real, nu alese din burta.
|
||||
# Peste RANK_STRONG_COSINE raspundem din documente fara alte conditii; sub
|
||||
# RANK_WEAK_COSINE escaladam direct; intre ele, raspundem doar daca termenii rari
|
||||
# din intrebare (coduri de eroare, in general) chiar apar in chunk-urile gasite.
|
||||
RANK_STRONG_COSINE=0.70
|
||||
RANK_WEAK_COSINE=0.58
|
||||
RANK_RECALL_N=12
|
||||
# Ce fractiune din termenii distinctivi ai intrebarii trebuie sa apara in
|
||||
# chunk-urile gasite, ca sa salvam un cosinus de mijloc. Un singur termen gasit
|
||||
# nu e o dovada — vezi ops/calibrate-rank.py.
|
||||
RANK_MIN_RARE_RATIO=0.5
|
||||
|
||||
# --- Escaladare la suport ----------------------------------------------------
|
||||
# Unde pleaca intrebarile la care Maria NU are raspuns in documente. Fara asta ar
|
||||
# raspunde generic, ceea ce e mai rau decat sa nu raspunda deloc.
|
||||
# JID de WhatsApp: "<numar>@s.whatsapp.net" pentru o persoana,
|
||||
# "<id>@g.us" pentru un grup. Numarul se scrie international, fara "+".
|
||||
# Gol = escaladarile se scriu doar in ~/.maria-bridge/escalations/, fara notificare.
|
||||
SUPPORT_JID=
|
||||
|
||||
# --- Backend LLM + embeddings -----------------------------------------------
|
||||
# Modelul de chat folosit pentru raspunsuri (format compatibil OpenAI
|
||||
# /v1/chat/completions). Implicit presupune un tunel/proxy local catre acelasi
|
||||
|
||||
Reference in New Issue
Block a user