diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index 88f11ef..5bc8632 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -358,6 +358,22 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in chunk-urile gasite. +Termenii se compara **pe primele 5 litere**, nu pe cuvantul intreg (codurile fac +exceptie: `ORA-01722` si `ORA-01720` incep la fel). Documentatia si clientul nu +folosesc aceleasi forme — documentul scrie „token" si „tokenuri", omul scrie +„tokenul"; documentul „Generare", omul „generez". Comparate intregi, exact +cuvintele care conteaza nu se gaseau: pe setul de calibrare, trei continuari +legitime escaladau desi chunk-ul cu raspunsul era chiar in context. + +**Pe un fir de discutie, interogarea are doua roluri diferite.** Regasirea se face +pe ancora plus mesajul nou — ancora chiar ajuta, „da, ma blocheaza" singur nu +gaseste nimic. Acoperirea se judeca insa pe **mesajul nou** (`dovada`, in +`rank.assess`): altfel raspunde la ce a intrebat omul acum SI la formulele si +numele proprii din primul mesaj („buna", „august", „AUTO SULEA" nu apar in niciun +document), iar raportul scade la fiecare replica — firul lung escaladeaza degeaba. +Cand mesajul nou n-are termeni distinctivi proprii, se cade inapoi pe interogarea +intreaga, ca inainte. + Fractiunea din pasul 2 nu e cosmetica: cu un singur termen gasit, „cum imi resetez parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente. @@ -373,7 +389,7 @@ L-am scos, nu urcat, fiindca **cosinusul creste cu lungimea interogarii**: pe un de discutie se cauta dupa ancora plus mesajul nou, deci scorul urca la fiecare replica fara sa apara vreo dovada in plus. Aceeasi captura: 0,778 singura, 0,836 impreuna cu mesajul de dinainte. Orice prag fix de sus e trecut de o discutie destul -de lunga. `ops/calibrate-rank.py`: 26/26, si niciunul dintre cazurile cu raspuns in +de lunga. `ops/calibrate-rank.py`: 31/31, si niciunul dintre cazurile cu raspuns in documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala. Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde 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 3feecc4..5481238 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 @@ -24,6 +24,7 @@ siguranta: daca un prag nou strica un caz care mergea, se vede imediat. from __future__ import annotations import argparse +import inspect import json import pathlib import sys @@ -34,8 +35,10 @@ import config # noqa: E402 import consumer # noqa: E402 import rank # noqa: E402 -# (intrebare, are raspuns in documente?) -CAZURI: list[tuple[str, bool]] = [ +# (intrebare, are raspuns in documente?) sau, pentru continuari pe un fir, +# (ancora, mesaj_nou, are raspuns in documente?): regasirea se face pe ancora plus +# mesajul nou (exact ce produce fir.interogare), dovada doar pe mesajul nou. +CAZURI: list[tuple] = [ ("token efactura expirat", True), ("Nu exista nici un CIF pentru care sa aveti drept in SPV", True), ("cum trimit declaratia D406 SAF-T", True), @@ -80,6 +83,31 @@ CAZURI: list[tuple[str, bool]] = [ ("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), + # --- continuari pe fir, fara cod de eroare: aici decid termenii distinctivi --- + # Ancora politicoasa aduce in interogare „buna/ziua/puteti/ajutati", cuvinte care + # nu apar niciodata in documente. Raspunsul (token expirat) e in + # efactura_knowledge.md:118-134 (TOPIC token_anaf, expirare la 90 de zile). + ("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?", + "imi zice ca tokenul e expirat", True), + # Aceeasi continuare, ancora fara formule de politete: verdictul trebuie sa fie + # acelasi. Daca difera, politetea din ancora e cea care escaladeaza. + ("Am o problema la trimiterea in SPV", + "imi zice ca tokenul e expirat", True), + # Aceeasi ancora politicoasa, dar raspunsul NU e in documente: „CUI cumparator + # incorect" nu apare in niciun document (grep peste documents/, 2026-09-01). + ("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?", + "imi da eroare CUI cumparator incorect la validare", False), + # Fir lung: a patra replica, ancora crescuta cu schimburile de dinainte. + # Verdictul nu are voie sa se degradeze cu lungimea (cosinusul urca oricum). + ("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?\n" + "imi zice ca tokenul e expirat\n" + "am apasat Actualizare in borderoul eFactura dar tot nu merge", + "unde apas ca sa generez tokenul nou?", True), + # Continuare pe un fir deschis de o captura (escaladarea M-260901-06EB): ancora + # e mesajul omului plus OCR-ul, plin de zgomot de interfata. Eroarea din captura + # nu e in documente, deci si continuarea trebuie sa plece la suport. + ("Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULEA MOBILE SRL . Poti te rog sa ma ajuti?\nTotal\nLit\nO) Pers. Juridice (798) O Netrimise DI Trimise (fara raspuns) [5 Timis (cu raspuns Erori) (1). O Tamise (cu diferente Reg. V2.) (1)\nNr Articol Cantitate UM | Pret unitar Valoare fara TVA Discount fara TVA % TVA Descriere Detalii\n5 Detalii raspuns\n663| | <2xml version=\"1.0” encoding=\"UTF-8\" standalone=\"yes\"?>\n
\nTri\nMesaj trimitere | Transmis cu succes. Index incarcare\n1670404158\" Cif_emitent=\"\n3137200\">\n@ inclusiv Pers. Fizice\nSelecteaz!", + "si acum ce fac cu factura asta?", False), ] CACHE = config.STATE_DIR / "calibrare-cache.json" @@ -92,14 +120,27 @@ def incarca_cache() -> dict[str, list[float]]: return {} -def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dict]]: +def desfa(caz: tuple) -> tuple[str, str | None, bool]: + """(interogare de regasire, interogare de dovada, asteptat). + + Un 3-tuplu e o continuare pe fir: se cauta dupa ancora plus mesajul nou (ca in + fir.interogare), dar dovada e doar ce a intrebat omul acum. + """ + if len(caz) == 3: + ancora, mesaj, asteptat = caz + return f"{ancora}\n{mesaj}", mesaj, asteptat + return caz[0], None, caz[1] + + +def pregateste(cazuri: list[tuple]) -> 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: + for caz in cazuri: + intrebare, dovada, asteptat = desfa(caz) vec = cache.get(intrebare) if vec is None: print(f" embed: {intrebare[:50]}…", file=sys.stderr) @@ -112,6 +153,7 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic : config.get_int("TOP_K", 3)] date.append({ "intrebare": intrebare, + "dovada": dovada, "asteptat": asteptat, # ca in consumer.search: cosinusul chunk-urilor date modelului "best": max((cos[i] for i in ordine), default=0.0), @@ -124,13 +166,19 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic return index, date +# rank.assess primeste `dovada` doar dupa reparatia termenilor distinctivi; pana +# atunci se poate rula numai varianta de azi. +ARE_DOVADA = "dovada" in inspect.signature(rank.assess).parameters + + def evalueaza(index: consumer.Index, date: list[dict], weak: float, - min_ratio: float) -> tuple[int, list[str]]: + min_ratio: float, dovada_separata: bool = False) -> tuple[int, list[str]]: corecte, gresite = 0, [] for d in date: + extra = {"dovada": d["dovada"]} if dovada_separata else {} ok, _ = rank.assess( d["intrebare"], d["best"], d["top_texts"], index.bm25, - weak=weak, min_ratio=min_ratio, + weak=weak, min_ratio=min_ratio, **extra, ) if ok == d["asteptat"]: corecte += 1 @@ -147,7 +195,7 @@ def main() -> None: cazuri = CAZURI if args.cases: - cazuri = [(q, bool(a)) for q, a in json.loads(pathlib.Path(args.cases).read_text())] + cazuri = [tuple(c) for c in json.loads(pathlib.Path(args.cases).read_text())] index, date = pregateste(cazuri) print(f"index: {len(index)} chunk-uri | cazuri: {len(date)}\n") @@ -156,9 +204,11 @@ def main() -> None: 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) + corecte, _ = evalueaza(index, date, weak, ratio, ARE_DOVADA) rezultate.append((corecte, weak, ratio)) rezultate.sort(key=lambda r: (-r[0], r[1])) + mod = "cu dovada separata" if ARE_DOVADA else "fara separare (rank.assess nu are inca `dovada`)" + print(f"mod: {mod}\n") print(f"{'corecte':>8} weak ratio") for corecte, weak, ratio in rezultate[:15]: print(f"{corecte:>5}/{len(date)} {weak:.2f} {ratio:.2f}") @@ -171,10 +221,14 @@ def main() -> None: 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}") + for eticheta, separata in (("fara separare (azi)", False), ("cu dovada separata ", True)): + if separata and not ARE_DOVADA: + print("\ncu dovada separata : rank.assess nu accepta inca `dovada`, sarit") + continue + corecte, gresite = evalueaza(index, date, weak, ratio, separata) + print(f"\n{eticheta}: {corecte}/{len(date)} corecte") + for g in gresite: + print(f" - {g}") if __name__ == "__main__": 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 cb054b7..3f01c04 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -128,8 +128,13 @@ def cosine(a: list[float], b: list[float]) -> float: return dot / (na * nb) if na and nb else 0.0 -def search(index: Index, query: str, top_k: int) -> dict: - """Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log.""" +def search(index: Index, query: str, top_k: int, dovada: str | None = None) -> dict: + """Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log. + + `query` regaseste, `dovada` judeca. Pe un fir, `query` e ancora plus mesajul + nou (ancora chiar ajuta regasirea), dar acoperirea nu se judeca pe ea — vezi + `rank.assess`. + """ if not len(index): return {"chunks": [], "covered": False, "reason": "index gol", "best_cosine": 0.0, "top": []} @@ -141,7 +146,7 @@ def search(index: Index, query: str, top_k: int) -> dict: # 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) + covered, reason = rank.assess(query, best, texts, index.bm25, dovada=dovada) return { "chunks": texts, "covered": covered, @@ -558,6 +563,7 @@ def handle(index: Index, msg: dict) -> None: if not question: return + dovada = None if continuare: fir_mod.adauga(fir, "client", text or "[captura de ecran]") fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "") @@ -571,6 +577,9 @@ def handle(index: Index, msg: dict) -> None: send_reply(sender, raspuns) return fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal + # ce a intrebat omul ACUM (mesajul nou, sau textul retinut din captura lui): + # acoperirea se judeca pe asta, nu pe ancora care ramane in interogare. + dovada = search_query search_query = fir_mod.interogare(fir, search_query) print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}", file=sys.stderr) @@ -579,7 +588,7 @@ def handle(index: Index, msg: dict) -> None: fir_mod.adauga(fir, "client", text or "[captura de ecran]") try: - result = search(index, search_query, config.get_int("TOP_K", 3)) + result = search(index, search_query, config.get_int("TOP_K", 3), dovada) except Exception as exc: # noqa: BLE001 print(f"[consumer] cautare esuata: {exc}", file=sys.stderr) send_reply(sender, "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta.") diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py index f38c6b4..29572ba 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py @@ -17,6 +17,11 @@ escaladarii deschise si ultimele schimburi. Ancora intra in interogarea de cauta la fiecare continuare — asa „da, ma blocheaza" se cauta impreuna cu eroarea, nu singur. +Ancora ajuta REGASIREA, nu acoperirea. Decizia „raspund sau escaladez" se ia pe +mesajul nou (`rank.assess(..., dovada=...)`), fiindca ancora poarta si salutul, si +numele firmei, si luna — cuvinte care nu apar in niciun document si care trageau +raportul de termeni distinctivi in jos la fiecare replica. + Ce rupe firul, necontestat: o captura noua sau un cod de eroare diferit. Sunt singurele semne sigure ca omul a trecut la altceva; restul (schimbarea subiectului in cuvinte) e prea usor de confundat cu o continuare, iar greseala aia produce 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 ea69854..44ba156 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py @@ -209,6 +209,31 @@ def rank( return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True) +RADACINA = 5 # cate litere din inceputul unui cuvant se compara cu documentele + + +def _apare(term: str, blob: str) -> bool: + """Termenul e in text — si la alta forma gramaticala decat a scris-o omul. + + Romana articuleaza si conjuga, documentatia si clientul nu folosesc aceleasi + forme: documentul scrie „token" si „tokenuri", omul scrie „tokenul"; documentul + „Generare", omul „generez"; documentul „trimise", omul „trimiterea". Comparat + pe cuvantul intreg, exact cuvantul care conteaza nu se gaseste — masurat pe + setul de calibrare (2026-09-01), trei continuari legitime din cinci escaladau + numai din cauza asta, desi chunk-ul cu raspunsul era chiar in context. + + Codurile se compara intregi: `ora-01722` si `ora-01720` au aceleasi 5 litere + de la inceput si n-au nimic in comun. + + # ponytail: prefix fix, nu stemmer. Poate potrivi gresit cuvinte scurte cu + # aceeasi radacina („capitala" ~ „capitaluri"); daca apare in practica, aici + # se pune un stemmer romanesc, nu un prag mai mare. + """ + if _COD.match(term): + return term in blob + return term[:RADACINA] in blob + + def assess( query: str, best_cosine: float, @@ -217,6 +242,7 @@ def assess( *, weak: float | None = None, min_ratio: float | None = None, + dovada: str | None = None, ) -> tuple[bool, str]: """(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada. @@ -232,6 +258,19 @@ def assess( 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. + `dovada` desparte cele doua roluri ale interogarii pe un fir de discutie. + Regasirea are nevoie de ancora („da, ma blocheaza" singur nu gaseste nimic), + dar acoperirea nu: judecata pe ancora + mesaj nou, ea raspunde la ce a intrebat + omul acum SI la formulele si numele proprii din primul mesaj — „buna", „august", + „AUTO SULEA" nu apar in niciun document, deci raportul scade la fiecare replica. + Cand `dovada` (mesajul nou, plus OCR-ul capturii lui) are termeni distinctivi + proprii, acoperirea se judeca pe ei. Cand n-are, se cade inapoi pe interogarea + intreaga — comportamentul de dinainte; altfel o continuare fara niciun cuvant + al ei ar escalada. + + Codurile (cazul 0) raman pe interogarea intreaga: codul din ancora e chiar + subiectul firului, iar continuarea nu-l repeta. + A existat si un prag „cosinus mare -> raspundem, indiferent de lexical" (`RANK_STRONG_COSINE`, 0,70). L-am scos, nu urcat, si nu doar fiindca a lasat sa treaca doua raspunsuri inventate pe 2026-09-01: **cosinusul creste cu @@ -256,17 +295,19 @@ def assess( ) if best_cosine < weak: return False, f"cosinus {best_cosine:.3f} < {weak}" - rare = bm25.rare_terms(query) + rare = bm25.rare_terms(dovada) if dovada else [] + if not rare: + rare = bm25.rare_terms(query) if not rare: return False, f"cosinus {best_cosine:.3f}, iar intrebarea nu are termeni distinctivi" blob = fold("\n".join(top_texts)) - gasiti = [t for t in rare if t in blob] + gasiti = [t for t in rare if _apare(t, 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] + lipsa = [t for t in rare if not _apare(t, blob)] return False, ( f"cosinus {best_cosine:.3f}, iar termenii distinctivi " f"({', '.join(lipsa[:5])}) nu apar in documente" 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 9f523aa..933cad5 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 @@ -45,6 +45,45 @@ def test_rare_terms_prinde_codurile_si_ignora_cuvintele_comune(): assert "ora-01722" in rare +def test_assess_gaseste_termenul_la_alta_forma_gramaticala(): + # Documentul scrie „token"/„tokenuri", omul scrie „tokenul". Comparat pe cuvantul + # intreg, singurul cuvant care conteaza nu se gaseste si intrebarea escaladeaza. + bm = rank.Bm25(DOCS) + covered, motiv = rank.assess("imi zice ca tokenul a expirat", 0.64, DOCS[1:2], bm) + assert covered + assert "tokenul" in motiv + + +def test_assess_codurile_se_compara_intregi_nu_pe_radacina(): + # „ora-01722" si „ora-01720" au aceleasi cinci litere de la inceput. + bm = rank.Bm25(DOCS) + covered, _ = rank.assess("eroarea ORA-01720", 0.64, DOCS[:1], bm) + assert not covered + + +def test_assess_dovada_judeca_mesajul_nou_nu_ancora(): + # Continuare pe un fir: ancora e salutul plus reclamatia vaga, mesajul nou e + # chiar intrebarea. Fara dovada separata, „buna/august" (care nu apar nicaieri) + # trag raportul sub prag si escaladeaza degeaba. + bm = rank.Bm25(DOCS) + ancora = "Buna ziua . Am o problema pe luna august la firma AUTO SULEA . Ma ajutati?" + query = f"{ancora}\ntokenul a expirat" + fara, _ = rank.assess(query, 0.64, DOCS[1:2], bm) + cu, motiv = rank.assess(query, 0.64, DOCS[1:2], bm, dovada="tokenul a expirat") + assert not fara + assert cu and "expirat" in motiv + + +def test_assess_continuare_scurta_cade_inapoi_pe_interogarea_intreaga(): + # „da, ma blocheaza complet" n-are termeni distinctivi proprii. Judecata singura + # ar escalada orice continuare scurta — exact regresia pentru care exista ancora. + bm = rank.Bm25(DOCS) + query = "token efactura expirat\nda" + covered, _ = rank.assess(query, 0.64, DOCS[1:2], bm, dovada="da") + fara, _ = rank.assess(query, 0.64, DOCS[1:2], bm) + assert covered is fara is True + + def test_rank_urca_chunkul_confirmat_de_ambele_metode(): # cosinusul favorizeaza documentul 3, dar lexical se potriveste documentul 0; # documentul 0 e sustinut de amandoua (cosinus decent + BM25 mare) si iese primul