diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index c21fb64..e07cf8d 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -429,6 +429,23 @@ singurele semne sigure ca omul a trecut la altceva. Schimbarea subiectului in cu 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). +### Completarile: trei feluri, trei raspunsuri + +Cat timp firul are o escaladare deschisa, mesajele urmatoare sunt completari la ea. +Decizia se ia **inaintea cautarii si a confirmarii** „caut informatia": raspunsul vine +instant, deci un „revin imediat" ar fi o promisiune inutila, iar embedding-ul (~1-2s) +s-ar calcula degeaba — asta se si intampla inainte, la fiecare „tot nimic". + +| Ce scrie omul | Ce face Maria | +|---|---| +| „este foarte urgent", „ma blocheaza" | marcheaza escaladarea `urgenta`, trimite la suport un mesaj cu **URGENT** in cap | +| „cat mai dureaza?", „nu m-a contactat nimeni" | spune de cat timp e trimisa problema si reaminteste echipei — **cel mult o data la 15 minute** (`REAMINTIRE_MIN`) | +| orice alt detaliu | confirmare scurta, cu formularea rotita | + +Peste 30 de minute fara raspuns, Maria n-o mai da cu „echipa vede detaliul cand preia +problema": spune ca nu i-a raspuns nimeni si indruma spre telefon (`SUPPORT_PHONE`, +daca e completat). Omul stie oricum de cat timp asteapta — o formula l-ar enerva. + ### Cand preia un om Daca cineva din echipa scrie in discutie, Maria tace `FIR_TACERE_MIN` minute (60), ca 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 80feb8c..18c4b87 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example @@ -50,6 +50,8 @@ RANK_MIN_RARE_RATIO=0.5 # "@g.us" pentru un grup. Numarul se scrie international, fara "+". # Gol = escaladarile se scriu doar in ~/.maria-bridge/escalations/, fara notificare. SUPPORT_JID= +# Numarul spus clientului cand asteapta de peste 30 de minute fara raspuns. +SUPPORT_PHONE= # --- Backend LLM + embeddings ----------------------------------------------- # Modelul de chat folosit pentru raspunsuri (format compatibil OpenAI 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 ec5def4..07ec05a 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -64,6 +64,9 @@ DEFAULTS: dict[str, str] = { # "40712345678@s.whatsapp.net" sau "1203...@g.us" pentru un grup. # Gol = escaladarile se scriu doar in jurnal (~/.maria-bridge/escalations/). "SUPPORT_JID": "", + # Numarul de telefon al suportului, spus omului cand asteapta de prea mult + # timp fara ca cineva sa preia (vezi triaj.raspuns_stare). Gol = nu se da. + "SUPPORT_PHONE": "", # Tinta rclone pentru sincronizarea depozitului de documente, ex: # "gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O:" (dosarul # document_store din Drive, vazut pe Windows ca D:\GoogleDrive\romfast\document_store). 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 139289f..5e7b561 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -80,10 +80,6 @@ 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." -) def bridge_url() -> str: @@ -346,40 +342,84 @@ def escalate(msg: dict, question: str, search_result: dict, fir: dict | None = N return record -def append_followup(ref: str, msg: dict, text: str) -> bool: - """Adauga raspunsul omului la escaladarea deschisa si il trimite la suport. +REAMINTIRE_MIN = 15 # cat de rar poate Maria sa reaminteasca aceeasi problema - 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. - """ + +def _fisier_escaladare(ref: str): 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: + if not d.exists(): + return None + return next(iter(sorted(d.glob(f"*-{ref}.json"))), None) + + +def _notifica_suport(text: str) -> bool: + 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) + return True + except Exception as exc: # noqa: BLE001 + print(f"[consumer] mesaj catre suport netrimis: {exc}", file=sys.stderr) + return False + + +def completeaza(ref: str, msg: dict, text: str) -> str | None: + """Adauga mesajul la escaladarea deschisa si intoarce ce i se raspunde omului. + + `None` daca escaladarea nu mai exista (atunci mesajul se trateaza normal). + + Trei feluri de completare, cu trei raspunsuri diferite: un semnal de urgenta + schimba escaladarea si anunta echipa; o intrebare de stare primeste ce stim + (de cat timp asteapta, daca am reamintit); un detaliu primeste o confirmare + scurta, rotita, ca sa nu sune a robot la a treia oara. + """ + fisier = _fisier_escaladare(ref) + if fisier is None: + return None 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")}) + return None + + fel = triaj.fel_completare(text) + completari = record.setdefault("completari", []) + completari.append({"at": time.time(), "text": text, "fel": fel, + "message_id": msg.get("id")}) + cine = msg.get("pushName") or msg.get("from") or "necunoscut" + minute = int((time.time() - record.get("at", time.time())) / 60) + + if fel == "urgenta": + record["urgenta"] = True + trimis = _notifica_suport( + f"[Maria] URGENT — {ref} (de la {cine})\n{text[:800]}") + record["ultima_reamintire"] = time.time() + raspuns = triaj.URGENTA_MARCATA.format(ref=ref) + elif fel == "stare": + # Reamintirea e limitata: la fiecare „tot nimic" nu suna telefonul echipei. + de_reamintit = time.time() - record.get("ultima_reamintire", record.get("at", 0)) \ + > REAMINTIRE_MIN * 60 + reamintit = False + if de_reamintit: + reamintit = _notifica_suport( + f"[Maria] {ref}: clientul intreaba de {minute} minute daca s-a " + f"rezolvat.\n{text[:400]}") + if reamintit: + record["ultima_reamintire"] = time.time() + intrebari = sum(1 for c in completari if c.get("fel") == "stare") + raspuns = triaj.raspuns_stare(ref, minute, reamintit, + config.get("SUPPORT_PHONE", ""), + a_cata=intrebari - 1) + else: + _notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}") + raspuns = triaj.confirmare(ref, len(completari) - 1) + 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 + print(f"[consumer] completare '{fel}' la {ref}: {text[:60]!r}", file=sys.stderr) + return raspuns _GRUPURI_CACHE: dict = {"at": 0.0, "date": {}} @@ -483,6 +523,20 @@ def handle(index: Index, msg: dict) -> None: 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(): + raspuns = completeaza(fir["ref"], msg, text.strip()) + if raspuns: + fir_mod.adauga(fir, "client", text) + 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 + send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT) question, search_query, immediate = prepare_query(msg) @@ -511,15 +565,6 @@ 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, fir) 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 9e53a29..4953b90 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py @@ -127,3 +127,93 @@ def raspuns_escaladat(chunk: str, nivel: str, ref: str, notificat: bool, ) parti.append(INTREBARE_URGENTA) return "\n\n".join(parti) + + +# --- ce fel de completare e raspunsul omului -------------------------------- + +_URGENTA = re.compile( + r"\b(urgent|urgenta|foarte urgent|ma blocheaza|blocheaza|nu pot lucra|" + r"nu pot factura|nu merge nimic|sta toata firma|s-a oprit tot)\b" +) +_STARE = re.compile( + r"(cat mai dureaza|cat dureaza|cand ma contacteaza|nu m-?a contactat|" + r"nu m-?a sunat|tot nimic|nimic inca|chiar nimic|ati uitat|mai dureaza|" + r"s-a rezolvat|ce se intampla|vreo veste|veste|noutati|s-a miscat ceva|" + r"aveti un raspuns)" +) + + +def fel_completare(text: str) -> str: + """`"urgenta"` / `"stare"` / `"detaliu"` — ce vrea omul cu mesajul asta. + + Trei lucruri diferite primeau acelasi raspuns sablon („am adaugat si asta la + M-…"): un semnal de urgenta, o intrebare la care Maria nici nu raspundea, si un + detaliu in plus. Ordinea conteaza: „urgent, cat mai dureaza?" e intai urgenta. + """ + t = fold_text(text) + if _URGENTA.search(t): + return "urgenta" + if _STARE.search(t): + return "stare" + return "detaliu" + + +def fold_text(text: str) -> str: + return rank.fold(text or "") + + +# Confirmari rotite: trei mesaje identice unul dupa altul suna a robot. +_CONFIRMARI = ( + "Am adaugat si asta la {ref}.", + "Notat — am trecut si asta in {ref}.", + "Am completat {ref} cu ce mi-ai spus.", +) + +URGENTA_MARCATA = ( + "Am marcat problema ca urgenta si am anuntat echipa ({ref}). " + "Daca intre timp te blocheaza complet, spune-mi." +) + + +def confirmare(ref: str, a_cata: int) -> str: + return _CONFIRMARI[a_cata % len(_CONFIRMARI)].format(ref=ref) + + +def minute_text(n: int) -> str: + """„3 minute", dar „25 de minute" — regula romaneasca a lui „de" peste 19.""" + return f"{n} minute" if n < 20 else f"{n} de minute" + + +# Cand omul intreaba a doua oara si nu s-a schimbat nimic, nu repetam acelasi text. +_ASTEPTARE = ( + "Inca nimic nou de la echipa. Te anunt imediat ce am un raspuns.", + "Inca nu mi-a raspuns nimeni. Problema e in lista lor, cu {ref}.", +) + + +def raspuns_stare(ref: str, minute: int, reamintit: bool, telefon: str = "", + a_cata: int = 0) -> str: + """Ce se raspunde la „cat mai dureaza?" — cu ce stim, nu cu formule. + + Peste o jumatate de ora fara raspuns, „echipa vede detaliul cand preia + problema" nu mai e un raspuns onest: omul asteapta de o jumatate de ora si o + stie mai bine decat noi. + """ + unde = f" la {telefon}" if telefon else "" + if minute >= 30: + return ( + f"Au trecut {minute_text(minute)} de cand am trimis problema ({ref}) si inca " + f"nu mi-a raspuns nimeni. Nu vreau sa te tin cu vorbe: daca te blocheaza, " + f"suna direct la suport{unde}." + ) + if reamintit: + return ( + f"Am trimis problema acum {minute_text(minute)} (referinta {ref}) si tocmai " + f"am reamintit echipei. Inca nu am un raspuns de la ei." + ) + if a_cata > 0: + return _ASTEPTARE[(a_cata - 1) % len(_ASTEPTARE)].format(ref=ref) + return ( + f"Am trimis problema acum {minute_text(minute)} (referinta {ref}) si inca nu am " + f"un raspuns de la ei. Echipa e deja anuntata." + ) 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 fa6bcba..b1c66eb 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 @@ -67,32 +67,105 @@ def _escaladare(ref: str) -> None: json.dumps({"ref": ref, "text": "eroare initiala"}), encoding="utf-8") -def test_raspunsul_despre_urgenta_intra_in_escaladarea_deschisa(monkeypatch): +def _fara_suport(monkeypatch): + import config + monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID" + else config.DEFAULTS.get(k, d)) + + +def test_completarea_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)) + _fara_suport(monkeypatch) _escaladare("M-TEST-1") - assert consumer.append_followup("M-TEST-1", {"id": "x"}, "ma blocheaza, nu pot factura") + raspuns = consumer.completeaza("M-TEST-1", {"id": "x"}, "eram la salvarea unei facturi") + assert "M-TEST-1" in raspuns 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" + assert record["completari"][0]["text"] == "eram la salvarea unei facturi" + assert record["completari"][0]["fel"] == "detaliu" -def test_completarile_se_aduna_nu_se_inlocuiesc(monkeypatch): - """Omul raspunde de mai multe ori; prima data nu inchide firul.""" +def test_completarile_se_aduna_si_nu_repeta_acelasi_text(monkeypatch): + """Trei confirmari identice una dupa alta suna a robot.""" import config import consumer - monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID" - else config.DEFAULTS.get(k, d)) + _fara_suport(monkeypatch) _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") + raspunsuri = [consumer.completeaza("M-TEST-3", {"id": str(i)}, t) for i, t in enumerate( + ["eram la facturi", "si la rapoarte", "si la import"])] + assert len(set(raspunsuri)) == 3 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"] + assert len(record["completari"]) == 3 + + +def test_urgenta_marcheaza_escaladarea_si_anunta(monkeypatch): + import config + import consumer + + trimise = [] + monkeypatch.setattr(consumer, "_notifica_suport", + lambda text: trimise.append(text) or True) + _escaladare("M-TEST-U") + raspuns = consumer.completeaza("M-TEST-U", {"id": "u"}, "Este foarte urgent, ma blocheaza") + + record = json.loads(next((config.STATE_DIR / "escalations").glob("*-M-TEST-U.json")) + .read_text(encoding="utf-8")) + assert record["urgenta"] is True + assert record["completari"][0]["fel"] == "urgenta" + assert trimise and trimise[0].startswith("[Maria] URGENT") + assert "urgenta" in raspuns.lower() + + +def test_intrebarea_de_stare_primeste_un_raspuns_nu_o_confirmare(monkeypatch): + """„Nu m-a contactat nimeni. Cat mai dureaza?" primea „am adaugat si asta".""" + import config + import consumer + + trimise = [] + monkeypatch.setattr(consumer, "_notifica_suport", + lambda text: trimise.append(text) or True) + _escaladare("M-TEST-S") + raspuns = consumer.completeaza("M-TEST-S", {"id": "s"}, + "Nu m-a contactat nimeni. Cat mai dureaza?") + assert "am adaugat" not in raspuns.lower() + assert "M-TEST-S" in raspuns + assert trimise and "intreaba" in trimise[0] + + # a doua intrebare la un minut distanta nu mai suna telefonul echipei + trimise.clear() + consumer.completeaza("M-TEST-S", {"id": "s2"}, "tot nimic") + assert not trimise + + +def test_dupa_o_jumatate_de_ora_maria_o_spune_pe_fata(): + import triaj + text = triaj.raspuns_stare("M-1", 42, False, telefon="0241 000 000") + assert "42" in text and "0241 000 000" in text + assert "nu mi-a raspuns nimeni" in text + + +def test_fel_completare(): + import triaj + assert triaj.fel_completare("Este foarte urgent") == "urgenta" + assert triaj.fel_completare("ma blocheaza complet") == "urgenta" + assert triaj.fel_completare("Cat mai dureaza?") == "stare" + assert triaj.fel_completare("Tot nimic") == "stare" + assert triaj.fel_completare("eram la salvarea unei facturi") == "detaliu" + # ordinea conteaza: urgenta bate intrebarea de stare + assert triaj.fel_completare("urgent, cat mai dureaza?") == "urgenta" + + +def test_a_doua_intrebare_de_stare_nu_repeta_acelasi_text(): + import triaj + intai = triaj.raspuns_stare("M-1", 10, False, a_cata=0) + apoi = triaj.raspuns_stare("M-1", 11, False, a_cata=1) + assert intai != apoi + # „acum 10 minute", nu „acum 10 de minute" + assert "10 minute" in intai and "10 de minute" not in intai + assert triaj.minute_text(25) == "25 de minute"