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
274 lines
11 KiB
Python
274 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Ordonarea chunk-urilor: embeddings + potrivire lexicala, fuzionate.
|
|
|
|
De ce nu doar embeddings. Masurat pe indexul viu (140 de chunk-uri), scorul
|
|
cosinus al celui mai bun chunk a fost 0,600-0,816 la intrebari care CHIAR au
|
|
raspuns in documente si 0,534-0,685 la intrebari straine de ROA. Intervalele SE
|
|
SUPRAPUN, deci niciun prag pe cosinus singur nu le separa: „bilant contabil"
|
|
(0,600, buna) sta sub „eroare 0x80070005 la instalarea unui joc pe Steam" (0,685,
|
|
straina).
|
|
|
|
Ce lipseste din cosinus e exact ce conteaza la suport: **codurile**. `ORA-01722`,
|
|
`D406`, `CIF`, `SPV` sunt tokeni exacti — o cautare lexicala ii prinde imediat, iar
|
|
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
|
|
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
|
|
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
|
|
chunk), nu pe pozitia in clasament.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
import unicodedata
|
|
|
|
import config
|
|
|
|
# Praguri implicite, calibrate pe masuratoarea din docstring. Se pot suprascrie
|
|
# din env (vezi config.DEFAULTS) fara sa umbli in cod.
|
|
RRF_K = 60 # constanta standard din Reciprocal Rank Fusion
|
|
|
|
|
|
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"))
|
|
|
|
|
|
def recall_n() -> int:
|
|
"""Cate chunk-uri intra in fuziune, din fiecare metoda (RANK_RECALL_N)."""
|
|
return config.get_int("RANK_RECALL_N", 12)
|
|
|
|
|
|
def min_rare_ratio() -> float:
|
|
"""Ce fractiune din termenii distinctivi ai intrebarii trebuie gasita (RANK_MIN_RARE_RATIO).
|
|
|
|
Un singur termen gasit nu e o dovada: „cum imi resetez parola de la Windows" a
|
|
fost salvata gresit doar fiindca „parola" apare in documente. Cerand o
|
|
fractiune, intrebarea trebuie sa se suprapuna cu documentele, nu sa le atinga.
|
|
"""
|
|
return float(config.get("RANK_MIN_RARE_RATIO", "0.5"))
|
|
|
|
_TOKEN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
|
|
|
# Cuvinte prea frecvente ca sa spuna ceva despre subiect. Lista e scurta
|
|
# deliberat: la BM25 idf-ul se ocupa oricum de restul.
|
|
_STOP = {
|
|
"si", "sa", "se", "la", "de", "din", "in", "pe", "cu", "pentru", "ca", "care",
|
|
"este", "sunt", "am", "are", "nu", "un", "o", "al", "ale", "lui", "cum", "ce",
|
|
"mi", "imi", "ma", "eu", "va", "vrea", "vreau", "poate", "pot", "the", "to",
|
|
"a", "an", "of", "is", "it", "da", "dar", "sau", "mai", "fost", "face",
|
|
}
|
|
|
|
# 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,}$")
|
|
|
|
# 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:
|
|
"""Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token."""
|
|
norm = unicodedata.normalize("NFKD", text.lower())
|
|
return "".join(c for c in norm if not unicodedata.combining(c))
|
|
|
|
|
|
def tokenize(text: str) -> list[str]:
|
|
return [t for t in _TOKEN.findall(fold(text)) if t not in _STOP and len(t) > 1]
|
|
|
|
|
|
class Bm25:
|
|
"""Okapi BM25 peste chunk-urile indexului.
|
|
|
|
Se reconstruieste la fiecare recitire a indexului (la 140 de chunk-uri costa
|
|
milisecunde) — nu merita tinut in fisier si desincronizat de el.
|
|
"""
|
|
|
|
def __init__(self, documents: list[str], k1: float = 1.5, b: float = 0.75):
|
|
self.k1, self.b = k1, b
|
|
self.docs = [tokenize(d) for d in documents]
|
|
self.lens = [len(d) for d in self.docs]
|
|
self.avg_len = (sum(self.lens) / len(self.lens)) if self.lens else 0.0
|
|
self.freqs: list[dict[str, int]] = []
|
|
self.df: dict[str, int] = {}
|
|
for toks in self.docs:
|
|
f: dict[str, int] = {}
|
|
for t in toks:
|
|
f[t] = f.get(t, 0) + 1
|
|
self.freqs.append(f)
|
|
for t in f:
|
|
self.df[t] = self.df.get(t, 0) + 1
|
|
self.n = len(self.docs)
|
|
|
|
def idf(self, term: str) -> float:
|
|
df = self.df.get(term, 0)
|
|
# varianta cu +0.5 (Robertson): termenii din peste jumatate din documente
|
|
# ajung aproape de zero, ceea ce e exact ce vrem.
|
|
return math.log(1 + (self.n - df + 0.5) / (df + 0.5))
|
|
|
|
def scores(self, query: str) -> list[float]:
|
|
q = tokenize(query)
|
|
out = [0.0] * self.n
|
|
if not q or not self.avg_len:
|
|
return out
|
|
for i, f in enumerate(self.freqs):
|
|
s = 0.0
|
|
for t in q:
|
|
tf = f.get(t, 0)
|
|
if not tf:
|
|
continue
|
|
denom = tf + self.k1 * (1 - self.b + self.b * self.lens[i] / self.avg_len)
|
|
s += self.idf(t) * tf * (self.k1 + 1) / denom
|
|
out[i] = s
|
|
return out
|
|
|
|
def rare_terms(self, query: str, max_df_ratio: float = 0.25) -> list[str]:
|
|
"""Termenii din intrebare care chiar disting ceva: coduri, sau cuvinte rare in corpus."""
|
|
out = []
|
|
for t in dict.fromkeys(tokenize(query)):
|
|
if _COD.match(t):
|
|
out.append(t)
|
|
elif len(t) >= 4 and self.df.get(t, 0) <= max(1, int(self.n * max_df_ratio)):
|
|
out.append(t)
|
|
return out
|
|
|
|
|
|
def rrf(rankings: list[list[int]], k: int = RRF_K) -> dict[int, float]:
|
|
"""Reciprocal Rank Fusion: suma de 1/(k+pozitie) peste clasamente.
|
|
|
|
Fuzioneaza POZITII, nu scoruri — cosinusul (0..1) si BM25 (nemarginit) nu se
|
|
pot aduna direct fara o normalizare care ar depinde de corpus.
|
|
"""
|
|
fused: dict[int, float] = {}
|
|
for ranking in rankings:
|
|
for pos, idx in enumerate(ranking):
|
|
fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + pos + 1)
|
|
return fused
|
|
|
|
|
|
def rank(
|
|
cosines: list[float],
|
|
bm25_scores: list[float],
|
|
code_order: list[int] | None = None,
|
|
recall: int | None = None,
|
|
) -> list[int]:
|
|
"""Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea clasamentelor."""
|
|
recall = recall_n() if recall is None else 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
|
|
# 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)
|
|
if bm25_scores[i] > 0][:recall]
|
|
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)
|
|
|
|
|
|
def assess(
|
|
query: str,
|
|
best_cosine: float,
|
|
top_texts: list[str],
|
|
bm25: Bm25,
|
|
*,
|
|
weak: float | None = None,
|
|
min_ratio: float | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada.
|
|
|
|
Cazuri, in ordine:
|
|
0. intrebarea contine coduri de eroare -> decide DOAR prezenta lor in
|
|
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.
|
|
"""
|
|
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)
|
|
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 < weak:
|
|
return False, f"cosinus {best_cosine:.3f} < {weak}"
|
|
rare = bm25.rare_terms(query)
|
|
if not rare:
|
|
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:
|
|
return True, (
|
|
f"cosinus {best_cosine:.3f} + {len(gasiti)}/{len(rare)} termeni distinctivi "
|
|
f"in context: {', '.join(gasiti)}"
|
|
)
|
|
lipsa = [t for t in rare if t not in blob]
|
|
return False, (
|
|
f"cosinus {best_cosine:.3f}, iar termenii distinctivi "
|
|
f"({', '.join(lipsa[:5])}) nu apar in documente"
|
|
)
|