Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py
Claude Agent 47110d71bf fix(maria): mesajul si captura sunt acelasi lucru, iar cosinusul singur nu mai e dovada
Doua greseli din grupul "Maria Test", 2026-09-01, pe care 27409fb le-a atenuat dar
nu le-a rezolvat.

1. La o reclamatie vaga Maria cauta, in loc sa intrebe. "Buna . Am si eu o factura
   pe luna august cu eroare in spv la trimitere AUTO SULE" nu spune CE eroare e —
   n-are ce cauta in documente si n-are ce trimite la suport, fiindca programatorul
   ar pune exact aceeasi intrebare. Gardul (triaj.prea_vag) exista, dar cerea text
   sub 12 cuvinte; mesajul are 14. Gresea si invers: "nu pot incarca factura in SPV,
   imi da eroare de certificat" are 9 cuvinte si ARE raspuns in documente, si era
   oprita degeaba. Lungimea nu masoara cat de precis e mesajul.
   -> gardul nu mai numara cuvinte si se aplica DUPA cautare, doar cand nu exista
      acoperire. O singura data pe fir: daca nici intrebat omul nu spune mai mult,
      mesajul pleaca la suport.

2. Textul si captura, trimise una dupa alta, erau tratate ca doua probleme fara
   legatura. `este_continuare` rupea firul la ORICE imagine — dar cazul frecvent e
   tocmai omul care scrie problema si trimite captura imediat dupa, sau care
   raspunde la "trimite-mi o captura". Textul se pierdea, captura se cauta doar pe
   OCR-ul ei, se deschideau doua fire si se puteau deschide doua escaladari pentru
   aceeasi problema.
   -> o captura in primele FIR_IMAGINE_MIN (5) minute continua firul: textul citit
      din ea intra in ancora (cu tot cu coduri), cautarea se face pe mesaj +
      captura, iar pe o escaladare deschisa pleaca la aceeasi referinta — cu
      imaginea, nu doar cu textul citit din ea (_notifica_suport ia si media).

Si, ca urmare a lui (2): RANK_STRONG_COSINE dispare. Interogarea pe un fir e ancora
plus mesajul nou, deci cosinusul urca la fiecare replica fara sa apara vreo dovada
noua — aceeasi captura da 0,778 singura si 0,836 cu mesajul de dinainte, adica peste
0,80 pus ieri. Orice prag fix de sus e trecut de o discutie destul de lunga. Acoperirea
ramane pe dovada lexicala (termenii distinctivi ai intrebarii chiar in chunk-uri),
care e stabila la lungime. Niciun caz cu raspuns in documente nu avea nevoie de
scurtatura.

ops/calibrate-rank.py: 26/26, cu interogarea combinata adaugata la set. Teste: 102 pass.
Verificat end-to-end pe scenariul real (mesaj, apoi captura la 10 secunde): intrebare
de detalii, apoi o singura escaladare care poarta si textul si captura.

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

188 lines
6.6 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 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
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?
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 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()
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)