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