Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py
Claude Agent 47110d71bf fix(maria): mesajul si captura sunt acelasi lucru, iar cosinusul singur nu mai e dovada
Doua greseli din grupul "Maria Test", 2026-09-01, pe care 27409fb le-a atenuat dar
nu le-a rezolvat.

1. La o reclamatie vaga Maria cauta, in loc sa intrebe. "Buna . Am si eu o factura
   pe luna august cu eroare in spv la trimitere AUTO SULE" nu spune CE eroare e —
   n-are ce cauta in documente si n-are ce trimite la suport, fiindca programatorul
   ar pune exact aceeasi intrebare. Gardul (triaj.prea_vag) exista, dar cerea text
   sub 12 cuvinte; mesajul are 14. Gresea si invers: "nu pot incarca factura in SPV,
   imi da eroare de certificat" are 9 cuvinte si ARE raspuns in documente, si era
   oprita degeaba. Lungimea nu masoara cat de precis e mesajul.
   -> gardul nu mai numara cuvinte si se aplica DUPA cautare, doar cand nu exista
      acoperire. O singura data pe fir: daca nici intrebat omul nu spune mai mult,
      mesajul pleaca la suport.

2. Textul si captura, trimise una dupa alta, erau tratate ca doua probleme fara
   legatura. `este_continuare` rupea firul la ORICE imagine — dar cazul frecvent e
   tocmai omul care scrie problema si trimite captura imediat dupa, sau care
   raspunde la "trimite-mi o captura". Textul se pierdea, captura se cauta doar pe
   OCR-ul ei, se deschideau doua fire si se puteau deschide doua escaladari pentru
   aceeasi problema.
   -> o captura in primele FIR_IMAGINE_MIN (5) minute continua firul: textul citit
      din ea intra in ancora (cu tot cu coduri), cautarea se face pe mesaj +
      captura, iar pe o escaladare deschisa pleaca la aceeasi referinta — cu
      imaginea, nu doar cu textul citit din ea (_notifica_suport ia si media).

Si, ca urmare a lui (2): RANK_STRONG_COSINE dispare. Interogarea pe un fir e ancora
plus mesajul nou, deci cosinusul urca la fiecare replica fara sa apara vreo dovada
noua — aceeasi captura da 0,778 singura si 0,836 cu mesajul de dinainte, adica peste
0,80 pus ieri. Orice prag fix de sus e trecut de o discutie destul de lunga. Acoperirea
ramane pe dovada lexicala (termenii distinctivi ai intrebarii chiar in chunk-uri),
care e stabila la lungime. Niciun caz cu raspuns in documente nu avea nevoie de
scurtatura.

ops/calibrate-rank.py: 26/26, cu interogarea combinata adaugata la set. Teste: 102 pass.
Verificat end-to-end pe scenariul real (mesaj, apoi captura la 10 secunde): intrebare
de detalii, apoi o singura escaladare care poarta si textul si captura.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-09-01 19:44:24 +00:00

182 lines
7.7 KiB
Python
Executable File

#!/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),
# dupa adaugarea dictionarului de erori Oracle (oracle-erori-uzuale.xml)
("ORA-01722 invalid number la salvarea facturii", True),
("imi da ORA-12154 cand deschid programul", True),
("ORA-00001 unique constraint violated", True),
("eroare ORA-28000 the account is locked", True),
# captura reala (OCR), 2026-08-31: raspundea cu ORA-03113/ORA-12541, alta eroare
("[Oracle][ODBC][OraJORA-06550: line 1, column 7\n"
"PLS-00906: object VADECO PACK PARTENERI is invalid\n"
"ORA-06550: line 1, column 7:", True),
# cod Oracle real, dar care NU e in dictionar: trebuie sa plece la suport
("ORA-00600 internal error code arguments", False),
# continuari pe un fir deschis: asa arata interogarea dupa fir.interogare()
# — ancora plus raspunsul scurt al omului. Fara ancora, „da, ma blocheaza"
# nimerea ordinul de plata la Trezorerie, cu cosinus 0,642.
("ORA-06550: line 1, column 7 PLS-00906 object invalid\nda, ma blocheaza complet", True),
("ORA-12154 TNS could not resolve the connect identifier\n"
"eram la salvarea unei facturi", True),
# 2026-09-01, grup „Maria Test": ambele au primit raspunsuri inventate.
# Prima e o reclamatie vaga (nu spune CE eroare), a doua e o eroare eFactura
# care nu exista in documente — dar toate chunk-urile eFactura ii seamana.
("Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE", False),
("O) Pers. Juridice (798) O Netrimise DI Trimise (fara raspuns) [5 Timis (cu raspuns Erori) (1). "
"O Tamise (cu diferente Reg. V2.) (1)\nMesaj raspuns. | Factura cu id incarcare=6638295792 nu po] "
"| | <Error errorMessage=\"CUI cumparator incorect\"/>", False),
# aceeasi captura, dar cautata IMPREUNA cu mesajul care a precedat-o (fir.interogare):
# textul in plus urca cosinusul la 0,836 fara sa aduca vreo dovada noua.
("Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE\n"
"Mesaj raspuns. | Factura cu id incarcare=6638295792 nu po] "
"| | <Error errorMessage=\"CUI cumparator incorect\"/>", False),
("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, rank.by_codes(intrebare, index.texts))[
: config.get_int("TOP_K", 3)]
date.append({
"intrebare": intrebare,
"asteptat": asteptat,
# ca in consumer.search: cosinusul chunk-urilor date modelului
"best": max((cos[i] for i in ordine), default=0.0),
"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], 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,
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 weak in (0.50, 0.54, 0.58, 0.60, 0.64):
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
corecte, _ = evalueaza(index, date, weak, ratio)
rezultate.append((corecte, weak, ratio))
rezultate.sort(key=lambda r: (-r[0], r[1]))
print(f"{'corecte':>8} weak ratio")
for corecte, weak, ratio in rezultate[:15]:
print(f"{corecte:>5}/{len(date)} {weak:.2f} {ratio:.2f}")
return
weak, ratio = rank.weak_cosine(), rank.min_rare_ratio()
print(f"praguri curente: 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, weak, ratio)
print(f"\n{corecte}/{len(date)} corecte")
for g in gresite:
print(f" - {g}")
if __name__ == "__main__":
main()