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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ---------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user