diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index bb3be19..0bed273 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -374,6 +374,37 @@ cosinus si BM25 — la o captura de ecran, OCR-ul aduce zeci de tokeni de zgomot Codul se cauta oriunde in text, nu ca token intreg: OCR-ul lipeste `[Oracle][ODBC]` de cod si scoate `OraJORA-06550`. +### Triaj: cine primeste raspuns, cine primeste intrebari + +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. + +**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 +rezolva din aplicatie. Pentru astea raspunsul se compune **direct din campurile +dictionarului, fara model**, iar mesajul (si captura) pleaca automat la suport, cu +referinta. + +De ce fara model: avand in context chiar intrarea care spune „nu se rezolva din +aplicatie", modelul a raspuns unui contabil sa *„verifice schema de date din aplicatia +ROA sau sa foloseasca procedura alternativa `MI_pack_parteneri_old`"* — o procedura pe +care a inventat-o. Textul din dictionar e deja scris pentru clienti; parafrazarea lui +nu adauga nimic si poate strica tot. Pentru restul erorilor, unde utilizatorul chiar +are ce incerca, raspunde modelul — cu interdictii explicite de vocabular in +`SYSTEM_PROMPT` (procedura, schema, tabela, PL/SQL, compilare…). + +**Cat e de urgent.** Aceeasi eroare poate bloca omul complet sau poate fi ocolita pana +maine, si nu se deduce din text. Maria intreaba, iar raspunsul lui se ataseaza +escaladarii deschise (`completari` in jurnal, plus un mesaj la suport), nu deschide +alta referinta. Fereastra e de 30 de minute (`consumer.PENDING_TTL_S`), tinuta in +`~/.maria-bridge/escalations/pending.json`; un mesaj cu captura sau cu alt cod de +eroare e tratat ca intrebare noua, nu ca raspuns la intrebarea de urgenta. + ### 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/knowledge/oracle-erori-uzuale.xml b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/knowledge/oracle-erori-uzuale.xml index cc0f64d..724e23a 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/knowledge/oracle-erori-uzuale.xml +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/knowledge/oracle-erori-uzuale.xml @@ -78,7 +78,7 @@ ORA-00942 table or view does not exist tabela sau vederea nu exista ORA-00942: table or view does not exist - Aplicatia cauta ceva in baza de date care nu exista acolo. De obicei inseamna ca versiunea aplicatiei si baza de date nu sunt aduse la aceeasi zi — tipic dupa o actualizare care nu s-a aplicat complet. + Programul cauta ceva ce nu gaseste, de obicei fiindca o actualizare nu s-a aplicat complet. Nu e ceva ce ai gresit tu. Nu e ceva ce poti rezolva din aplicatie. Inchide programul si redeschide-l; daca eroarea revine, noteaza in ce ecran apare si anunta suportul. Intotdeauna. Spune la ce operatiune apare si daca ati facut recent o actualizare. @@ -86,16 +86,16 @@ ORA-00904 invalid identifier identificator invalid coloana inexistenta ORA-00904: invalid identifier - Ca si ORA-00942: aplicatia cere un camp care nu exista in baza de date. Aproape intotdeauna o actualizare aplicata partial. - Nu se rezolva din aplicatie. Noteaza ecranul si operatiunea si anunta suportul. + Ca si ORA-00942: programul cere o informatie pe care versiunea instalata nu o are inca. Aproape intotdeauna o actualizare aplicata pe jumatate. + Nu se rezolva din aplicatie. Retine in ce ecran si la ce operatiune apare — asta scurteaza mult remedierea. Intotdeauna. ORA-06550 PLS-00201 PLS-00306 wrong number or types of arguments identifier must be declared eroare compilare ORA-06550 / PLS-00201 / PLS-00306: eroare de compilare in program - O procedura din baza de date lipseste sau e apelata gresit. Ca si ORA-00942, semnul unei actualizari incomplete. - Nu se rezolva din aplicatie. + Programul nu e adus la zi peste tot: o parte din el a fost actualizata, alta nu. Nu are legatura cu ce ai facut tu si nu apare din vina datelor tale. + Nu se rezolva din aplicatie si nu ajuta sa incerci din nou. Pana se remediaza, poti lucra pe alte operatiuni. Intotdeauna. Trimite si o captura de ecran cu textul complet al erorii — contine numele procedurii, ceea ce scurteaza mult diagnosticul. @@ -150,7 +150,7 @@ ORA-28000 the account is locked cont blocat ORA-28000: the account is locked - Contul de acces la baza de date a fost blocat, de regula dupa mai multe incercari cu parola gresita. + Accesul programului la date a fost blocat, de regula dupa mai multe incercari cu parola gresita. Se deblocheaza doar de catre echipa tehnica. Nu se rezolva din aplicatie si nu se rezolva incercand din nou — incercarile suplimentare nu ajuta. Imediat. Spune daca cineva a incercat sa se conecteze repetat inainte. @@ -158,8 +158,8 @@ ORA-28001 the password has expired parola expirata ORA-28001: the password has expired - Parola contului de baza de date a expirat. - Nu incerca sa o schimbi din aplicatie. + Parola cu care programul se conecteaza la date a expirat. Nu e parola ta de utilizator si nu tine de tine. + Nu incerca sa o schimbi din aplicatie — se face de catre echipa tehnica. Imediat. E o operatiune de administrare. 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 368d5d9..c861af3 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -27,6 +27,7 @@ import requests import config import ocr import rank +import triaj SYSTEM_PROMPT = ( "Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). " @@ -38,6 +39,13 @@ SYSTEM_PROMPT = ( "Cand intrebarea contine text extras dintr-o captura de ecran (OCR), tine cont " "ca pot exista greseli de recunoastere a caracterelor: cauta sensul mesajului, " "nu te agata de o litera sau o cifra. " + "Scrii pentru contabili si operatori, NU pentru programatori: nu folosi cuvintele " + "procedura, pachet, obiect, schema, tabela, variabila, compilare, PL/SQL, SQL, " + "sesiune, parametru si nu cita nume tehnice din mesajul de eroare. Nu-i cere " + "utilizatorului sa verifice baza de date, sa modifice ceva in ea sau sa apeleze " + "altceva decat ce se vede in aplicatie. " + "Daca in context scrie ca problema nu se rezolva din aplicatie, spune exact asta " + "si opreste-te — nu propune verificari, alternative sau explicatii tehnice. " "Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, " "containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau " "daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea " @@ -67,6 +75,12 @@ ESCALATED_RECORDED = ( ) +FOLLOWUP_OK = ( + "Multumesc, am adaugat si asta la {ref} — echipa vede detaliul cand preia " + "problema." +) + + def bridge_url() -> str: return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}" @@ -315,6 +329,85 @@ def escalate(msg: dict, question: str, search_result: dict) -> dict: return record +PENDING_FILE_NAME = "pending.json" +PENDING_TTL_S = 30 * 60 # cat timp un raspuns scurt mai e „completare", nu intrebare noua + + +def _pending_path(): + return config.STATE_DIR / "escalations" / PENDING_FILE_NAME + + +def _pending_all() -> dict: + try: + return json.loads(_pending_path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _pending_write(data: dict) -> None: + try: + _pending_path().parent.mkdir(parents=True, exist_ok=True) + _pending_path().write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + except OSError as exc: + print(f"[consumer] nu pot scrie {PENDING_FILE_NAME}: {exc}", file=sys.stderr) + + +def pending_set(sender: str, ref: str) -> None: + """Retine ca l-am intrebat pe om cat e de urgent, ca sa stiu unde duce raspunsul.""" + data = _pending_all() + data[sender] = {"ref": ref, "at": time.time()} + _pending_write(data) + + +def pending_get(sender: str) -> dict | None: + intrare = _pending_all().get(sender) + if not intrare or time.time() - intrare.get("at", 0) > PENDING_TTL_S: + return None + return intrare + + +def pending_clear(sender: str) -> None: + data = _pending_all() + if data.pop(sender, None) is not None: + _pending_write(data) + + +def append_followup(ref: str, msg: dict, text: str) -> bool: + """Adauga raspunsul omului la escaladarea deschisa si il trimite la suport. + + Raspunsul la „te blocheaza sau poti continua?" e chiar informatia care lipseste + din escaladare. O escaladare noua ar rupe firul: aceeasi problema, alta + referinta, iar omul a citat-o deja pe prima. + """ + d = config.STATE_DIR / "escalations" + fisier = next((f for f in sorted(d.glob(f"*-{ref}.json"))), None) if d.exists() else None + if fisier is None: + return False + try: + record = json.loads(fisier.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + record.setdefault("completari", []).append( + {"at": time.time(), "text": text, "message_id": msg.get("id")}) + try: + fisier.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError as exc: + print(f"[consumer] nu pot actualiza {fisier.name}: {exc}", file=sys.stderr) + + jid = config.get("SUPPORT_JID") or "" + if jid: + cine = msg.get("pushName") or msg.get("from") or "necunoscut" + try: + requests.post(f"{bridge_url()}/send", json={ + "to": jid, + "text": f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}", + }, timeout=20) + except Exception as exc: # noqa: BLE001 + print(f"[consumer] completare netrimisa: {exc}", file=sys.stderr) + print(f"[consumer] completare la {ref}: {text[:60]!r}", file=sys.stderr) + return True + + def cleanup_media(msg: dict) -> None: """Capturile pot contine date de client — nu raman pe disc dupa ce s-a tratat mesajul.""" path = (msg.get("media") or {}).get("path") @@ -332,6 +425,24 @@ def handle(index: Index, msg: dict) -> None: text = msg.get("text", "") or "" print(f"[consumer] {sender}: {'[imagine] ' if has_image else ''}{text[:80]}", file=sys.stderr) react_seen(sender, msg.get("id"), msg.get("fromMe", False)) + + # Raspunsul la „te blocheaza sau poti continua?" merge la escaladarea deschisa. + # Inainte de orice altceva: nu e o intrebare noua, deci nu se cauta si nu se + # confirma cu „caut informatia". + asteptat = pending_get(sender) if not has_image else None + if asteptat and text.strip() and not rank.codes(text): + if append_followup(asteptat["ref"], msg, text.strip()): + pending_clear(sender) + send_reply(sender, FOLLOWUP_OK.format(ref=asteptat["ref"])) + return + pending_clear(sender) + + # „Am o eroare", fara sa spuna care: nu ghicim si nu deranjam suportul — intrebam. + if triaj.prea_vag(text, has_image): + print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr) + send_reply(sender, triaj.CERE_DETALII) + return + send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT) question, search_query, immediate = prepare_query(msg) @@ -358,6 +469,18 @@ def handle(index: Index, msg: dict) -> None: send_reply(sender, sablon.format(ref=record["ref"])) return + # Eroare care nu se rezolva din aplicatie: raspunsul se compune din dictionar + # si pleaca automat la programatori. Vezi triaj.py pentru de ce nu prin model. + nivel = triaj.nivel_suport(result["chunks"][0]) if result["chunks"] else None + if nivel: + print(f"[consumer] eroare de nivel '{nivel}' -> raspuns fix + suport", file=sys.stderr) + record = escalate(msg, question, result) + send_reply(sender, triaj.raspuns_escaladat( + result["chunks"][0], nivel, record["ref"], record["notified"], + cu_imagine=has_image)) + pending_set(sender, record["ref"]) + return + try: reply = ask_llm(result["chunks"], question) except Exception as exc: # noqa: BLE001 diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py new file mode 100644 index 0000000..9e53a29 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Triaj: cine primeste raspuns, cine primeste intrebari, cine cheama programatorii. + +Trei decizii pe care cautarea in documente nu le poate lua singura: + +1. **Mesaj prea vag.** „Am o eroare" nu e o intrebare — e inceputul uneia. Fara + operatiunea, ecranul si textul erorii, si un om ar ghici. Maria intreaba, nu + ghiceste (si nu deranjeaza suportul cu „am o eroare"). + +2. **Erori care oricum ajung la programatori.** Dictionarul spune, pentru fiecare + eroare, `cand suni suportul`. Noua dintre ele incep cu „Intotdeauna" sau + „Imediat": nu se rezolva din aplicatie, orice explicatie i-ar da utilizatorului + nu-l ajuta sa iasa din ea. Pentru astea raspunsul se compune din chiar campurile + dictionarului, iar mesajul pleaca automat la suport. + + De ce fara model: pe captura cu ORA-06550, avand in context exact intrarea care + spune „nu se rezolva din aplicatie", modelul a raspuns „verifica schema de date + din aplicatia ROA (...) sau foloseste procedura alternativa MI_pack_parteneri_old". + A inventat o procedura, a cerut unui contabil sa umble in schema si a contrazis + documentul. Textul din dictionar e deja scris pentru clienti; parafrazarea lui + nu adauga nimic si poate strica tot. + +3. **Cat e de urgent.** Aceeasi eroare poate bloca omul complet sau poate fi ocolita + pana maine. Nu se deduce din text, deci se intreaba — iar raspunsul lui se + ataseaza escaladarii deja deschise, nu porneste alta. +""" + +from __future__ import annotations + +import re + +import rank + +# Prima vorba din `cand suni suportul`. Restul („Daca revine dupa restart") sunt +# conditionate: acolo utilizatorul are ce incerca, deci raspunde modelul. +_INTOTDEAUNA = ("intotdeauna", "imediat") + +_CAMP = re.compile(r"^([a-z ]+):\s*(.*)$") + +# Cuvintele cu care oamenii anunta o problema fara sa spuna care. +_ACUZA = re.compile( + r"\b(eroare|erori|eroarea|problema|probleme|nu merge|nu functioneaza|" + r"nu mai merge|s-a blocat|crapa|imi da|nu pot)\b" +) + + +def camp(chunk: str, nume: str) -> str: + """Valoarea unui camp din chunk-ul XML randat (`mesaj eroare: ...`).""" + tinta = nume.replace("_", " ") + for line in chunk.splitlines(): + m = _CAMP.match(line.strip()) + if m and m.group(1).strip() == tinta: + return m.group(2).strip() + return "" + + +def nivel_suport(chunk: str) -> str | None: + """`"intotdeauna"` / `"imediat"` daca eroarea cere suport oricum, altfel `None`.""" + valoare = camp(chunk, "cand suni suportul").lower() + for cuvant in _INTOTDEAUNA: + if valoare.startswith(cuvant): + return cuvant + return 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. + """ + 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 + + +CERE_DETALII = ( + "Ca sa-mi dau seama despre ce e vorba, spune-mi te rog:\n" + "1. ce faceai exact cand a aparut (ce operatiune),\n" + "2. in ce ecran sau meniu erai,\n" + "3. ce scrie exact in mesaj — cel mai simplu, trimite o captura de ecran cu " + "fereastra de eroare." +) + +# Ce se trimite la o eroare care oricum ajunge la programatori. Explicatia si +# indicatia sunt luate ca atare din dictionar, nu reformulate. +_URGENT = ("Este o eroare care afecteaza toti utilizatorii, nu doar pe tine, asa ca " + "am anuntat echipa tehnica imediat si i-am trimis {ce}") +_NORMAL = "Am trimis {ce} mai departe, catre echipa tehnica" + +INTREBARE_URGENTA = ( + "Spune-mi te rog daca te blocheaza complet sau poti continua pe alta " + "operatiune — asa stiu cat e de urgent." +) + + +def raspuns_escaladat(chunk: str, nivel: str, ref: str, notificat: bool, + cu_imagine: bool = False) -> str: + """Raspunsul pentru o eroare care oricum ajunge la programatori. + + Compus din campurile dictionarului, fara model: textul de acolo e deja scris + pentru clienti, iar aici nu e nimic de dedus — doar de spus limpede. + """ + parti: list[str] = [] + explicatie = camp(chunk, "ce inseamna") + if explicatie: + parti.append(explicatie) + rezolvare = camp(chunk, "rezolvare") + if rezolvare: + parti.append(rezolvare) + + ce = "mesajul si captura" if cu_imagine else "mesajul" + trimis = (_URGENT if nivel == "imediat" else _NORMAL).format(ce=ce) + if notificat: + parti.append(f"{trimis} (referinta {ref}). Te contacteaza cineva.") + else: + # Nu promitem ce nu s-a intamplat: notificarea a esuat, dar e inregistrata. + parti.append( + f"Am inregistrat problema pentru echipa tehnica (referinta {ref}), dar " + f"nu am putut sa o trimit chiar acum. Daca te blocheaza, suna-ne." + ) + parti.append(INTREBARE_URGENTA) + return "\n\n".join(parti) 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 new file mode 100644 index 0000000..d66acde --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_triaj.py @@ -0,0 +1,95 @@ +"""Triajul: cand se cer detalii, cand pleaca automat la programatori.""" + +from __future__ import annotations + +import json +import time + +import triaj + + +CHUNK_06550 = """[eroare ORA 06550] +keywords: ORA-06550 PLS-00201 eroare compilare +mesaj eroare: ORA-06550: eroare de compilare in program +ce inseamna: Programul nu e adus la zi peste tot. +rezolvare: Nu se rezolva din aplicatie si nu ajuta sa incerci din nou. +cand suni suportul: Intotdeauna. Trimite si o captura de ecran.""" + +CHUNK_04068 = """[eroare ORA 04068] +mesaj eroare: ORA-04068: existing state of packages has been discarded +ce inseamna: Programul a fost actualizat in timp ce aplicatia ta era deschisa. +rezolvare: Inchide complet aplicatia si redeschide-o. +cand suni suportul: Daca revine si dupa restartul aplicatiei.""" + + +def test_nivelul_se_citeste_din_dictionar(): + assert triaj.nivel_suport(CHUNK_06550) == "intotdeauna" + assert triaj.nivel_suport(CHUNK_04068) is None + assert triaj.nivel_suport("cand suni suportul: Imediat. Blocheaza tot.") == "imediat" + + +def test_raspunsul_nu_vorbeste_ca_pentru_programatori(): + """Cazul din 2026-09-01: modelul ii cerea unui contabil sa verifice schema.""" + text = triaj.raspuns_escaladat(CHUNK_06550, "intotdeauna", "M-260901-A1B2", + True, cu_imagine=True) + jos = text.lower() + for cuvant in ("procedura", "schema", "pl/sql", "obiect", "variabila", "compilare"): + assert cuvant not in jos, cuvant + assert "M-260901-A1B2" in text + assert "captura" in jos + assert "blocheaza complet" in jos # intreaba cat e de urgent + + +def test_cand_notificarea_nu_a_plecat_nu_promitem_ca_a_plecat(): + text = triaj.raspuns_escaladat(CHUNK_06550, "intotdeauna", "M-1", False) + assert "am inregistrat" in text.lower() + assert "te contacteaza cineva" not in text.lower() + + +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) + # 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 + assert not triaj.prea_vag("imi da ORA-01722 la salvare", False) + # captura vorbeste de la sine + assert not triaj.prea_vag("am o eroare", True) + + +# --- completarea la o escaladare deschisa ----------------------------------- + +def _escaladare(ref: str) -> None: + import config + d = config.STATE_DIR / "escalations" + d.mkdir(parents=True, exist_ok=True) + (d / f"{int(time.time())}-{ref}.json").write_text( + json.dumps({"ref": ref, "text": "eroare initiala"}), encoding="utf-8") + + +def test_raspunsul_despre_urgenta_intra_in_escaladarea_deschisa(monkeypatch): + import config + import consumer + + monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID" + else config.DEFAULTS.get(k, d)) + _escaladare("M-TEST-1") + consumer.pending_set("40712@s.whatsapp.net", "M-TEST-1") + assert consumer.pending_get("40712@s.whatsapp.net")["ref"] == "M-TEST-1" + + assert consumer.append_followup("M-TEST-1", {"id": "x"}, "ma blocheaza, nu pot factura") + + fisier = next((config.STATE_DIR / "escalations").glob("*-M-TEST-1.json")) + record = json.loads(fisier.read_text(encoding="utf-8")) + assert record["completari"][0]["text"] == "ma blocheaza, nu pot factura" + + consumer.pending_clear("40712@s.whatsapp.net") + assert consumer.pending_get("40712@s.whatsapp.net") is None + + +def test_completarea_expira(monkeypatch): + import consumer + consumer.pending_set("40799@s.whatsapp.net", "M-TEST-2") + peste_o_ora = time.time() + consumer.PENDING_TTL_S + 1 + monkeypatch.setattr(consumer.time, "time", lambda: peste_o_ora) + assert consumer.pending_get("40799@s.whatsapp.net") is None