Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py
Claude Agent 4d6ff1940f feat(maria): fir de discutie — continuarea nu mai e tratata ca eroare noua
Fiecare mesaj pornea de la zero. Masurat pe indexul viu, dupa o escaladare pentru
ORA-06550: "da, ma blocheaza complet" primea raspuns despre ordinul de plata la
Trezorerie (cosinus 0,642, "acoperit"), "eram la salvarea unei facturi" despre
corectia unei eFacturi (0,742), iar "am incercat si tot nu merge" cerea din nou
detaliile tocmai date. Patru cuvinte fara context seamana cu ceva din documente,
iar cautarea nu avea de unde sti ca sunt raspunsul la intrebarea Mariei.

rag/fir.py tine ancora (textul erorii), codurile ei, referinta escaladarii si
ultimele 6 schimburi, in ~/.maria-bridge/conversations/ (expira, ca si capturile).
La o continuare: cautarea e pe ancora + mesajul nou, modelul primeste istoricul,
mesajul se adauga la escaladarea deschisa de cate ori e nevoie, iar "mesaj prea
vag" nu se mai aplica. Escaladarea duce firul intreg la suport, nu un mesaj rupt
din context.

Firul se rupe doar la o captura noua sau un cod de eroare diferit — schimbarea
subiectului in cuvinte e prea usor de confundat cu o continuare.

In plus:
- Maria tace 60 min cand preia un om, dar automat doar in grupuri cu >= 2
  participanti: in self-chat totul e `fromMe`. Comenzi explicite oriunde
  ("Maria, stop" / "Maria, continua").
- ALLOWED_GROUP_JIDS: testarea se muta in grupul "Maria Test"
  (120363409761730101@g.us), ca sa nu mai poluam chatul "Eu". Filtru si in punte,
  si in consumer. NU echo-test: puntea lui Echo nu filtreaza fromMe in grupuri,
  deci cei doi boti ar intra in bucla.

Teste: 94 pass. Calibrare 23/23, cu doua cazuri de continuare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-09-01 06:16:21 +00:00

161 lines
5.3 KiB
Python

#!/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)