diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index 37dd625..88f11ef 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -353,24 +353,28 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci 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 +1. cosinus < `RANK_WEAK_COSINE` → escaladam direct; +2. peste → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in chunk-urile gasite. -Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez +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. -`RANK_STRONG_COSINE` a fost 0,70 si a lasat sa treaca doua raspunsuri inventate +**Nu exista prag „cosinus destul de mare ca sa nu mai verificam".** A existat +(`RANK_STRONG_COSINE`, 0,70) si a lasat sa treaca doua raspunsuri inventate (2026-09-01, grupul „Maria Test"): „am o factura pe luna august cu eroare in SPV" (0,768 — nu spune ce eroare e) si o captura cu `errorMessage="CUI cumparator -incorect"` (0,778 — eroarea nu exista in documente). **Toate chunk-urile eFactura -seamana intre ele**, deci pe 0,7x cosinusul nu mai distinge „e in documente" de „e -despre eFactura"; e acelasi tip de greseala ca la codurile Oracle de mai sus, doar -ca fara cod pe care sa te sprijini. La **0,80** trec doar potrivirile aproape -textuale, iar restul cad pe dovada lexicala de la pasul 3. `ops/calibrate-rank.py`: -25/25, cu ambele cazuri adaugate la set. +incorect"` (0,778 — eroarea nu exista in documente). Toate chunk-urile eFactura +seamana intre ele, deci pe 0,7x cosinusul nu mai distinge „e in documente" de „e +despre eFactura". + +L-am scos, nu urcat, fiindca **cosinusul creste cu lungimea interogarii**: pe un fir +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 +documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala. Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde cu 0,8 — creativitate exact acolo unde vrem sa se rezume la context. @@ -393,9 +397,18 @@ Nu orice mesaj e o intrebare, si nu orice raspuns e util. `rag/triaj.py` ia trei decizii inaintea modelului: **Mesaj prea vag.** „Am o eroare" nu spune nimic. Maria cere operatiunea, ecranul si -textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat. Regula -e ingusta deliberat: doar text scurt, fara captura si fara cod de eroare, in care apar -cuvinte de acuza („nu merge", „imi da"). „Cum trimit D406" trece nestingherit. +textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat, fiindca +programatorul ar pune exact aceeasi intrebare. + +Decizia se ia **dupa cautare, si numai cand nu exista acoperire in documente**. Inainte +se lua inaintea cautarii, dupa lungime: text scurt (sub 12 cuvinte), fara captura si +fara cod, cu cuvinte de acuza („nu merge", „imi da"). Gardul gresea in ambele sensuri +— „Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE" +are 14 cuvinte si tot nu zice CE eroare e (a trecut, si a primit un raspuns inventat), +iar „nu pot incarca factura in SPV, imi da eroare de certificat" are 9 si e in +documente (era oprita degeaba). Lungimea nu masoara cat de precis e mesajul; +acoperirea in documente da. Se cere o singura data pe fir: daca nici dupa ce a fost +intrebat omul nu spune mai mult, mesajul pleaca la suport. **Erori care oricum ajung la programatori.** Dictionarul de erori Oracle are pe fiecare intrare campul `cand suni suportul`. Noua incep cu „Intotdeauna" sau „Imediat" — nu se @@ -437,10 +450,20 @@ La o continuare: cautarea se face pe **ancora + mesajul nou**, modelul primeste istoricul, mesajul se adauga la escaladarea deschisa (`completari`, de cate ori e nevoie), iar „mesaj prea vag" nu se mai aplica — detaliile au fost deja cerute. -**Ce rupe firul, necontestat:** o captura noua sau un cod de eroare diferit. Sunt -singurele semne sigure ca omul a trecut la altceva. Schimbarea subiectului in cuvinte -NU rupe firul: e prea usor de confundat cu o continuare, iar greseala aia produce -exact tabelul de mai sus. In rest, firul expira dupa `FIR_TTL_MIN` (120 min). +**Ce rupe firul:** un cod de eroare pe care firul nu-l are, sau o captura venita la +mai mult de `FIR_IMAGINE_MIN` (5 min) dupa ultimul mesaj. Schimbarea subiectului in +cuvinte NU rupe firul: e prea usor de confundat cu o continuare, iar greseala aia +produce exact tabelul de mai sus. In rest, firul expira dupa `FIR_TTL_MIN` (120 min). + +Orice captura a rupt firul pana pe 2026-09-01, si era gresit exact in cazul cel mai +frecvent: **omul scrie problema si trimite captura imediat dupa** (sau Maria tocmai +i-a cerut-o). WhatsApp le livreaza ca doua mesaje, dar e un singur lucru. Tratate +separat, textul se pierdea si captura se cauta doar pe OCR-ul ei; se deschideau doua +fire, se puteau deschide doua escaladari pentru aceeasi problema, iar suportul primea +doua mesaje fara legatura. Acum captura din fereastra de 5 minute continua firul: +textul citit din ea intra in ancora (cu tot cu codurile lui), cautarea se face pe +mesaj + captura, iar daca firul are deja o escaladare deschisa captura pleaca la +aceeasi referinta — cu imaginea, nu doar cu textul citit din ea. ### Completarile: trei feluri, trei raspunsuri 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 c581da5..3feecc4 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 @@ -68,6 +68,11 @@ CAZURI: list[tuple[str, bool]] = [ ("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] " "| | ", 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] " + "| | ", 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), @@ -119,13 +124,13 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic return index, date -def evalueaza(index: consumer.Index, date: list[dict], strong: float, weak: float, +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, - strong=strong, weak=weak, min_ratio=min_ratio, + weak=weak, min_ratio=min_ratio, ) if ok == d["asteptat"]: corecte += 1 @@ -149,28 +154,24 @@ def main() -> None: 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)) + 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} strong weak ratio") - for corecte, strong, weak, ratio in rezultate[:15]: - print(f"{corecte:>5}/{len(date)} {strong:.2f} {weak:.2f} {ratio:.2f}") + print(f"{'corecte':>8} weak ratio") + for corecte, weak, ratio in rezultate[:15]: + print(f"{corecte:>5}/{len(date)} {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") + 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, strong, weak, ratio) + corecte, gresite = evalueaza(index, date, weak, ratio) print(f"\n{corecte}/{len(date)} corecte") for g in gresite: print(f" - {g}") diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example index 6f959af..a36a33d 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example @@ -32,10 +32,9 @@ OCR_TIMEOUT_S=60 # 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 +# Sub RANK_WEAK_COSINE escaladam direct; peste, raspundem doar daca termenii rari # din intrebare (coduri de eroare, in general) chiar apar in chunk-urile gasite. -RANK_STRONG_COSINE=0.80 +# Nu exista prag „cosinus destul de mare ca sa nu mai verificam" — vezi rag/rank.py. RANK_WEAK_COSINE=0.58 RANK_RECALL_N=12 # Ce fractiune din termenii distinctivi ai intrebarii trebuie sa apara in diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py index 92ca654..a5299b4 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -57,7 +57,6 @@ DEFAULTS: dict[str, str] = { "OCR_TIMEOUT_S": "60", # Ordonarea chunk-urilor si pragul de la care se considera ca raspunsul chiar # e in documente (vezi rag/rank.py pentru masuratoarea din spatele valorilor). - "RANK_STRONG_COSINE": "0.80", "RANK_WEAK_COSINE": "0.58", "RANK_RECALL_N": "12", "RANK_MIN_RARE_RATIO": "0.5", 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 473b0d6..861d696 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -357,12 +357,16 @@ def _fisier_escaladare(ref: str): return next(iter(sorted(d.glob(f"*-{ref}.json"))), None) -def _notifica_suport(text: str) -> bool: +def _notifica_suport(text: str, imagine: str | None = None) -> bool: + """Mesaj catre suport; cu `imagine`, chiar captura primita, nu doar textul citit.""" jid = config.get("SUPPORT_JID") or "" if not jid: return False try: - requests.post(f"{bridge_url()}/send", json={"to": jid, "text": text}, timeout=20) + if imagine and os.path.exists(imagine): + send_image(jid, imagine, text[:1000]) + else: + requests.post(f"{bridge_url()}/send", json={"to": jid, "text": text}, timeout=20) return True except Exception as exc: # noqa: BLE001 print(f"[consumer] mesaj catre suport netrimis: {exc}", file=sys.stderr) @@ -416,7 +420,8 @@ def completeaza(ref: str, msg: dict, text: str) -> str | None: config.get("SUPPORT_PHONE", ""), a_cata=intrebari - 1) else: - _notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}") + _notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}", + imagine=(msg.get("media") or {}).get("path")) raspuns = triaj.confirmare(ref, len(completari) - 1) try: @@ -517,22 +522,11 @@ def handle(index: Index, msg: dict) -> None: react_seen(sender, msg.get("id"), msg.get("fromMe", False)) continuare = fir_mod.este_continuare(fir, text, has_image) - # „Am o eroare", fara sa spuna care: nu ghicim si nu deranjam suportul — intrebam. - # Doar la deschiderea firului: la o continuare, detaliile au fost deja cerute. - if not continuare and triaj.prea_vag(text, has_image): - print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr) - fir = fir_mod.deschide(sender, text) - fir_mod.adauga(fir, "client", text) - fir_mod.adauga(fir, "maria", triaj.CERE_DETALII) - fir_mod.salveaza(fir) - send_reply(sender, triaj.CERE_DETALII) - return - # Firul are o escaladare deschisa: mesajul e completare la ea, nu intrebare # noua. Inaintea confirmarii si a cautarii — raspunsul vine instant, deci un # „caut informatia, revin imediat" ar fi o promisiune inutila, iar embedding-ul # s-ar calcula degeaba. - if continuare and fir.get("ref") and text.strip(): + if continuare and fir.get("ref") and text.strip() and not has_image: raspuns = completeaza(fir["ref"], msg, text.strip()) if raspuns: fir_mod.adauga(fir, "client", text) @@ -552,7 +546,18 @@ def handle(index: Index, msg: dict) -> None: return if continuare: - fir_mod.adauga(fir, "client", text) + fir_mod.adauga(fir, "client", text or "[captura de ecran]") + fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "") + # Captura pe o escaladare deschisa e chiar detaliul care lipsea, nu o + # problema noua: pleaca la aceeasi referinta, cu tot cu imagine. + if fir.get("ref") and has_image: + raspuns = completeaza(fir["ref"], msg, msg.get("ocr_text") or text) + if raspuns: + fir_mod.adauga(fir, "maria", raspuns) + fir_mod.salveaza(fir) + send_reply(sender, raspuns) + return + fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal search_query = fir_mod.interogare(fir, search_query) print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}", file=sys.stderr) @@ -571,6 +576,17 @@ def handle(index: Index, msg: dict) -> None: print(f"[consumer] rank: {surse} | {result['reason']}", file=sys.stderr) if not result["covered"]: + # „Am o eroare la o factura", fara sa spuna care: nu avem ce cauta si nici + # ce trimite la suport — programatorul ar pune exact aceeasi intrebare. + # O singura data pe fir: daca nici dupa ce a fost intrebat nu spune mai + # mult, mesajul pleaca la om, care poate insista altfel. + if triaj.prea_vag(text, has_image) and not fir.get("detalii_cerute"): + fir["detalii_cerute"] = True + fir_mod.adauga(fir, "maria", triaj.CERE_DETALII) + fir_mod.salveaza(fir) + print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr) + send_reply(sender, triaj.CERE_DETALII) + return print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr) record = escalate(msg, question, result, fir) sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED 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 ef73156..f38c6b4 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py @@ -43,6 +43,11 @@ def ttl_s() -> int: return config.get_int("FIR_TTL_MIN", 120) * 60 +def imagine_fereastra_s() -> int: + """Cat de repede dupa un mesaj o captura mai e „acelasi lucru" (FIR_IMAGINE_MIN).""" + return config.get_int("FIR_IMAGINE_MIN", 5) * 60 + + def tacere_s() -> int: """Cat sta Maria deoparte dupa ce a preluat un om (FIR_TACERE_MIN).""" return config.get_int("FIR_TACERE_MIN", 60) * 60 @@ -96,20 +101,42 @@ def adauga(fir: dict, rol: str, text: str) -> None: def este_continuare(fir: dict | None, text: str, are_imagine: bool) -> bool: """Mesajul duce mai departe firul deschis, sau incepe altceva? - O captura noua sau un cod pe care firul nu-l are inseamna subiect nou. In rest - — inclusiv la un mesaj lung — e continuare: omul raspunde la ce l-a intrebat - Maria, iar ancora nu strica o cautare care oricum are destul text al ei. + Un cod pe care firul nu-l are inseamna subiect nou. In rest — inclusiv la un + mesaj lung — e continuare: omul raspunde la ce l-a intrebat Maria, iar ancora + nu strica o cautare care oricum are destul text al ei. + + Captura de ecran a fost la inceput semn sigur de subiect nou. E fals exact in + cazul cel mai frecvent: omul scrie „am o factura cu eroare la trimitere" si + trimite imediat captura, sau Maria tocmai i-a cerut-o. WhatsApp le livreaza ca + doua mesaje, dar e un singur lucru — iar tratate separat, textul se pierde si + captura se cauta pe OCR-ul ei singur (2026-09-01, grupul „Maria Test"). + Departajarea o face timpul: o captura in primele `FIR_IMAGINE_MIN` minute + continua firul, una venita mult mai tarziu deschide altul. """ if not fir: return False if are_imagine: - return False + return time.time() - fir.get("at", 0) <= imagine_fereastra_s() noi = rank.codes(text) if noi and any(c not in (fir.get("coduri") or []) for c in noi): return False return True +def extinde_ancora(fir: dict, text: str, maxim: int = 2000) -> None: + """Captura sosita pe firul deschis aduce chiar textul erorii — intra in ancora. + + Fara asta, ancora ramane reclamatia vaga care a deschis firul („am o eroare la + o factura"), iar continuarile de dupa captura se cauta tot dupa ea. + """ + text = (text or "").strip() + if not text: + return + ancora = (fir.get("ancora") or "").strip() + fir["ancora"] = f"{ancora}\n{text}".strip()[-maxim:] + fir["coduri"] = list(dict.fromkeys((fir.get("coduri") or []) + rank.codes(text))) + + def interogare(fir: dict, text: str) -> str: """Ce se cauta in index la o continuare: ancora firului plus mesajul nou.""" ancora = (fir.get("ancora") or "").strip() 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 ad17234..ea69854 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py @@ -38,20 +38,6 @@ import config RRF_K = 60 # constanta standard din Reciprocal Rank Fusion -def strong_cosine() -> float: - """Peste atat, singur cosinusul e de ajuns (RANK_STRONG_COSINE). - - Era 0,70 si a fost prea jos: pe 2026-09-01, „am o factura cu eroare in SPV" - (0,768) si o captura cu `errorMessage="CUI cumparator incorect"` (0,778) au - trecut drept acoperite, desi prima nu spune ce eroare e, iar a doua nu exista - in documente. Toate chunk-urile eFactura seamana intre ele: 0,7x nu distinge - „e in documente" de „e despre eFactura". La 0,80 trec doar potrivirile - aproape textuale, restul cad pe dovada lexicala (termenii rari din intrebare - chiar in chunk-uri). Verificat cu ops/calibrate-rank.py: 25/25. - """ - return float(config.get("RANK_STRONG_COSINE", "0.80")) - - 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")) @@ -229,7 +215,6 @@ def assess( top_texts: list[str], bm25: Bm25, *, - strong: float | None = None, weak: float | None = None, min_ratio: float | None = None, ) -> tuple[bool, str]: @@ -237,18 +222,26 @@ def assess( 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. + chunk-uri; + 1. cosinus sub `weak` -> nici nu ne uitam mai departe, pleaca la suport; + 2. altfel -> raspundem doar daca o fractiune suficienta din termenii rari ai + intrebarii apare chiar in chunk-urile gasite. 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. + + 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 + lungimea interogarii**. Pe un fir de discutie interogarea e ancora plus + mesajul nou, deci urca la fiecare replica fara sa aduca vreo dovada — aceeasi + captura a dat 0,778 singura si 0,836 cu mesajul de dinainte. Orice prag fix + de sus e trecut mai devreme sau mai tarziu de o discutie destul de lunga. + Verificat pe setul de cazuri: niciunul dintre cele cu raspuns in documente nu + avea nevoie de scurtatura — toate trec pe dovada lexicala. """ - 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) @@ -261,13 +254,11 @@ def assess( 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: 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" + 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] if gasiti and len(gasiti) / len(rare) >= min_ratio: @@ -277,6 +268,6 @@ def assess( ) lipsa = [t for t in rare if t not in blob] return False, ( - f"cosinus {best_cosine:.3f} la limita, iar termenii distinctivi " + 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/rag/triaj.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py index 4953b90..2b6c68f 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py @@ -66,18 +66,22 @@ def nivel_suport(chunk: str) -> str | None: def prea_vag(text: str, are_imagine: bool) -> bool: """Mesajul anunta o problema fara sa spuna care. - Deliberat ingust: doar text scurt, fara captura si fara cod de eroare. O - intrebare adevarata („cum trimit D406") nu contine cuvintele de acuza, iar una - cu un cod are deja tot ce trebuie ca sa fie cautata. + Se intreaba DUPA cautare, si numai cand nu exista acoperire in documente — + altfel gardul taia si intrebari cu raspuns („nu pot incarca factura in SPV, + imi da eroare de certificat" e in documente, dar suna a reclamatie). + + Avea si un plafon de 12 cuvinte, pe ideea ca un mesaj lung spune destul. Nu + spune: „Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere + AUTO SULE" are 14 si tot nu zice CE eroare e — a trecut de gard si a primit un + raspuns inventat (2026-09-01). Lungimea nu masoara cat de precis e mesajul; + acoperirea in documente da. """ if are_imagine: return False t = rank.fold(text.strip()) if not t or rank.codes(t): return False - if not _ACUZA.search(t): - return False - return len(rank.tokenize(t)) <= 12 + return bool(_ACUZA.search(t)) CERE_DETALII = ( diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_fir.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_fir.py index 30af005..0c79267 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_fir.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_fir.py @@ -27,14 +27,35 @@ def test_un_raspuns_scurt_e_continuare_nu_eroare_noua(): assert "ORA-06550" in q and "ma blocheaza" in q -def test_captura_noua_sau_alt_cod_rup_firul(): +def test_alt_cod_rupe_firul(): f = fir.deschide(JID, EROARE) assert not fir.este_continuare(f, "imi da acum ORA-12154", False) - assert not fir.este_continuare(f, "si asta ce e?", True) # captura noua # acelasi cod nu rupe firul assert fir.este_continuare(f, "tot ORA-06550 imi da", False) +def test_captura_imediat_dupa_mesaj_continua_firul(): + """Cazul real: omul scrie problema si trimite captura la cateva secunde.""" + f = fir.deschide(JID, "am o factura pe august cu eroare la trimitere") + assert fir.este_continuare(f, "", True) + + +def test_captura_mult_mai_tarziu_deschide_alt_fir(): + f = fir.deschide(JID, EROARE) + f["at"] = time.time() - fir.imagine_fereastra_s() - 1 + assert not fir.este_continuare(f, "", True) + + +def test_captura_pe_fir_aduce_textul_erorii_in_ancora(): + """Ancora era reclamatia vaga; captura e cea care spune ce eroare e.""" + f = fir.deschide(JID, "am o factura cu eroare la trimitere") + fir.extinde_ancora(f, "ORA-06550: line 1, column 7") + assert "ora-06550" in f["coduri"] + assert "eroare la trimitere" in f["ancora"] and "ORA-06550" in f["ancora"] + # continuarile de dupa captura se cauta cu tot cu eroare, nu doar cu reclamatia + assert "ORA-06550" in fir.interogare(f, "da, ma blocheaza") + + def test_fara_fir_nu_exista_continuare(): assert not fir.este_continuare(None, "da", False) 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 9a7f333..9f523aa 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 @@ -118,9 +118,21 @@ def test_assess_intrebare_vaga_in_zona_de_mijloc_escaladeaza(): def test_pragurile_vin_din_env(monkeypatch): import config bm = rank.Bm25(DOCS) - monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50") - covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm) - assert covered + monkeypatch.setitem(config._env, "RANK_WEAK_COSINE", "0.90") + covered, motiv = rank.assess("factura", 0.85, DOCS[:1], bm) + assert not covered and "0.9" in motiv + + +def test_cosinusul_mare_singur_nu_mai_e_acoperire(): + """Interogarea creste pe fir si cosinusul cu ea; dovada ramane cea lexicala. + + Cazul real: aceeasi captura a dat 0,778 singura si 0,836 cu mesajul de + dinainte, fara sa apara vreo dovada noua in documente. + """ + bm = rank.Bm25(DOCS) + covered, motiv = rank.assess("cumparator incorect la incarcare", 0.84, DOCS[:1], bm) + assert not covered + assert "cumparator" in motiv # --- coduri de eroare: identificatori exacti, nu cuvinte --------------------- diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_triaj.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_triaj.py index b1c66eb..5cb3fcb 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_triaj.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_triaj.py @@ -49,6 +49,11 @@ def test_cand_notificarea_nu_a_plecat_nu_promitem_ca_a_plecat(): def test_prea_vag_doar_pentru_acuzatii_fara_continut(): assert triaj.prea_vag("am o eroare", False) assert triaj.prea_vag("nu merge nimic", False) + # cazul real din 2026-09-01: lung, dar tot nu spune CE eroare e. Plafonul de + # 12 cuvinte il lasa sa treaca, si a primit un raspuns inventat. + assert triaj.prea_vag( + "Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE", + False) # o intrebare adevarata nu se opreste ca sa i se ceara detalii assert not triaj.prea_vag("cum trimit declaratia D406 SAF-T", False) # un cod inseamna ca avem deja dupa ce cauta