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
This commit is contained in:
Claude Agent
2026-09-01 19:17:09 +00:00
parent 45b36591c9
commit 47110d71bf
11 changed files with 192 additions and 94 deletions

View File

@@ -57,7 +57,6 @@ DEFAULTS: dict[str, str] = {
"OCR_TIMEOUT_S": "60",
# Ordonarea chunk-urilor si pragul de la care se considera ca raspunsul chiar
# e in documente (vezi rag/rank.py pentru masuratoarea din spatele valorilor).
"RANK_STRONG_COSINE": "0.80",
"RANK_WEAK_COSINE": "0.58",
"RANK_RECALL_N": "12",
"RANK_MIN_RARE_RATIO": "0.5",

View File

@@ -357,12 +357,16 @@ def _fisier_escaladare(ref: str):
return next(iter(sorted(d.glob(f"*-{ref}.json"))), None)
def _notifica_suport(text: str) -> bool:
def _notifica_suport(text: str, imagine: str | None = None) -> bool:
"""Mesaj catre suport; cu `imagine`, chiar captura primita, nu doar textul citit."""
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)
if imagine and os.path.exists(imagine):
send_image(jid, imagine, text[:1000])
else:
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)
@@ -416,7 +420,8 @@ def completeaza(ref: str, msg: dict, text: str) -> str | None:
config.get("SUPPORT_PHONE", ""),
a_cata=intrebari - 1)
else:
_notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}")
_notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}",
imagine=(msg.get("media") or {}).get("path"))
raspuns = triaj.confirmare(ref, len(completari) - 1)
try:
@@ -517,22 +522,11 @@ def handle(index: Index, msg: dict) -> None:
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.
# 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
# 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():
if continuare and fir.get("ref") and text.strip() and not has_image:
raspuns = completeaza(fir["ref"], msg, text.strip())
if raspuns:
fir_mod.adauga(fir, "client", text)
@@ -552,7 +546,18 @@ def handle(index: Index, msg: dict) -> None:
return
if continuare:
fir_mod.adauga(fir, "client", text)
fir_mod.adauga(fir, "client", text or "[captura de ecran]")
fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "")
# Captura pe o escaladare deschisa e chiar detaliul care lipsea, nu o
# problema noua: pleaca la aceeasi referinta, cu tot cu imagine.
if fir.get("ref") and has_image:
raspuns = completeaza(fir["ref"], msg, msg.get("ocr_text") or text)
if raspuns:
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
search_query = fir_mod.interogare(fir, search_query)
print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}",
file=sys.stderr)
@@ -571,6 +576,17 @@ def handle(index: Index, msg: dict) -> None:
print(f"[consumer] rank: {surse} | {result['reason']}", file=sys.stderr)
if not result["covered"]:
# „Am o eroare la o factura", fara sa spuna care: nu avem ce cauta si nici
# ce trimite la suport — programatorul ar pune exact aceeasi intrebare.
# O singura data pe fir: daca nici dupa ce a fost intrebat nu spune mai
# mult, mesajul pleaca la om, care poate insista altfel.
if triaj.prea_vag(text, has_image) and not fir.get("detalii_cerute"):
fir["detalii_cerute"] = True
fir_mod.adauga(fir, "maria", triaj.CERE_DETALII)
fir_mod.salveaza(fir)
print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr)
send_reply(sender, triaj.CERE_DETALII)
return
print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr)
record = escalate(msg, question, result, fir)
sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED

View File

@@ -43,6 +43,11 @@ def ttl_s() -> int:
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
@@ -96,20 +101,42 @@ def adauga(fir: dict, rol: str, text: str) -> None:
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.
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 False
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()

View File

@@ -38,20 +38,6 @@ import config
RRF_K = 60 # constanta standard din Reciprocal Rank Fusion
def strong_cosine() -> float:
"""Peste atat, singur cosinusul e de ajuns (RANK_STRONG_COSINE).
Era 0,70 si a fost prea jos: pe 2026-09-01, „am o factura cu eroare in SPV"
(0,768) si o captura cu `errorMessage="CUI cumparator incorect"` (0,778) au
trecut drept acoperite, desi prima nu spune ce eroare e, iar a doua nu exista
in documente. Toate chunk-urile eFactura seamana intre ele: 0,7x nu distinge
„e in documente" de „e despre eFactura". La 0,80 trec doar potrivirile
aproape textuale, restul cad pe dovada lexicala (termenii rari din intrebare
chiar in chunk-uri). Verificat cu ops/calibrate-rank.py: 25/25.
"""
return float(config.get("RANK_STRONG_COSINE", "0.80"))
def weak_cosine() -> float:
"""Sub atat nu salvam raspunsul nici cu potrivire lexicala (RANK_WEAK_COSINE)."""
return float(config.get("RANK_WEAK_COSINE", "0.58"))
@@ -229,7 +215,6 @@ def assess(
top_texts: list[str],
bm25: Bm25,
*,
strong: float | None = None,
weak: float | None = None,
min_ratio: float | None = None,
) -> tuple[bool, str]:
@@ -237,18 +222,26 @@ def assess(
Cazuri, in ordine:
0. intrebarea contine coduri de eroare -> decide DOAR prezenta lor in
chunk-uri, si cand cosinusul e mare (vezi mai jos);
1. cosinus mare -> raspundem, indiferent de lexical;
2. cosinus la mijloc, dar o fractiune suficienta din termenii rari ai
intrebarii apare chiar in chunk-uri (tipic: un cod de eroare) -> raspundem;
3. altfel -> nu avem acoperire, intrebarea pleaca la suport.
chunk-uri;
1. cosinus sub `weak` -> nici nu ne uitam mai departe, pleaca la suport;
2. altfel -> raspundem doar daca o fractiune suficienta din termenii rari ai
intrebarii apare chiar in chunk-urile gasite.
Cazul 0 exista fiindca cosinusul mare NU e o dovada cand e vorba de coduri:
doua erori Oracle diferite se scriu aproape la fel („[Oracle][ODBC][Ora]ORA-…"),
deci un chunk despre ORA-12541 sta la 0,74 de o intrebare despre ORA-06550.
Fara asta, Maria raspundea sigur pe ea cu eroarea gresita, in loc sa escaladeze.
A existat si un prag „cosinus mare -> raspundem, indiferent de lexical"
(`RANK_STRONG_COSINE`, 0,70). L-am scos, nu urcat, si nu doar fiindca a lasat
sa treaca doua raspunsuri inventate pe 2026-09-01: **cosinusul creste cu
lungimea interogarii**. Pe un fir de discutie interogarea e ancora plus
mesajul nou, deci urca la fiecare replica fara sa aduca vreo dovada — aceeasi
captura a dat 0,778 singura si 0,836 cu mesajul de dinainte. Orice prag fix
de sus e trecut mai devreme sau mai tarziu de o discutie destul de lunga.
Verificat pe setul de cazuri: niciunul dintre cele cu raspuns in documente nu
avea nevoie de scurtatura — toate trec pe dovada lexicala.
"""
strong = strong_cosine() if strong is None else strong
weak = weak_cosine() if weak is None else weak
min_ratio = min_rare_ratio() if min_ratio is None else min_ratio
cerute = codes(query)
@@ -261,13 +254,11 @@ def assess(
f"intrebarea are coduri ({', '.join(cerute[:4])}) care nu apar in "
f"documente (cosinus {best_cosine:.3f})"
)
if best_cosine >= strong:
return True, f"cosinus {best_cosine:.3f} >= {strong}"
if best_cosine < weak:
return False, f"cosinus {best_cosine:.3f} < {weak}"
rare = bm25.rare_terms(query)
if not rare:
return False, f"cosinus {best_cosine:.3f} la limita, iar intrebarea nu are termeni distinctivi"
return False, f"cosinus {best_cosine:.3f}, iar intrebarea nu are termeni distinctivi"
blob = fold("\n".join(top_texts))
gasiti = [t for t in rare if t in blob]
if gasiti and len(gasiti) / len(rare) >= min_ratio:
@@ -277,6 +268,6 @@ def assess(
)
lipsa = [t for t in rare if t not in blob]
return False, (
f"cosinus {best_cosine:.3f} la limita, iar termenii distinctivi "
f"cosinus {best_cosine:.3f}, iar termenii distinctivi "
f"({', '.join(lipsa[:5])}) nu apar in documente"
)

View File

@@ -66,18 +66,22 @@ def nivel_suport(chunk: str) -> str | None:
def prea_vag(text: str, are_imagine: bool) -> bool:
"""Mesajul anunta o problema fara sa spuna care.
Deliberat ingust: doar text scurt, fara captura si fara cod de eroare. O
intrebare adevarata („cum trimit D406") nu contine cuvintele de acuza, iar una
cu un cod are deja tot ce trebuie ca sa fie cautata.
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
if not _ACUZA.search(t):
return False
return len(rank.tokenize(t)) <= 12
return bool(_ACUZA.search(t))
CERE_DETALII = (