Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.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

224 lines
8.5 KiB
Python

#!/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.
Se intreaba DUPA cautare, si numai cand nu exista acoperire in documente —
altfel gardul taia si intrebari cu raspuns („nu pot incarca factura in SPV,
imi da eroare de certificat" e in documente, dar suna a reclamatie).
Avea si un plafon de 12 cuvinte, pe ideea ca un mesaj lung spune destul. Nu
spune: „Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere
AUTO SULE" are 14 si tot nu zice CE eroare e — a trecut de gard si a primit un
raspuns inventat (2026-09-01). Lungimea nu masoara cat de precis e mesajul;
acoperirea in documente da.
"""
if are_imagine:
return False
t = rank.fold(text.strip())
if not t or rank.codes(t):
return False
return bool(_ACUZA.search(t))
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)
# --- 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."
)