fix(maria): codul de eroare decide acoperirea, nu cosinusul

O captura cu ORA-06550 / PLS-00906 primea raspunsul despre ORA-12541 (TNS no
listener) si nu pleca la suport. Cauza: doua erori Oracle diferite se scriu
aproape la fel ("[Oracle][ODBC][Ora]ORA-..."), asa ca chunk-ul gresit a iesit la
cosinus 0,736 — peste RANK_STRONG_COSINE, care scurtcircuita orice alta dovada.
Chunk-ul corect din dictionar exista, dar statea pe pozitia 4, in afara TOP_K:
OCR-ul unei ferestre intregi aduce zeci de tokeni de zgomot, iar BM25 ineca tocmai
codul.

- `rank.codes()` scoate codurile de oriunde din text, nu doar ca token intreg —
  OCR-ul le lipeste de ce e langa ("OraJORA-06550").
- `rank.by_codes()` e un al treilea clasament in fuziune: chunk-ul care poarta
  codul cerut urca peste cele care doar seamana. Acum iese primul.
- `rank.assess()`: cand intrebarea are coduri, decid doar ele. Niciun cod gasit =
  escaladare la suport, oricat de mare ar fi cosinusul.
- `consumer.search()` judeca acoperirea pe cosinusul chunk-urilor date chiar
  modelului, nu pe cel mai bun din tot indexul.

Calibrare 21/21 (cele 19 cazuri + captura reala + ORA-00600, cod care nu e in
documente si trebuie sa plece la suport). Teste: 79 pass.

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:25:45 +00:00
parent 44763869ce
commit b7d75f05ee
5 changed files with 143 additions and 10 deletions

View File

@@ -351,6 +351,8 @@ lexicala e singura care le tine in joc.
Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea decizia Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea decizia
**„avem sau nu acoperire"** se ia separat, pe dovezi: **„avem sau nu acoperire"** se ia separat, pe dovezi:
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; 1. cosinus ≥ `RANK_STRONG_COSINE` → raspundem;
2. cosinus < `RANK_WEAK_COSINE` → escaladam direct; 2. cosinus < `RANK_WEAK_COSINE` → escaladam direct;
3. intre ele → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii 3. intre ele → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii
@@ -360,6 +362,18 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci
Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez
parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente. parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente.
Pasul 0 e mai tare decat cosinusul pentru ca **doua erori Oracle diferite se scriu
aproape la fel**. O captura cu `ORA-06550 / PLS-00906` a primit cosinus 0,736 pe
chunk-ul despre `ORA-12541: TNS no listener` — peste pragul „sigur", deci Maria a
raspuns increzatoare cu alta eroare, fara sa escaladeze. Un cod e un identificator
exact: un document care nu-l pomeneste nu raspunde la el, oricat de bine ar semana
textul din jur. Codurile dau si un **al treilea clasament** (`rank.by_codes`), langa
cosinus si BM25 — la o captura de ecran, OCR-ul aduce zeci de tokeni de zgomot
(numele butoanelor din fereastra), iar BM25 singur ineca tocmai codul.
Codul se cauta oriunde in text, nu ca token intreg: OCR-ul lipeste `[Oracle][ODBC]`
de cod si scoate `OraJORA-06550`.
### Recalibrarea, cand se schimba documentele ### Recalibrarea, cand se schimba documentele
Pragurile sunt masurate, nu alese din burta — si se **muta** cand se schimba Pragurile sunt masurate, nu alese din burta — si se **muta** cand se schimba

View File

@@ -49,6 +49,12 @@ CAZURI: list[tuple[str, bool]] = [
("imi da ORA-12154 cand deschid programul", True), ("imi da ORA-12154 cand deschid programul", True),
("ORA-00001 unique constraint violated", True), ("ORA-00001 unique constraint violated", True),
("eroare ORA-28000 the account is locked", True), ("eroare ORA-28000 the account is locked", True),
# captura reala (OCR), 2026-08-31: raspundea cu ORA-03113/ORA-12541, alta eroare
("[Oracle][ODBC][OraJORA-06550: line 1, column 7\n"
"PLS-00906: object VADECO PACK PARTENERI is invalid\n"
"ORA-06550: line 1, column 7:", True),
# cod Oracle real, dar care NU e in dictionar: trebuie sa plece la suport
("ORA-00600 internal error code arguments", False),
("care e capitala Frantei", False), ("care e capitala Frantei", False),
("cat costa un bilet de avion la Paris", False), ("cat costa un bilet de avion la Paris", False),
("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False), ("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False),
@@ -84,11 +90,13 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic
nou += 1 nou += 1
cos = [consumer.cosine(vec, e["embedding"]) for e in index.entries] cos = [consumer.cosine(vec, e["embedding"]) for e in index.entries]
bm = index.bm25.scores(intrebare) bm = index.bm25.scores(intrebare)
ordine = rank.rank(cos, bm)[: config.get_int("TOP_K", 3)] ordine = rank.rank(cos, bm, rank.by_codes(intrebare, index.texts))[
: config.get_int("TOP_K", 3)]
date.append({ date.append({
"intrebare": intrebare, "intrebare": intrebare,
"asteptat": asteptat, "asteptat": asteptat,
"best": max(cos), # ca in consumer.search: cosinusul chunk-urilor date modelului
"best": max((cos[i] for i in ordine), default=0.0),
"top_texts": [index.texts[i] for i in ordine], "top_texts": [index.texts[i] for i in ordine],
"top_surse": [index.sources[i] for i in ordine], "top_surse": [index.sources[i] for i in ordine],
}) })

View File

@@ -121,9 +121,11 @@ def search(index: Index, query: str, top_k: int) -> dict:
q_vec = embed(query) q_vec = embed(query)
cosines = [cosine(q_vec, e["embedding"]) for e in index.entries] cosines = [cosine(q_vec, e["embedding"]) for e in index.entries]
bm_scores = index.bm25.scores(query) bm_scores = index.bm25.scores(query)
order = rank.rank(cosines, bm_scores)[:top_k] order = rank.rank(cosines, bm_scores, rank.by_codes(query, index.texts))[:top_k]
texts = [index.texts[i] for i in order] texts = [index.texts[i] for i in order]
best = max(cosines) # cosinusul chunk-urilor CHIAR date modelului, nu cel mai bun din tot indexul:
# altfel acoperirea se judeca pe o dovada care nu ajunge in context.
best = max((cosines[i] for i in order), default=0.0)
covered, reason = rank.assess(query, best, texts, index.bm25) covered, reason = rank.assess(query, best, texts, index.bm25)
return { return {
"chunks": texts, "chunks": texts,

View File

@@ -14,6 +14,11 @@ una semantica ii topeste in "ceva despre facturi". Asa ca ordonam de doua ori,
independent, si fuzionam clasamentele (Reciprocal Rank Fusion): un chunk urcat de independent, si fuzionam clasamentele (Reciprocal Rank Fusion): un chunk urcat de
ambele metode iese primul, iar unul urcat doar de una ramane in cursa. ambele metode iese primul, iar unul urcat doar de una ramane in cursa.
Codurile merg mai departe de atat: ele dau un al treilea clasament (`by_codes`) si
sunt singurul criteriu de acoperire cand intrebarea contine unul (`assess`). Un cod
e un identificator exact — un document care nu-l pomeneste nu raspunde la el, oricat
de bine ar semana textul din jur.
Fuziunea da mereu un clasament, si la o intrebare complet straina — de aceea Fuziunea da mereu un clasament, si la o intrebare complet straina — de aceea
decizia "avem sau nu acoperire in documente" se ia separat, in `assess()`, pe decizia "avem sau nu acoperire in documente" se ia separat, in `assess()`, pe
dovezi (cat de sus e cosinusul, si daca termenii rari din intrebare chiar apar in dovezi (cat de sus e cosinusul, si daca termenii rari din intrebare chiar apar in
@@ -71,6 +76,44 @@ _STOP = {
# Coduri de eroare si formulare: valoreaza cat zece cuvinte obisnuite. # Coduri de eroare si formulare: valoreaza cat zece cuvinte obisnuite.
_COD = re.compile(r"^(?:ora|pls|tns|sp2|imp|exp|ora)-\d+$|^d\d{3}$|^e\d{3,}$") _COD = re.compile(r"^(?:ora|pls|tns|sp2|imp|exp|ora)-\d+$|^d\d{3}$|^e\d{3,}$")
# Acelasi lucru, cautat ORIUNDE in text, nu doar ca token intreg. OCR-ul lipeste
# codul de ce e langa el ("[Oracle][ODBC][OraJORA-06550" -> un singur token
# "orajora-06550"), iar codul e exact bucata care nu are voie sa se piarda.
_COD_IN_TEXT = re.compile(
r"(?:ora|pls|tns|sp2|imp|exp)-\d+|(?<![a-z0-9])(?:d\d{3}|br-co-\d+)(?![a-z0-9])"
)
def codes(text: str) -> list[str]:
"""Codurile de eroare din text, in ordinea aparitiei, fara duplicate.
Un cod e un identificator exact, nu un cuvant: `ORA-06550` si `ORA-12541`
sunt doua erori fara nimic in comun, desi textele din jurul lor se aseamana
pana la confuzie pentru o cautare semantica.
"""
return list(dict.fromkeys(_COD_IN_TEXT.findall(fold(text))))
def by_codes(query: str, texts: list[str]) -> list[int]:
"""Chunk-urile care contin codurile din intrebare, cele cu mai multe intai.
Al treilea clasament, langa cosinus si BM25. BM25 stie de coduri, dar le
ineaca: o captura de ecran aduce prin OCR zeci de tokeni („oracle", „odbc",
„line", „column", numele butoanelor din fereastra), iar chunk-urile care se
potrivesc pe zgomotul ala pot trece inaintea celui care poarta chiar codul.
"""
cerute = codes(query)
if not cerute:
return []
gasite: list[tuple[int, int]] = []
for i, text in enumerate(texts):
ale_lui = set(codes(text))
n = sum(1 for c in cerute if c in ale_lui)
if n:
gasite.append((n, i))
gasite.sort(key=lambda g: -g[0])
return [i for _, i in gasite]
def fold(text: str) -> str: def fold(text: str) -> str:
"""Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token.""" """Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token."""
@@ -154,16 +197,20 @@ def rrf(rankings: list[list[int]], k: int = RRF_K) -> dict[int, float]:
def rank( def rank(
cosines: list[float], cosines: list[float],
bm25_scores: list[float], bm25_scores: list[float],
code_order: list[int] | None = None,
recall: int | None = None, recall: int | None = None,
) -> list[int]: ) -> list[int]:
"""Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea celor doua clasamente.""" """Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea clasamentelor."""
recall = recall_n() if recall is None else recall recall = recall_n() if recall is None else recall
by_cos = sorted(range(len(cosines)), key=lambda i: cosines[i], reverse=True)[:recall] by_cos = sorted(range(len(cosines)), key=lambda i: cosines[i], reverse=True)[:recall]
# chunk-urile cu scor BM25 zero nu au niciun token comun cu intrebarea: nu au # chunk-urile cu scor BM25 zero nu au niciun token comun cu intrebarea: nu au
# ce cauta in clasamentul lexical, doar ar dilua fuziunea cu pozitii arbitrare. # ce cauta in clasamentul lexical, doar ar dilua fuziunea cu pozitii arbitrare.
by_bm = [i for i in sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True) by_bm = [i for i in sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)
if bm25_scores[i] > 0][:recall] if bm25_scores[i] > 0][:recall]
fused = rrf([by_cos, by_bm]) rankings = [by_cos, by_bm]
if code_order:
rankings.append(code_order[:recall])
fused = rrf(rankings)
return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True) return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True)
@@ -179,15 +226,32 @@ def assess(
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada. """(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada.
Trei cazuri, in ordine: 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; 1. cosinus mare -> raspundem, indiferent de lexical;
2. cosinus la mijloc, dar o fractiune suficienta din termenii rari ai 2. cosinus la mijloc, dar o fractiune suficienta din termenii rari ai
intrebarii apare chiar in chunk-uri (tipic: un cod de eroare) -> raspundem; intrebarii apare chiar in chunk-uri (tipic: un cod de eroare) -> raspundem;
3. altfel -> nu avem acoperire, intrebarea pleaca la suport. 3. altfel -> nu avem acoperire, intrebarea pleaca la suport.
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.
""" """
strong = strong_cosine() if strong is None else strong strong = strong_cosine() if strong is None else strong
weak = weak_cosine() if weak is None else weak weak = weak_cosine() if weak is None else weak
min_ratio = min_rare_ratio() if min_ratio is None else min_ratio min_ratio = min_rare_ratio() if min_ratio is None else min_ratio
cerute = codes(query)
if cerute:
blob_cod = set(codes("\n".join(top_texts)))
gasite = [c for c in cerute if c in blob_cod]
if gasite:
return True, f"cod exact in documente: {', '.join(gasite)}"
return False, (
f"intrebarea are coduri ({', '.join(cerute[:4])}) care nu apar in "
f"documente (cosinus {best_cosine:.3f})"
)
if best_cosine >= strong: if best_cosine >= strong:
return True, f"cosinus {best_cosine:.3f} >= {strong}" return True, f"cosinus {best_cosine:.3f} >= {strong}"
if best_cosine < weak: if best_cosine < weak:

View File

@@ -100,10 +100,11 @@ def test_assess_un_singur_termen_din_multi_nu_e_dovada():
def test_assess_motivul_numeste_termenii_lipsa_nu_pe_toti(): def test_assess_motivul_numeste_termenii_lipsa_nu_pe_toti():
# Fara cod in intrebare: acolo decid termenii rari, nu regula codurilor.
bm = rank.Bm25(DOCS) bm = rank.Bm25(DOCS)
_, motiv = rank.assess("ORA-01722 la imprimanta LaserJet", 0.61, DOCS[:1], bm) _, motiv = rank.assess("factura la imprimanta LaserJet", 0.61, DOCS[3:], bm)
# codul chiar apare in context, deci nu are ce cauta in lista lipsurilor # „factura" chiar apare in context, deci nu are ce cauta in lista lipsurilor
assert "ora-01722" not in motiv assert "factura," not in motiv
assert "imprimanta" in motiv assert "imprimanta" in motiv
@@ -120,3 +121,47 @@ def test_pragurile_vin_din_env(monkeypatch):
monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50") monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50")
covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm) covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm)
assert covered assert covered
# --- coduri de eroare: identificatori exacti, nu cuvinte ---------------------
def test_codul_lipit_de_ocr_e_tot_gasit():
"""OCR-ul da „[Oracle][ODBC][OraJORA-06550"; codul nu are voie sa se piarda."""
import rank
assert rank.codes("[Oracle][ODBC][OraJORA-06550: line 1, column 7") == ["ora-06550"]
assert rank.codes("PLS-00906: object X is invalid") == ["pls-00906"]
assert rank.codes("cum trimit D406") == ["d406"]
assert rank.codes("nu merge nimic") == []
def test_chunkul_cu_codul_cerut_urca_in_clasament():
import rank
texts = [
"ORA-12541 TNS no listener, serverul nu raspunde",
"ORA-03113 end-of-file on communication channel",
"ORA-06550 eroare de compilare, procedura lipseste",
]
assert rank.by_codes("imi da ORA-06550 la salvare", texts) == [2]
assert rank.by_codes("intrebare fara coduri", texts) == []
def test_cosinus_mare_nu_acopera_alt_cod(monkeypatch):
"""Doua erori Oracle diferite se scriu la fel; cosinusul nu le deosebeste.
Cazul real din 2026-08-31: captura cu ORA-06550 primea raspunsul despre
ORA-12541, cu cosinus 0,736 — peste pragul „sigur".
"""
import rank
bm = rank.Bm25(["ORA-12541 TNS no listener"])
ok, motiv = rank.assess("ORA-06550 PLS-00906 la salvare", 0.736,
["ORA-12541 TNS no listener"], bm)
assert ok is False
assert "ora-06550" in motiv
def test_codul_gasit_acopera_si_la_cosinus_mic():
import rank
bm = rank.Bm25(["ORA-06550 eroare de compilare"])
ok, motiv = rank.assess("ORA-06550", 0.42, ["ORA-06550 eroare de compilare"], bm)
assert ok is True
assert "ora-06550" in motiv