diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index 0bed273..c21fb64 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -405,6 +405,49 @@ alta referinta. Fereastra e de 30 de minute (`consumer.PENDING_TTL_S`), tinuta i `~/.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. +### Firul de discutie + +Pana la `rag/fir.py`, fiecare mesaj pornea de la zero. Dupa o escaladare pentru +ORA-06550, masurat pe indexul viu: + +| Ce scrie omul | Ce raspundea Maria | +|---|---| +| „da, ma blocheaza complet" | cum se completeaza un ordin de plata la Trezorerie (0,642) | +| „eram la salvarea unei facturi" | procedura de corectie a unei eFacturi (0,742) | +| „si acum ce fac?" | observatii despre eFacturi primite gresit | + +Toate „acoperite", cu cosinus peste prag: patru cuvinte fara context chiar seamana cu +ceva din documente. Firul tine ancora (textul erorii care l-a deschis), codurile ei, +referinta escaladarii si ultimele 6 schimburi, in `~/.maria-bridge/conversations/`. + +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). + +### Cand preia un om + +Daca cineva din echipa scrie in discutie, Maria tace `FIR_TACERE_MIN` minute (60), ca +sa nu vorbeasca peste el. Automat **doar in grupuri cu cel putin +`FIR_PRELUARE_MIN_PARTICIPANTI` (2) participanti**: in self-chat si in grupul de test +tot ce se scrie e `fromMe`, deci regula ar amuti-o la primul mesaj. Oriunde merg si +comenzile explicite: „Maria, stop" / „preiau eu" si „Maria, continua". + +### Testarea intr-un grup + +`ALLOWED_GROUP_JIDS` (in `env`) listeaza grupurile in care Maria raspunde chiar si cu +`TEST_MODE_SELF_CHAT_ONLY=true`. Filtrul e si in punte, si in consumer. Azi: +`120363409761730101@g.us` — grupul „Maria Test". + +> **Nu pune aici `echo-test` (`120363424350922235@g.us`).** E canalul WhatsApp al lui +> Echo (LXC 110), iar puntea lui nu filtreaza `fromMe` in grupuri +> (`if (msg.key.fromMe && !isGroup) continue`). Cei doi boti si-ar raspunde unul +> altuia la nesfarsit, cu Claude pe API la Echo. Vezi `docs/chatboti-si-punti.md`. + ### 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 09ea967..86aae8a 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 @@ -55,6 +55,12 @@ CAZURI: list[tuple[str, bool]] = [ "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), ("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), 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 6c51b27..80feb8c 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example @@ -10,6 +10,13 @@ BRIDGE_PORT=8099 # dupa ce esti multumit de calitatea raspunsurilor — altfel Maria raspunde la # oricine iti scrie pe numarul legat. TEST_MODE_SELF_CHAT_ONLY=true +# Grupuri in care Maria raspunde chiar si cu self-chat-only (JID-uri, separate prin +# virgula). JID-ul unui grup se citeste din punte: curl -s localhost:8099/groups +# ATENTIE: nu pune un grup in care raspunde si Echo (LXC 110) — puntea lui nu +# filtreaza `fromMe` in grupuri, deci cei doi boti isi raspund unul altuia la +# nesfarsit. Vezi docs/chatboti-si-punti.md. +ALLOWED_GROUP_JIDS= + # Imaginile primite (capturi cu erori) peste aceasta marime sunt refuzate, fara # descarcare. O captura de ecran normala are sub 1 MB. MAX_MEDIA_MB=8 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 ee1ee24..ec5def4 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -41,6 +41,15 @@ DEFAULTS: dict[str, str] = { "MAX_TOKENS": "250", "POLL_INTERVAL_S": "2", "TEST_MODE_SELF_CHAT_ONLY": "true", + # Grupuri in care Maria are voie sa raspunda (JID-uri separate prin virgula). + # Restul grupurilor sunt ignorate, si de punte, si de consumer. + "ALLOWED_GROUP_JIDS": "", + # Firul de discutie (vezi rag/fir.py): cat tine minte, cat tace dupa ce a + # preluat un om, si de cati participanti e nevoie ca preluarea sa se aplice + # automat (in self-chat si in grupul de test totul e `fromMe`). + "FIR_TTL_MIN": "120", + "FIR_TACERE_MIN": "60", + "FIR_PRELUARE_MIN_PARTICIPANTI": "2", # OCR pentru capturile de ecran primite pe WhatsApp (vezi rag/ocr.py). "OCR_LANGS": "ron+eng", "OCR_MAX_CHARS": "1500", 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 c861af3..139289f 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -25,6 +25,7 @@ import time import requests import config +import fir as fir_mod import ocr import rank import triaj @@ -75,6 +76,10 @@ ESCALATED_RECORDED = ( ) +TACERE_OK = ( + 'Am inteles, ma opresc. Scrie-mi "Maria, continua" cand vrei sa reiau.' +) +REVENIRE_OK = "Sunt aici. Cu ce te ajut?" FOLLOWUP_OK = ( "Multumesc, am adaugat si asta la {ref} — echipa vede detaliul cand preia " "problema." @@ -154,17 +159,25 @@ def search(index: Index, query: str, top_k: int) -> dict: } -def ask_llm(chunks: list[str], question: str) -> str: +def ask_llm(chunks: list[str], question: str, istoric: list[dict] | None = None) -> str: + """Raspunsul modelului. `istoric` = schimburile de pana acum, la o continuare. + + Fara istoric, „si acum ce fac?" ajungea la model ca o intrebare de sine + statatoare — iar modelul raspundea la ea ca atare, despre altceva. + """ context = "\n\n---\n\n".join(chunks) if chunks else "(fara documente indexate)" user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}" + mesaje = [{"role": "system", "content": SYSTEM_PROMPT}] + # Istoricul precede contextul: ultimul mesaj trebuie sa fie intrebarea curenta, + # altfel modelul raspunde la penultima. + mesaje += (istoric or [])[:-1] + mesaje.append({"role": "user", "content": user_message}) + resp = requests.post( f"{config.get('LLM_URL')}/v1/chat/completions", json={ - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": user_message}, - ], + "messages": mesaje, "max_tokens": config.get_int("MAX_TOKENS", 250), }, timeout=60, @@ -270,7 +283,7 @@ def reference(when: float, message_id: str | None) -> str: return f"M-{time.strftime('%y%m%d', time.localtime(when))}-{sufix}" -def escalate(msg: dict, question: str, search_result: dict) -> dict: +def escalate(msg: dict, question: str, search_result: dict, fir: dict | None = None) -> dict: """Trimite intrebarea la suport si o inregistreaza in jurnal. Intoarce inregistrarea. Jurnalul se scrie INTOTDEAUNA, si cand notificarea esueaza sau `SUPPORT_JID` @@ -295,15 +308,19 @@ def escalate(msg: dict, question: str, search_result: dict) -> dict: "had_image": bool(media.get("path")), "notified": False, } + if fir and fir.get("schimburi"): + record["fir"] = fir["schimburi"] jid = config.get("SUPPORT_JID") or "" if jid: cine = msg.get("pushName") or msg.get("from") or "necunoscut" + discutie = fir_mod.rezumat(fir) if fir else "" rezumat = ( f"[Maria] Intrebare fara raspuns in documente — {record['ref']}\n" f"De la: {cine}\n" f"Motiv: {record['reason']}\n\n" f"{(msg.get('ocr_text') or msg.get('text') or '').strip()[:1200]}" + + (f"\n\n--- discutia de pana acum ---\n{discutie}" if discutie else "") ) try: if media.get("path") and os.path.exists(media["path"]): @@ -329,49 +346,6 @@ 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. @@ -408,6 +382,44 @@ def append_followup(ref: str, msg: dict, text: str) -> bool: return True +_GRUPURI_CACHE: dict = {"at": 0.0, "date": {}} +GRUPURI_TTL_S = 600 + + +def grup_info(jid: str) -> dict: + """Cate persoane sunt in grup. Raspunsul se tine 10 minute in memorie.""" + if time.time() - _GRUPURI_CACHE["at"] > GRUPURI_TTL_S: + try: + resp = requests.get(f"{bridge_url()}/groups", timeout=15) + resp.raise_for_status() + _GRUPURI_CACHE["date"] = {g["jid"]: g for g in resp.json().get("groups", [])} + _GRUPURI_CACHE["at"] = time.time() + except Exception as exc: # noqa: BLE001 + print(f"[consumer] nu pot citi grupurile: {exc}", file=sys.stderr) + return _GRUPURI_CACHE["date"].get(jid, {}) + + +def preluare_de_om(msg: dict) -> bool: + """Un om din echipa a scris in discutie, deci Maria se retrage. + + Doar in grupuri cu mai multi oameni. In self-chat si in grupul de test (un + singur participant) TOT ce se scrie e `fromMe`: acolo regula ar face Maria sa + amuteasca la primul mesaj, deci ramane doar comanda explicita. + """ + if not msg.get("fromMe") or not msg.get("isGroup"): + return False + if (msg.get("text") or "").startswith(REPLY_PREFIX): + return False # propriul raspuns, nu un om + minim = config.get_int("FIR_PRELUARE_MIN_PARTICIPANTI", 2) + return grup_info(msg.get("from") or "").get("participants", 0) >= minim + + +def grupuri_permise() -> set[str]: + """JID-urile de grup in care Maria are voie sa raspunda (ALLOWED_GROUP_JIDS).""" + brut = config.get("ALLOWED_GROUP_JIDS", "") or "" + return {j.strip() for j in brut.replace(";", ",").split(",") if j.strip()} + + 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") @@ -424,22 +436,50 @@ def handle(index: Index, msg: dict) -> None: has_image = bool(msg.get("media")) 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) + fir = fir_mod.incarca(sender) + + # Comenzi explicite. Merg oriunde, si acolo unde regula automata de mai jos nu + # se aplica (self-chat, grup de test cu un singur om). + cmd = fir_mod.comanda(text) + if cmd and not has_image: + fir = fir or fir_mod.deschide(sender, "") + if cmd == "stop": + fir_mod.taci(fir) + fir_mod.salveaza(fir) + print(f"[consumer] {sender}: tac la cerere", file=sys.stderr) + send_reply(sender, TACERE_OK) + else: + fir_mod.vorbeste(fir) + fir_mod.salveaza(fir) + send_reply(sender, REVENIRE_OK) + return + + # Un om din echipa a intrat in discutie: Maria nu vorbeste peste el. + if preluare_de_om(msg): + fir = fir or fir_mod.deschide(sender, "") + fir_mod.adauga(fir, "om", text) + fir_mod.taci(fir) + fir_mod.salveaza(fir) + print(f"[consumer] {sender}: a preluat un om, tac {fir_mod.tacere_s() // 60} min", + file=sys.stderr) + return + + if fir_mod.tace(fir): + print(f"[consumer] {sender}: fir preluat de om, nu raspund", file=sys.stderr) + return + + 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. - if triaj.prea_vag(text, has_image): + # 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 @@ -452,6 +492,15 @@ def handle(index: Index, msg: dict) -> None: if not question: return + if continuare: + fir_mod.adauga(fir, "client", text) + search_query = fir_mod.interogare(fir, search_query) + print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}", + file=sys.stderr) + else: + fir = fir_mod.deschide(sender, msg.get("ocr_text") or text) + fir_mod.adauga(fir, "client", text or "[captura de ecran]") + try: result = search(index, search_query, config.get_int("TOP_K", 3)) except Exception as exc: # noqa: BLE001 @@ -462,11 +511,24 @@ def handle(index: Index, msg: dict) -> None: surse = ", ".join(f"{t['source']}({t['cosine']})" for t in result["top"]) or "-" print(f"[consumer] rank: {surse} | {result['reason']}", file=sys.stderr) + # Firul are deja o escaladare deschisa: ce spune omul acum e completare la ea, + # nu o problema noua. De cate ori e nevoie, nu o singura data. + if continuare and fir.get("ref") and append_followup(fir["ref"], msg, text.strip() or "(captura)"): + raspuns = FOLLOWUP_OK.format(ref=fir["ref"]) + fir_mod.adauga(fir, "maria", raspuns) + fir_mod.salveaza(fir) + send_reply(sender, raspuns) + return + if not result["covered"]: print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr) - record = escalate(msg, question, result) + record = escalate(msg, question, result, fir) sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED - send_reply(sender, sablon.format(ref=record["ref"])) + raspuns = sablon.format(ref=record["ref"]) + fir["ref"] = record["ref"] + fir_mod.adauga(fir, "maria", raspuns) + fir_mod.salveaza(fir) + send_reply(sender, raspuns) return # Eroare care nu se rezolva din aplicatie: raspunsul se compune din dictionar @@ -474,18 +536,22 @@ def handle(index: Index, msg: dict) -> None: 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"]) + record = escalate(msg, question, result, fir) + raspuns = triaj.raspuns_escaladat( + result["chunks"][0], nivel, record["ref"], record["notified"], cu_imagine=has_image) + fir["ref"] = record["ref"] + fir_mod.adauga(fir, "maria", raspuns) + fir_mod.salveaza(fir) + send_reply(sender, raspuns) return try: - reply = ask_llm(result["chunks"], question) + reply = ask_llm(result["chunks"], question, fir_mod.istoric(fir) if continuare else None) except Exception as exc: # noqa: BLE001 print(f"[consumer] LLM error: {exc}", file=sys.stderr) reply = "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta." + fir_mod.adauga(fir, "maria", reply) + fir_mod.salveaza(fir) send_reply(sender, reply) print(f"[consumer] -> raspuns catre {sender}", file=sys.stderr) @@ -509,7 +575,7 @@ def main() -> None: resp = requests.get(f"{bridge_url()}/messages", timeout=10) resp.raise_for_status() for msg in resp.json().get("messages", []): - if msg.get("isGroup"): + if msg.get("isGroup") and msg.get("from") not in grupuri_permise(): continue if (msg.get("text") or "").startswith(REPLY_PREFIX): continue # ecoul propriului raspuns in self-chat, ignorat diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py new file mode 100644 index 0000000..ef73156 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Firul de discutie: al doilea mesaj al omului e continuarea primului. + +Fara asta, fiecare mesaj pornea de la zero. Masurat pe indexul viu, dupa o +escaladare pentru ORA-06550: + + „da, ma blocheaza complet" -> raspuns despre ordinul de plata la Trezorerie + „eram la salvarea unei facturi" -> raspuns despre corectia unei eFacturi + „si acum ce fac?" -> raspuns despre eFacturi primite gresit + +Toate trei „acoperite", cu cosinus peste prag: patru cuvinte fara context seamana +cu ceva din documente, iar cautarea nu are de unde sti ca sunt raspunsul la +intrebarea Mariei, nu o intrebare noua. + +Ce tine firul: ancora (textul erorii care l-a deschis), codurile ei, referinta +escaladarii deschise si ultimele schimburi. Ancora intra in interogarea de cautare +la fiecare continuare — asa „da, ma blocheaza" se cauta impreuna cu eroarea, nu +singur. + +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 +exact raspunsurile de mai sus. + +Firele contin text de client (uneori si OCR de pe capturi), deci expira si se sterg +singure, ca si capturile. +""" + +from __future__ import annotations + +import json +import re +import time + +import config +import rank + +MAX_SCHIMBURI = 6 # cate replici se tin minte (ale omului si ale Mariei, la un loc) + + +def ttl_s() -> int: + """Cat timp de tacere inseamna ca discutia s-a incheiat (FIR_TTL_MIN).""" + return config.get_int("FIR_TTL_MIN", 120) * 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 + + +def _dir(): + return config.STATE_DIR / "conversations" + + +def _cale(jid: str): + return _dir() / (re.sub(r"[^0-9a-zA-Z@._-]", "_", jid) + ".json") + + +def incarca(jid: str) -> dict | None: + """Firul deschis cu cineva, sau `None` daca nu exista ori a expirat.""" + cale = _cale(jid) + try: + fir = json.loads(cale.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if time.time() - fir.get("at", 0) > ttl_s(): + cale.unlink(missing_ok=True) + return None + return fir + + +def salveaza(fir: dict) -> None: + fir["at"] = time.time() + try: + _dir().mkdir(parents=True, exist_ok=True) + _cale(fir["jid"]).write_text(json.dumps(fir, ensure_ascii=False), encoding="utf-8") + except OSError: + pass # firul e o comoditate, nu o obligatie: fara el se comporta ca inainte + + +def sterge(jid: str) -> None: + _cale(jid).unlink(missing_ok=True) + + +def deschide(jid: str, ancora: str) -> dict: + return {"jid": jid, "at": time.time(), "ancora": ancora.strip(), + "coduri": rank.codes(ancora), "ref": None, "tacere_pana": 0, "schimburi": []} + + +def adauga(fir: dict, rol: str, text: str) -> None: + fir.setdefault("schimburi", []).append({"rol": rol, "text": text.strip()[:1500], + "at": time.time()}) + del fir["schimburi"][:-MAX_SCHIMBURI] + + +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. + """ + if not fir: + return False + if are_imagine: + return False + noi = rank.codes(text) + if noi and any(c not in (fir.get("coduri") or []) for c in noi): + return False + return True + + +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() + if not ancora: + return text + return f"{ancora}\n{text.strip()}".strip() + + +def istoric(fir: dict) -> list[dict]: + """Schimburile de pana acum, in formatul de chat al modelului.""" + return [{"role": "assistant" if s["rol"] == "maria" else "user", "content": s["text"]} + for s in fir.get("schimburi", [])] + + +def rezumat(fir: dict, maxim: int = 900) -> str: + """Firul, ca text, pentru mesajul catre suport.""" + etichete = {"maria": "Maria", "om": "Suport"} + linii = [f"{etichete.get(s['rol'], 'Client')}: {s['text']}" + for s in fir.get("schimburi", [])] + return "\n".join(linii)[-maxim:] + + +# --- cand preia un om ------------------------------------------------------- + +_STOP = re.compile(r"^\s*(maria[, ]+)?(stop|pauza|taci|preiau|ma ocup eu)\b") +_START = re.compile(r"^\s*maria[, ]+(start|continua|revino|poti)\b") + + +def comanda(text: str) -> str | None: + """`"stop"` / `"start"` / `None` — comenzi explicite de oprire si repornire.""" + t = rank.fold(text or "") + if _START.match(t): + return "start" + if _STOP.match(t): + return "stop" + return None + + +def taci(fir: dict, secunde: int | None = None) -> None: + fir["tacere_pana"] = time.time() + (tacere_s() if secunde is None else secunde) + + +def vorbeste(fir: dict) -> None: + fir["tacere_pana"] = 0 + + +def tace(fir: dict | None) -> bool: + return bool(fir) and time.time() < (fir.get("tacere_pana") or 0) 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 new file mode 100644 index 0000000..30af005 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_fir.py @@ -0,0 +1,91 @@ +"""Firul de discutie: continuare vs subiect nou, ancora, tacerea la preluare.""" + +from __future__ import annotations + +import time + +import fir + + +JID = "40712345678@s.whatsapp.net" +EROARE = ("[Oracle][ODBC][Ora]ORA-06550: line 1, column 7\n" + "PLS-00906: object VADECO PACK PARTENERI is invalid") + + +def test_firul_retine_codurile_ancorei(): + f = fir.deschide(JID, EROARE) + assert "ora-06550" in f["coduri"] and "pls-00906" in f["coduri"] + + +def test_un_raspuns_scurt_e_continuare_nu_eroare_noua(): + """Cazul masurat: „da, ma blocheaza" cautat singur nimerea ordinul de plata.""" + f = fir.deschide(JID, EROARE) + assert fir.este_continuare(f, "da, ma blocheaza complet", False) + assert fir.este_continuare(f, "eram la salvarea unei facturi", False) + # ancora intra in cautare, ca sa nu se caute patru cuvinte fara context + q = fir.interogare(f, "da, ma blocheaza complet") + assert "ORA-06550" in q and "ma blocheaza" in q + + +def test_captura_noua_sau_alt_cod_rup_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_fara_fir_nu_exista_continuare(): + assert not fir.este_continuare(None, "da", False) + + +def test_firul_expira(monkeypatch): + f = fir.deschide(JID, EROARE) + fir.salveaza(f) + assert fir.incarca(JID) is not None + + tarziu = time.time() + fir.ttl_s() + 1 + monkeypatch.setattr(fir.time, "time", lambda: tarziu) + assert fir.incarca(JID) is None + + +def test_istoricul_se_opreste_la_max_schimburi(): + f = fir.deschide(JID, EROARE) + for i in range(fir.MAX_SCHIMBURI + 4): + fir.adauga(f, "client", f"mesaj {i}") + assert len(f["schimburi"]) == fir.MAX_SCHIMBURI + assert f["schimburi"][-1]["text"] == f"mesaj {fir.MAX_SCHIMBURI + 3}" + assert fir.istoric(f)[-1]["role"] == "user" + + +def test_comenzile_de_oprire_si_revenire(): + assert fir.comanda("Maria, stop") == "stop" + assert fir.comanda("preiau eu") == "stop" + assert fir.comanda("Maria, continua") == "start" + assert fir.comanda("cum trimit D406") is None + + +def test_tacerea_expira(monkeypatch): + f = fir.deschide(JID, EROARE) + fir.taci(f) + assert fir.tace(f) + tarziu = time.time() + fir.tacere_s() + 1 + monkeypatch.setattr(fir.time, "time", lambda: tarziu) + assert not fir.tace(f) + + +def test_preluarea_doar_in_grupuri_cu_mai_multi_oameni(monkeypatch): + """In self-chat si in grupul de test tot ce se scrie e fromMe.""" + import consumer + monkeypatch.setattr(consumer, "grup_info", + lambda jid: {"participants": 1 if "test" in jid else 5}) + + assert not consumer.preluare_de_om({"fromMe": True, "isGroup": False, "from": JID}) + assert not consumer.preluare_de_om( + {"fromMe": True, "isGroup": True, "from": "test@g.us", "text": "salut"}) + assert consumer.preluare_de_om( + {"fromMe": True, "isGroup": True, "from": "client@g.us", "text": "preiau eu cazul"}) + # propriul raspuns al Mariei nu e „un om care a preluat" + assert not consumer.preluare_de_om( + {"fromMe": True, "isGroup": True, "from": "client@g.us", + "text": consumer.REPLY_PREFIX + "buna"}) 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 d66acde..fa6bcba 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 @@ -74,22 +74,25 @@ def test_raspunsul_despre_urgenta_intra_in_escaladarea_deschisa(monkeypatch): 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): +def test_completarile_se_aduna_nu_se_inlocuiesc(monkeypatch): + """Omul raspunde de mai multe ori; prima data nu inchide firul.""" + import config 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 + + monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID" + else config.DEFAULTS.get(k, d)) + _escaladare("M-TEST-3") + consumer.append_followup("M-TEST-3", {"id": "a"}, "da, ma blocheaza") + consumer.append_followup("M-TEST-3", {"id": "b"}, "eram la salvarea unei facturi") + + fisier = next((config.STATE_DIR / "escalations").glob("*-M-TEST-3.json")) + record = json.loads(fisier.read_text(encoding="utf-8")) + assert [c["text"] for c in record["completari"]] == [ + "da, ma blocheaza", "eram la salvarea unei facturi"] diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js index cef4041..6986e64 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/index.js @@ -28,6 +28,12 @@ const MEDIA_DIR = process.env.MARIA_BRIDGE_DIR : path.join(__dirname, 'media'); const MAX_RECONNECT_ATTEMPTS = 5; const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false'; +// Grupuri in care Maria are voie sa raspunda chiar si in modul self-chat. Testarea +// intr-un grup dedicat scoate zgomotul din chatul „Eu" — dar numai grupurile de +// aici trec, ca sa nu inceapa Maria sa vorbeasca in cele 197 de grupuri ale contului. +const ALLOWED_GROUP_JIDS = new Set( + (process.env.ALLOWED_GROUP_JIDS || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean), +); // WhatsApp invalideaza codul de asociere in cateva minute. Il tinem afisat doar // atat: un cod expirat afisat in dashboard trimite omul sa tasteze degeaba. const PAIRING_CODE_TTL_MS = 3 * 60 * 1000; @@ -230,7 +236,8 @@ async function startConnection() { || (ownLid && msg.key.remoteJid === ownLid); // Mesajele aruncate se logheaza. Fara asta, un JID care nu se potriveste cu // ownJid (ex. formatul @lid) face bridge-ul sa taca fara nicio urma in log. - if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat) { + const isAllowedGroup = isGroup && ALLOWED_GROUP_JIDS.has(msg.key.remoteJid); + if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat && !isAllowedGroup) { console.log(`[whatsapp] ignorat (nu e self-chat): remoteJid=${msg.key.remoteJid} ownJid=${ownJid} ownLid=${ownLid}`); continue; } @@ -292,6 +299,7 @@ app.get('/status', (_req, res) => { connected, phone: phoneNumber, selfChatOnly: TEST_MODE_SELF_CHAT_ONLY, + allowedGroups: [...ALLOWED_GROUP_JIDS], lid: ownLid, qr: connected ? null : currentQR, pairing: connected || !p ? null : {