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

@@ -353,24 +353,28 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci
0. **intrebarea contine un cod de eroare** (`ORA-…`, `PLS-…`, `D406`) → decide
doar prezenta lui in chunk-uri, indiferent de cosinus;
1. cosinus ≥ `RANK_STRONG_COSINE` → raspundem;
2. cosinus < `RANK_WEAK_COSINE` → escaladam direct;
3. intre ele → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii
1. cosinus < `RANK_WEAK_COSINE` → escaladam direct;
2. peste → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii
distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in
chunk-urile gasite.
Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez
Fractiunea din pasul 2 nu e cosmetica: cu un singur termen gasit, „cum imi resetez
parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente.
`RANK_STRONG_COSINE` a fost 0,70 si a lasat sa treaca doua raspunsuri inventate
**Nu exista prag „cosinus destul de mare ca sa nu mai verificam".** A existat
(`RANK_STRONG_COSINE`, 0,70) si a lasat sa treaca doua raspunsuri inventate
(2026-09-01, grupul „Maria Test"): „am o factura pe luna august cu eroare in SPV"
(0,768 — nu spune ce eroare e) si o captura cu `errorMessage="CUI cumparator
incorect"` (0,778 — eroarea nu exista in documente). **Toate chunk-urile eFactura
seamana intre ele**, deci pe 0,7x cosinusul nu mai distinge „e in documente" de „e
despre eFactura"; e acelasi tip de greseala ca la codurile Oracle de mai sus, doar
ca fara cod pe care sa te sprijini. La **0,80** trec doar potrivirile aproape
textuale, iar restul cad pe dovada lexicala de la pasul 3. `ops/calibrate-rank.py`:
25/25, cu ambele cazuri adaugate la set.
incorect"` (0,778 — eroarea nu exista in documente). Toate chunk-urile eFactura
seamana intre ele, deci pe 0,7x cosinusul nu mai distinge „e in documente" de „e
despre eFactura".
L-am scos, nu urcat, fiindca **cosinusul creste cu lungimea interogarii**: pe un fir
de discutie se cauta dupa ancora plus mesajul nou, deci scorul urca la fiecare
replica fara sa apara vreo dovada in plus. Aceeasi captura: 0,778 singura, 0,836
impreuna cu mesajul de dinainte. Orice prag fix de sus e trecut de o discutie destul
de lunga. `ops/calibrate-rank.py`: 26/26, si niciunul dintre cazurile cu raspuns in
documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala.
Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde
cu 0,8 — creativitate exact acolo unde vrem sa se rezume la context.
@@ -393,9 +397,18 @@ Nu orice mesaj e o intrebare, si nu orice raspuns e util. `rag/triaj.py` ia trei
decizii inaintea modelului:
**Mesaj prea vag.** „Am o eroare" nu spune nimic. Maria cere operatiunea, ecranul si
textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat. Regula
e ingusta deliberat: doar text scurt, fara captura si fara cod de eroare, in care apar
cuvinte de acuza („nu merge", „imi da"). „Cum trimit D406" trece nestingherit.
textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat, fiindca
programatorul ar pune exact aceeasi intrebare.
Decizia se ia **dupa cautare, si numai cand nu exista acoperire in documente**. Inainte
se lua inaintea cautarii, dupa lungime: text scurt (sub 12 cuvinte), fara captura si
fara cod, cu cuvinte de acuza („nu merge", „imi da"). Gardul gresea in ambele sensuri
— „Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE"
are 14 cuvinte si tot nu zice CE eroare e (a trecut, si a primit un raspuns inventat),
iar „nu pot incarca factura in SPV, imi da eroare de certificat" are 9 si e in
documente (era oprita degeaba). Lungimea nu masoara cat de precis e mesajul;
acoperirea in documente da. Se cere o singura data pe fir: daca nici dupa ce a fost
intrebat omul nu spune mai mult, mesajul pleaca la suport.
**Erori care oricum ajung la programatori.** Dictionarul de erori Oracle are pe fiecare
intrare campul `cand suni suportul`. Noua incep cu „Intotdeauna" sau „Imediat" — nu se
@@ -437,10 +450,20 @@ La o continuare: cautarea se face pe **ancora + mesajul nou**, modelul primeste
istoricul, mesajul se adauga la escaladarea deschisa (`completari`, de cate ori e
nevoie), iar „mesaj prea vag" nu se mai aplica — detaliile au fost deja cerute.
**Ce rupe firul, necontestat:** o captura noua sau un cod de eroare diferit. Sunt
singurele semne sigure ca omul a trecut la altceva. Schimbarea subiectului in cuvinte
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).
**Ce rupe firul:** un cod de eroare pe care firul nu-l are, sau o captura venita la
mai mult de `FIR_IMAGINE_MIN` (5 min) dupa ultimul mesaj. Schimbarea subiectului in
cuvinte 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).
Orice captura a rupt firul pana pe 2026-09-01, si era gresit exact in cazul cel mai
frecvent: **omul scrie problema si trimite captura imediat dupa** (sau Maria tocmai
i-a cerut-o). WhatsApp le livreaza ca doua mesaje, dar e un singur lucru. Tratate
separat, textul se pierdea si captura se cauta doar pe OCR-ul ei; se deschideau doua
fire, se puteau deschide doua escaladari pentru aceeasi problema, iar suportul primea
doua mesaje fara legatura. Acum captura din fereastra de 5 minute continua firul:
textul citit din ea intra in ancora (cu tot cu codurile lui), cautarea se face pe
mesaj + captura, iar daca firul are deja o escaladare deschisa captura pleaca la
aceeasi referinta — cu imaginea, nu doar cu textul citit din ea.
### Completarile: trei feluri, trei raspunsuri

View File

@@ -68,6 +68,11 @@ CAZURI: list[tuple[str, bool]] = [
("O) Pers. Juridice (798) O Netrimise DI Trimise (fara raspuns) [5 Timis (cu raspuns Erori) (1). "
"O Tamise (cu diferente Reg. V2.) (1)\nMesaj raspuns. | Factura cu id incarcare=6638295792 nu po] "
"| | <Error errorMessage=\"CUI cumparator incorect\"/>", False),
# aceeasi captura, dar cautata IMPREUNA cu mesajul care a precedat-o (fir.interogare):
# textul in plus urca cosinusul la 0,836 fara sa aduca vreo dovada noua.
("Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE\n"
"Mesaj raspuns. | Factura cu id incarcare=6638295792 nu po] "
"| | <Error errorMessage=\"CUI cumparator incorect\"/>", False),
("care e capitala Frantei", False),
("cat costa un bilet de avion la Paris", False),
("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False),
@@ -119,13 +124,13 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic
return index, date
def evalueaza(index: consumer.Index, date: list[dict], strong: float, weak: float,
def evalueaza(index: consumer.Index, date: list[dict], weak: float,
min_ratio: float) -> tuple[int, list[str]]:
corecte, gresite = 0, []
for d in date:
ok, _ = rank.assess(
d["intrebare"], d["best"], d["top_texts"], index.bm25,
strong=strong, weak=weak, min_ratio=min_ratio,
weak=weak, min_ratio=min_ratio,
)
if ok == d["asteptat"]:
corecte += 1
@@ -149,28 +154,24 @@ def main() -> None:
if args.sweep:
rezultate = []
for strong in (0.62, 0.66, 0.68, 0.70, 0.72, 0.75):
for weak in (0.54, 0.58, 0.60):
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
if weak >= strong:
continue
corecte, _ = evalueaza(index, date, strong, weak, ratio)
rezultate.append((corecte, strong, weak, ratio))
for weak in (0.50, 0.54, 0.58, 0.60, 0.64):
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
corecte, _ = evalueaza(index, date, weak, ratio)
rezultate.append((corecte, weak, ratio))
rezultate.sort(key=lambda r: (-r[0], r[1]))
print(f"{'corecte':>8} strong weak ratio")
for corecte, strong, weak, ratio in rezultate[:15]:
print(f"{corecte:>5}/{len(date)} {strong:.2f} {weak:.2f} {ratio:.2f}")
print(f"{'corecte':>8} weak ratio")
for corecte, weak, ratio in rezultate[:15]:
print(f"{corecte:>5}/{len(date)} {weak:.2f} {ratio:.2f}")
return
strong, weak = rank.strong_cosine(), rank.weak_cosine()
ratio = rank.min_rare_ratio()
print(f"praguri curente: strong={strong} weak={weak} ratio={ratio}\n")
weak, ratio = rank.weak_cosine(), rank.min_rare_ratio()
print(f"praguri curente: weak={weak} ratio={ratio}\n")
for d in date:
ok, motiv = rank.assess(d["intrebare"], d["best"], d["top_texts"], index.bm25)
semn = "OK " if ok == d["asteptat"] else "GRESIT"
stare = "ACOPERIT " if ok else "ESCALADAT"
print(f"{semn} [{stare}] {d['intrebare'][:44]:46} {motiv}")
corecte, gresite = evalueaza(index, date, strong, weak, ratio)
corecte, gresite = evalueaza(index, date, weak, ratio)
print(f"\n{corecte}/{len(date)} corecte")
for g in gresite:
print(f" - {g}")

View File

@@ -32,10 +32,9 @@ OCR_TIMEOUT_S=60
# Chunk-urile se ordoneaza de doua ori — semantic (embeddings) si lexical (BM25) —
# iar clasamentele se fuzioneaza. Vezi rag/rank.py; valorile de mai jos sunt
# calibrate pe indexul real, nu alese din burta.
# Peste RANK_STRONG_COSINE raspundem din documente fara alte conditii; sub
# RANK_WEAK_COSINE escaladam direct; intre ele, raspundem doar daca termenii rari
# Sub RANK_WEAK_COSINE escaladam direct; peste, raspundem doar daca termenii rari
# din intrebare (coduri de eroare, in general) chiar apar in chunk-urile gasite.
RANK_STRONG_COSINE=0.80
# Nu exista prag „cosinus destul de mare ca sa nu mai verificam" — vezi rag/rank.py.
RANK_WEAK_COSINE=0.58
RANK_RECALL_N=12
# Ce fractiune din termenii distinctivi ai intrebarii trebuie sa apara in

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 = (

View File

@@ -27,14 +27,35 @@ def test_un_raspuns_scurt_e_continuare_nu_eroare_noua():
assert "ORA-06550" in q and "ma blocheaza" in q
def test_captura_noua_sau_alt_cod_rup_firul():
def test_alt_cod_rupe_firul():
f = fir.deschide(JID, EROARE)
assert not fir.este_continuare(f, "imi da acum ORA-12154", False)
assert not fir.este_continuare(f, "si asta ce e?", True) # captura noua
# acelasi cod nu rupe firul
assert fir.este_continuare(f, "tot ORA-06550 imi da", False)
def test_captura_imediat_dupa_mesaj_continua_firul():
"""Cazul real: omul scrie problema si trimite captura la cateva secunde."""
f = fir.deschide(JID, "am o factura pe august cu eroare la trimitere")
assert fir.este_continuare(f, "", True)
def test_captura_mult_mai_tarziu_deschide_alt_fir():
f = fir.deschide(JID, EROARE)
f["at"] = time.time() - fir.imagine_fereastra_s() - 1
assert not fir.este_continuare(f, "", True)
def test_captura_pe_fir_aduce_textul_erorii_in_ancora():
"""Ancora era reclamatia vaga; captura e cea care spune ce eroare e."""
f = fir.deschide(JID, "am o factura cu eroare la trimitere")
fir.extinde_ancora(f, "ORA-06550: line 1, column 7")
assert "ora-06550" in f["coduri"]
assert "eroare la trimitere" in f["ancora"] and "ORA-06550" in f["ancora"]
# continuarile de dupa captura se cauta cu tot cu eroare, nu doar cu reclamatia
assert "ORA-06550" in fir.interogare(f, "da, ma blocheaza")
def test_fara_fir_nu_exista_continuare():
assert not fir.este_continuare(None, "da", False)

View File

@@ -118,9 +118,21 @@ def test_assess_intrebare_vaga_in_zona_de_mijloc_escaladeaza():
def test_pragurile_vin_din_env(monkeypatch):
import config
bm = rank.Bm25(DOCS)
monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50")
covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm)
assert covered
monkeypatch.setitem(config._env, "RANK_WEAK_COSINE", "0.90")
covered, motiv = rank.assess("factura", 0.85, DOCS[:1], bm)
assert not covered and "0.9" in motiv
def test_cosinusul_mare_singur_nu_mai_e_acoperire():
"""Interogarea creste pe fir si cosinusul cu ea; dovada ramane cea lexicala.
Cazul real: aceeasi captura a dat 0,778 singura si 0,836 cu mesajul de
dinainte, fara sa apara vreo dovada noua in documente.
"""
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("cumparator incorect la incarcare", 0.84, DOCS[:1], bm)
assert not covered
assert "cumparator" in motiv
# --- coduri de eroare: identificatori exacti, nu cuvinte ---------------------

View File

@@ -49,6 +49,11 @@ def test_cand_notificarea_nu_a_plecat_nu_promitem_ca_a_plecat():
def test_prea_vag_doar_pentru_acuzatii_fara_continut():
assert triaj.prea_vag("am o eroare", False)
assert triaj.prea_vag("nu merge nimic", False)
# cazul real din 2026-09-01: lung, dar tot nu spune CE eroare e. Plafonul de
# 12 cuvinte il lasa sa treaca, si a primit un raspuns inventat.
assert triaj.prea_vag(
"Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE",
False)
# o intrebare adevarata nu se opreste ca sa i se ceara detalii
assert not triaj.prea_vag("cum trimit declaratia D406 SAF-T", False)
# un cod inseamna ca avem deja dupa ce cauta