feat(maria): rank hibrid, escaladare la suport si dictionar de erori Oracle

Trei probleme legate, gasite testand cu capturi reale.

1. RASPUNSURI GENERICE. Cand raspunsul nu era in documente, modelul primea
   oricum top-3 chunk-uri si compunea ceva plauzibil — utilizatorul pleca cu
   impresia ca a primit ajutor. Acum, daca nu avem acoperire, modelul nu mai e
   intrebat deloc: intrebarea pleaca la suport, cu referinta (M-260831-A1B2) si
   confirmare onesta — "am trimis" doar daca notificarea chiar a plecat, altfel
   "am inregistrat, dar nu am putut trimite". Jurnalul se scrie intotdeauna, in
   ~/.maria-bridge/escalations/, si se vede in dashboard. Daca a fost o captura,
   imaginea insasi se retrimite la suport (endpoint nou /send-image, limitat la
   MEDIA_DIR).

2. ORDONARE (rank.py). Un prag pe cosinus nu putea decide "avem raspunsul?":
   masurat pe indexul viu, intrebarile bune dau 0,600-0,816 si cele straine
   0,534-0,685 — intervale care SE SUPRAPUN. Lipseau codurile: ORA-01722, D406,
   CIF sunt tokeni exacti pe care cautarea semantica ii topeste. Acum se ordoneaza
   de doua ori — embeddings si BM25 — si se fuzioneaza clasamentele (RRF), iar
   decizia de acoperire se ia pe dovezi: cosinus mare, sau cosinus de mijloc cu o
   fractiune din termenii distinctivi gasita chiar in chunk-uri. Fractiunea conteaza:
   cu un singur termen, "cum imi resetez parola de la Windows" trecea drept
   acoperita fiindca "parola" apare in documente.
   Pragurile sunt masurate, nu alese: ops/calibrate-rank.py ruleaza un set de cazuri
   pe indexul real si matura grila. 15/15 la strong=0.70 weak=0.58 ratio=0.5.

3. DICTIONAR DE ERORI ORACLE. 29 de erori uzuale, scrise pentru utilizatorul din
   fata aplicatiei: ce inseamna, ce poate incerca singur, cand sa sune. Fara nume
   de servere, IP-uri sau pasi de administrare — Maria e chatbot pentru clienti.
   Fisierul din git e SAMANTA: depozitul e oglinda Drive-ului, deci trebuie pus in
   document_store ca sa nu dispara la sincronizare.

Pe drum, doua lucruri gasite in log:
- fiecare mesaj din self-chat sosea de DOUA ori, pe @s.whatsapp.net si pe @lid,
  deci Maria raspundea de doua ori. Dedup pe key.id in punte.
- reindexarea reface toate embeddings-urile (~7 s fiecare pe CPU): adaugarea unui
  document la 170 de chunk-uri insemna 20 de minute. Acum refoloseste vectorii
  chunk-urilor nemodificate din indexul precedent, fara vreun fisier nou de stare.

Textul OCR se logheaza acum INTEGRAL, cu interogarea de cautare si sursele alese
cu scorurile lor — fara asta un raspuns gresit nu se poate explica: nu stiai daca
a citit prost captura sau a cautat prost in documente.

6 fisiere de test noi/extinse, 68 pass (de la 42).

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-08-31 22:08:50 +00:00
parent ecf5f2fa7a
commit ca6b53e0ca
15 changed files with 1455 additions and 71 deletions

View File

@@ -0,0 +1,126 @@
"""Escaladarea la suport: jurnalul si notificarea.
Nu atinge reteaua — `requests.post` e inlocuit si se verifica CE s-ar fi trimis.
"""
from __future__ import annotations
import json
import pytest
import config
import consumer
@pytest.fixture
def apeluri(monkeypatch):
"""Prinde apelurile HTTP catre punte, fara sa le execute."""
prinse = []
class Raspuns:
status_code = 200
def raise_for_status(self):
pass
def fals_post(url, json=None, timeout=None):
prinse.append((url, json))
return Raspuns()
monkeypatch.setattr(consumer.requests, "post", fals_post)
return prinse
@pytest.fixture
def rezultat_fara_acoperire():
return {
"chunks": [], "covered": False, "best_cosine": 0.55,
"reason": "cosinus 0.550 < 0.58",
"top": [{"source": "efactura_knowledge.md", "cosine": 0.55, "bm25": 0.0}],
}
def _mesaj(**kw):
baza = {"from": "40712345678@s.whatsapp.net", "pushName": "Ion",
"id": "ABC123", "text": "cum schimb uleiul la masina"}
baza.update(kw)
return baza
def test_jurnalul_se_scrie_si_fara_canal_de_suport(apeluri, rezultat_fara_acoperire, tmp_path):
# SUPPORT_JID nesetat: nu notificam pe nimeni, dar intrebarea nu are voie sa
# dispara fara urma — asta e chiar problema pe care escaladarea o rezolva.
consumer.escalate(_mesaj(), "cum schimb uleiul la masina", rezultat_fara_acoperire)
fisiere = list((config.STATE_DIR / "escalations").glob("*.json"))
assert len(fisiere) == 1
inregistrare = json.loads(fisiere[0].read_text(encoding="utf-8"))
assert inregistrare["notified"] is False
assert inregistrare["reason"] == "cosinus 0.550 < 0.58"
assert inregistrare["best_cosine"] == 0.55
assert not apeluri
def test_referinta_e_stabila_pentru_acelasi_mesaj():
# Se poate cita la telefon si nu se schimba daca escaladarea se reia.
a = consumer.reference(1788212023.0, "ABC123")
b = consumer.reference(1788212023.0, "ABC123")
assert a == b
assert a.startswith("M-")
assert consumer.reference(1788212023.0, "ALT") != a
def test_referinta_ajunge_si_la_suport_si_in_jurnal(apeluri, rezultat_fara_acoperire, monkeypatch):
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
inreg = consumer.escalate(_mesaj(), "intrebare", rezultat_fara_acoperire)
_, corp = apeluri[0]
assert inreg["ref"] in corp["text"]
pe_disc = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
assert pe_disc["ref"] == inreg["ref"]
def test_notifica_suportul_ca_text(apeluri, rezultat_fara_acoperire, monkeypatch):
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
consumer.escalate(_mesaj(), "cum schimb uleiul la masina", rezultat_fara_acoperire)
url, corp = apeluri[0]
assert url.endswith("/send")
assert corp["to"] == "40799999999@s.whatsapp.net"
assert "Ion" in corp["text"]
assert "cum schimb uleiul" in corp["text"]
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
assert inregistrare["notified"] is True
def test_captura_pleaca_la_suport_ca_imagine(apeluri, rezultat_fara_acoperire, monkeypatch, tmp_path):
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
captura = tmp_path / "captura.png"
captura.write_bytes(b"png")
msg = _mesaj(text="", media={"path": str(captura)}, ocr_text="Eroare necunoscuta XYZ")
consumer.escalate(msg, "intrebare", rezultat_fara_acoperire)
url, corp = apeluri[0]
assert url.endswith("/send-image")
assert corp["path"] == str(captura)
assert "Eroare necunoscuta XYZ" in corp["caption"]
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
assert inregistrare["had_image"] is True
assert inregistrare["ocr_text"] == "Eroare necunoscuta XYZ"
def test_notificarea_esuata_nu_pierde_jurnalul(rezultat_fara_acoperire, monkeypatch):
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
def cade(*a, **kw):
raise RuntimeError("puntea nu raspunde")
monkeypatch.setattr(consumer.requests, "post", cade)
consumer.escalate(_mesaj(), "intrebare", rezultat_fara_acoperire)
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
assert inregistrare["notified"] is False
assert "puntea nu raspunde" in inregistrare["notify_error"]

View File

@@ -54,19 +54,42 @@ def test_legenda_intra_si_in_intrebare_si_in_cautare(imagine):
assert cautare.splitlines()[0] == "nu merge raportul"
def test_captura_se_sterge_dupa_citire(imagine):
def test_captura_ramane_pe_disc_pana_e_tratat_mesajul(imagine):
# Nu se sterge la OCR: mai e nevoie de ea daca intrebarea pleaca la suport,
# unde se retrimite ca imagine.
msg, fisier = imagine("ORA-01722: invalid number")
consumer.prepare_query(msg)
# capturile pot contine date de client — nu raman pe disc dupa OCR
assert fisier.exists()
def test_cleanup_media_sterge_captura(imagine):
# capturile pot contine date de client — nu raman pe disc dupa tratarea mesajului
msg, fisier = imagine("ORA-01722: invalid number")
consumer.prepare_query(msg)
consumer.cleanup_media(msg)
assert not fisier.exists()
def test_cleanup_media_tolereaza_lipsa_fisierului(imagine):
msg, fisier = imagine("ceva")
fisier.unlink()
consumer.cleanup_media(msg) # nu trebuie sa arunce
consumer.cleanup_media({"text": "fara imagine"})
def test_captura_ilizibila_cere_textul_erorii(imagine):
msg, fisier = imagine("")
msg, _ = imagine("")
intrebare, _, imediat = consumer.prepare_query(msg)
assert intrebare is None
assert imediat == consumer.NO_TEXT_IN_IMAGE
assert not fisier.exists()
def test_textul_ocr_ramane_pe_mesaj_pentru_jurnal(imagine):
# escalate() il scrie in jurnalul de escaladari; fara el, o intrebare trimisa
# la suport ar ajunge acolo fara continutul capturii
msg, _ = imagine("ORA-01722: invalid number")
consumer.prepare_query(msg)
assert msg["ocr_text"] == "ORA-01722: invalid number"
def test_ocr_esuat_dar_cu_legenda_raspunde_pe_legenda(imagine):

View File

@@ -0,0 +1,122 @@
"""Ordonarea hibrida si decizia de escaladare.
Fara retea: embeddings-urile sunt inlocuite cu scoruri cosinus date direct, ca sa
se testeze fuziunea si pragurile, nu Ollama.
"""
from __future__ import annotations
import rank
DOCS = [
"mesaj eroare: ORA-01722 invalid number. rezolvare: verifica formatul coloanei numerice",
"mesaj eroare: token efactura expirat. rezolvare: reautorizeaza aplicatia in SPV",
"cum se trimite declaratia D406 SAF-T catre ANAF din meniul Rapoarte",
"instructiuni pentru inregistrarea unei facturi de achizitie in modulul Facturare",
]
def test_tokenize_pastreaza_codurile_intregi():
assert "ora-01722" in rank.tokenize("Imi da ORA-01722 la salvare")
# diacriticele nu trebuie sa rupa potrivirea
assert rank.tokenize("factură") == rank.tokenize("factura")
def test_tokenize_scoate_cuvintele_de_umplutura():
assert rank.tokenize("cum se salveaza si de ce nu") == ["salveaza"]
def test_bm25_gaseste_codul_de_eroare():
bm = rank.Bm25(DOCS)
scoruri = bm.scores("ORA-01722")
assert scoruri[0] > 0
assert max(scoruri) == scoruri[0]
def test_bm25_zero_cand_nu_exista_niciun_token_comun():
bm = rank.Bm25(DOCS)
assert all(s == 0 for s in bm.scores("uleiul de motor al masinii"))
def test_rare_terms_prinde_codurile_si_ignora_cuvintele_comune():
bm = rank.Bm25(DOCS)
rare = bm.rare_terms("ORA-01722 la factura")
assert "ora-01722" in rare
def test_rank_urca_chunkul_confirmat_de_ambele_metode():
# cosinusul favorizeaza documentul 3, dar lexical se potriveste documentul 0;
# documentul 0 e sustinut de amandoua (cosinus decent + BM25 mare) si iese primul
cos = [0.62, 0.30, 0.31, 0.64]
bm = [8.0, 0.0, 0.0, 0.0]
assert rank.rank(cos, bm)[0] == 0
def test_rank_fara_potrivire_lexicala_ramane_ordinea_semantica():
cos = [0.30, 0.40, 0.80, 0.20]
assert rank.rank(cos, [0.0, 0.0, 0.0, 0.0])[0] == 2
def test_assess_cosinus_mare_raspunde():
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("token efactura expirat", 0.77, DOCS[1:2], bm)
assert covered
assert "0.770" in motiv or "0.77" in motiv
def test_assess_cosinus_mic_escaladeaza():
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("cum schimb uleiul la masina", 0.53, DOCS[:1], bm)
assert not covered
assert "0.53" in motiv
def test_assess_zona_de_mijloc_salvata_de_codul_de_eroare():
# Exact cazul pentru care exista partea lexicala: cosinusul e mediocru, dar
# codul din intrebare apare textual in chunk.
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("ORA-01722", 0.61, DOCS[:1], bm)
assert covered
assert "ora-01722" in motiv
def test_assess_zona_de_mijloc_fara_termeni_in_context_escaladeaza():
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("ORA-99999", 0.61, DOCS[:1], bm)
assert not covered
assert "nu apar in documente" in motiv
def test_assess_un_singur_termen_din_multi_nu_e_dovada():
# Cazul care a scos la iveala nevoia de fractiune: „resetez parola de la Windows"
# era salvata gresit doar fiindca „parola" apare in documente. Un termen din
# trei nu inseamna ca intrebarea e acoperita.
bm = rank.Bm25(DOCS + ["reautorizeaza aplicatia si schimba parola in SPV"])
covered, motiv = rank.assess("cum resetez parola de la Windows", 0.64,
["reautorizeaza aplicatia si schimba parola in SPV"], bm)
assert not covered
assert "windows" in motiv
def test_assess_motivul_numeste_termenii_lipsa_nu_pe_toti():
bm = rank.Bm25(DOCS)
_, motiv = rank.assess("ORA-01722 la imprimanta LaserJet", 0.61, DOCS[:1], bm)
# codul chiar apare in context, deci nu are ce cauta in lista lipsurilor
assert "ora-01722" not in motiv
assert "imprimanta" in motiv
def test_assess_intrebare_vaga_in_zona_de_mijloc_escaladeaza():
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("nu merge", 0.61, DOCS[:1], bm)
assert not covered
assert "distinctivi" in motiv
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

View File

@@ -179,3 +179,61 @@ def test_xml_valid_nu_produce_avertismente(write, monkeypatch):
write("bun.xml", "<root><a>x</a></root>")
monkeypatch.setattr(indexer, "embed", lambda text: [0.0])
assert "warnings" not in indexer.build()
# --- refolosirea embeddings-urilor la reindexare -----------------------------
def test_vectorii_din_indexul_vechi_se_refolosesc(monkeypatch, write, tmp_path):
"""Un document nou nu are voie sa reforteze embeddings-urile celorlalte.
Pe CPU un embedding costa ~7 secunde; fara refolosire, adaugarea unui
document la un index de 170 de chunk-uri inseamna 20 de minute de asteptare.
"""
import json
import config
import indexer
write("primul.md", "text vechi, deja indexat")
calculate = []
def fals_embed(chunk):
calculate.append(chunk)
return [0.5, 0.5]
monkeypatch.setattr(indexer, "embed", fals_embed)
indexer.build()
assert calculate == ["text vechi, deja indexat"]
calculate.clear()
write("al-doilea.md", "text nou")
rezultat = indexer.build()
# doar chunk-ul nou s-a calculat
assert calculate == ["text nou"]
assert rezultat["embeddings_noi"] == 1
assert rezultat["embeddings_refolosite"] == 1
index = json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
assert {e["source"] for e in index} == {"primul.md", "al-doilea.md"}
def test_textul_modificat_se_reembedeaza(monkeypatch, write):
import indexer
write("doc.md", "prima varianta")
monkeypatch.setattr(indexer, "embed", lambda chunk: [1.0])
indexer.build()
calculate = []
def fals_embed(chunk):
calculate.append(chunk)
return [2.0]
monkeypatch.setattr(indexer, "embed", fals_embed)
write("doc.md", "varianta modificata")
rezultat = indexer.build()
assert calculate == ["varianta modificata"]
assert rezultat["embeddings_refolosite"] == 0