diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index a5bf00f..bb3be19 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -351,6 +351,8 @@ lexicala e singura care le tine in joc. Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea decizia **„avem sau nu acoperire"** se ia separat, pe dovezi: +0. **intrebarea contine un cod de eroare** (`ORA-…`, `PLS-…`, `D406`) → decide + doar prezenta lui in chunk-uri, indiferent de cosinus; 1. cosinus ≥ `RANK_STRONG_COSINE` → raspundem; 2. cosinus < `RANK_WEAK_COSINE` → escaladam direct; 3. intre ele → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii @@ -360,6 +362,18 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente. +Pasul 0 e mai tare decat cosinusul pentru ca **doua erori Oracle diferite se scriu +aproape la fel**. O captura cu `ORA-06550 / PLS-00906` a primit cosinus 0,736 pe +chunk-ul despre `ORA-12541: TNS no listener` — peste pragul „sigur", deci Maria a +raspuns increzatoare cu alta eroare, fara sa escaladeze. Un cod e un identificator +exact: un document care nu-l pomeneste nu raspunde la el, oricat de bine ar semana +textul din jur. Codurile dau si un **al treilea clasament** (`rank.by_codes`), langa +cosinus si BM25 — la o captura de ecran, OCR-ul aduce zeci de tokeni de zgomot +(numele butoanelor din fereastra), iar BM25 singur ineca tocmai codul. + +Codul se cauta oriunde in text, nu ca token intreg: OCR-ul lipeste `[Oracle][ODBC]` +de cod si scoate `OraJORA-06550`. + ### Recalibrarea, cand se schimba documentele Pragurile sunt masurate, nu alese din burta — si se **muta** cand se schimba diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py index 1acd0e3..09ea967 100755 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py @@ -49,6 +49,12 @@ CAZURI: list[tuple[str, bool]] = [ ("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), ("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), @@ -84,11 +90,13 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic 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)] + ordine = rank.rank(cos, bm, rank.by_codes(intrebare, index.texts))[ + : config.get_int("TOP_K", 3)] date.append({ "intrebare": intrebare, "asteptat": asteptat, - "best": max(cos), + # 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], }) diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py index 8d27e6a..368d5d9 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -121,9 +121,11 @@ def search(index: Index, query: str, top_k: int) -> dict: q_vec = embed(query) cosines = [cosine(q_vec, e["embedding"]) for e in index.entries] bm_scores = index.bm25.scores(query) - order = rank.rank(cosines, bm_scores)[:top_k] + order = rank.rank(cosines, bm_scores, rank.by_codes(query, index.texts))[:top_k] texts = [index.texts[i] for i in order] - best = max(cosines) + # cosinusul chunk-urilor CHIAR date modelului, nu cel mai bun din tot indexul: + # altfel acoperirea se judeca pe o dovada care nu ajunge in context. + best = max((cosines[i] for i in order), default=0.0) covered, reason = rank.assess(query, best, texts, index.bm25) return { "chunks": texts, diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py index 5ea585a..1efffe5 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py @@ -14,6 +14,11 @@ 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. +Codurile merg mai departe de atat: ele dau un al treilea clasament (`by_codes`) si +sunt singurul criteriu de acoperire cand intrebarea contine unul (`assess`). Un cod +e un identificator exact — un document care nu-l pomeneste nu raspunde la el, oricat +de bine ar semana textul din jur. + 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 @@ -71,6 +76,44 @@ _STOP = { # 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,}$") +# Acelasi lucru, cautat ORIUNDE in text, nu doar ca token intreg. OCR-ul lipeste +# codul de ce e langa el ("[Oracle][ODBC][OraJORA-06550" -> un singur token +# "orajora-06550"), iar codul e exact bucata care nu are voie sa se piarda. +_COD_IN_TEXT = re.compile( + r"(?:ora|pls|tns|sp2|imp|exp)-\d+|(? list[str]: + """Codurile de eroare din text, in ordinea aparitiei, fara duplicate. + + Un cod e un identificator exact, nu un cuvant: `ORA-06550` si `ORA-12541` + sunt doua erori fara nimic in comun, desi textele din jurul lor se aseamana + pana la confuzie pentru o cautare semantica. + """ + return list(dict.fromkeys(_COD_IN_TEXT.findall(fold(text)))) + + +def by_codes(query: str, texts: list[str]) -> list[int]: + """Chunk-urile care contin codurile din intrebare, cele cu mai multe intai. + + Al treilea clasament, langa cosinus si BM25. BM25 stie de coduri, dar le + ineaca: o captura de ecran aduce prin OCR zeci de tokeni („oracle", „odbc", + „line", „column", numele butoanelor din fereastra), iar chunk-urile care se + potrivesc pe zgomotul ala pot trece inaintea celui care poarta chiar codul. + """ + cerute = codes(query) + if not cerute: + return [] + gasite: list[tuple[int, int]] = [] + for i, text in enumerate(texts): + ale_lui = set(codes(text)) + n = sum(1 for c in cerute if c in ale_lui) + if n: + gasite.append((n, i)) + gasite.sort(key=lambda g: -g[0]) + return [i for _, i in gasite] + def fold(text: str) -> str: """Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token.""" @@ -154,16 +197,20 @@ def rrf(rankings: list[list[int]], k: int = RRF_K) -> dict[int, float]: def rank( cosines: list[float], bm25_scores: list[float], + code_order: list[int] | None = None, recall: int | None = None, ) -> list[int]: - """Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea celor doua clasamente.""" + """Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea clasamentelor.""" 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]) + rankings = [by_cos, by_bm] + if code_order: + rankings.append(code_order[:recall]) + fused = rrf(rankings) return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True) @@ -179,15 +226,32 @@ def assess( ) -> tuple[bool, str]: """(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada. - Trei cazuri, in ordine: + Cazuri, in ordine: + 0. intrebarea contine coduri de eroare -> decide DOAR prezenta lor in + chunk-uri, si cand cosinusul e mare (vezi mai jos); 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. + + Cazul 0 exista fiindca cosinusul mare NU e o dovada cand e vorba de coduri: + doua erori Oracle diferite se scriu aproape la fel („[Oracle][ODBC][Ora]ORA-…"), + deci un chunk despre ORA-12541 sta la 0,74 de o intrebare despre ORA-06550. + Fara asta, Maria raspundea sigur pe ea cu eroarea gresita, in loc sa escaladeze. """ 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 + cerute = codes(query) + if cerute: + blob_cod = set(codes("\n".join(top_texts))) + gasite = [c for c in cerute if c in blob_cod] + if gasite: + return True, f"cod exact in documente: {', '.join(gasite)}" + return False, ( + f"intrebarea are coduri ({', '.join(cerute[:4])}) care nu apar in " + f"documente (cosinus {best_cosine:.3f})" + ) if best_cosine >= strong: return True, f"cosinus {best_cosine:.3f} >= {strong}" if best_cosine < weak: diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_rank.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_rank.py index 8d4f9d0..9a7f333 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_rank.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_rank.py @@ -100,10 +100,11 @@ def test_assess_un_singur_termen_din_multi_nu_e_dovada(): def test_assess_motivul_numeste_termenii_lipsa_nu_pe_toti(): + # Fara cod in intrebare: acolo decid termenii rari, nu regula codurilor. bm = rank.Bm25(DOCS) - _, motiv = rank.assess("ORA-01722 la imprimanta LaserJet", 0.61, DOCS[:1], bm) - # codul chiar apare in context, deci nu are ce cauta in lista lipsurilor - assert "ora-01722" not in motiv + _, motiv = rank.assess("factura la imprimanta LaserJet", 0.61, DOCS[3:], bm) + # „factura" chiar apare in context, deci nu are ce cauta in lista lipsurilor + assert "factura," not in motiv assert "imprimanta" in motiv @@ -120,3 +121,47 @@ def test_pragurile_vin_din_env(monkeypatch): monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50") covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm) assert covered + + +# --- coduri de eroare: identificatori exacti, nu cuvinte --------------------- + +def test_codul_lipit_de_ocr_e_tot_gasit(): + """OCR-ul da „[Oracle][ODBC][OraJORA-06550"; codul nu are voie sa se piarda.""" + import rank + assert rank.codes("[Oracle][ODBC][OraJORA-06550: line 1, column 7") == ["ora-06550"] + assert rank.codes("PLS-00906: object X is invalid") == ["pls-00906"] + assert rank.codes("cum trimit D406") == ["d406"] + assert rank.codes("nu merge nimic") == [] + + +def test_chunkul_cu_codul_cerut_urca_in_clasament(): + import rank + texts = [ + "ORA-12541 TNS no listener, serverul nu raspunde", + "ORA-03113 end-of-file on communication channel", + "ORA-06550 eroare de compilare, procedura lipseste", + ] + assert rank.by_codes("imi da ORA-06550 la salvare", texts) == [2] + assert rank.by_codes("intrebare fara coduri", texts) == [] + + +def test_cosinus_mare_nu_acopera_alt_cod(monkeypatch): + """Doua erori Oracle diferite se scriu la fel; cosinusul nu le deosebeste. + + Cazul real din 2026-08-31: captura cu ORA-06550 primea raspunsul despre + ORA-12541, cu cosinus 0,736 — peste pragul „sigur". + """ + import rank + bm = rank.Bm25(["ORA-12541 TNS no listener"]) + ok, motiv = rank.assess("ORA-06550 PLS-00906 la salvare", 0.736, + ["ORA-12541 TNS no listener"], bm) + assert ok is False + assert "ora-06550" in motiv + + +def test_codul_gasit_acopera_si_la_cosinus_mic(): + import rank + bm = rank.Bm25(["ORA-06550 eroare de compilare"]) + ok, motiv = rank.assess("ORA-06550", 0.42, ["ORA-06550 eroare de compilare"], bm) + assert ok is True + assert "ora-06550" in motiv