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