fix(maria): acoperirea se judeca pe ce a intrebat omul acum, si pe radacina cuvantului

Doua reparatii pe poarta de acoperire, amandoua masurate pe setul de calibrare
extins (31 de cazuri, de la 26).

**Termenii distinctivi se comparau pe cuvantul intreg.** Documentatia si clientul
nu folosesc aceleasi forme: documentul scrie „token" si „tokenuri", omul scrie
„tokenul"; documentul „Generare", omul „generez". Trei continuari legitime din
cinci escaladau desi chunk-ul cu raspunsul era chiar in context. Comparatia se
face acum pe primele 5 litere; codurile raman intregi (ORA-01722 si ORA-01720 au
aceleasi cinci).

**Interogarea avea doua roluri amestecate.** Pe un fir, regasirea are nevoie de
ancora („da, ma blocheaza" singur nu gaseste nimic), dar acoperirea nu: judecata
pe ancora, ea cere sa apara in documente si salutul, si numele firmei, si luna,
deci raportul scade la fiecare replica si firul lung escaladeaza degeaba.
`rank.assess` primeste `dovada` — mesajul nou, plus OCR-ul capturii lui; cand
acesta n-are termeni proprii, se cade inapoi pe interogarea intreaga.

Setul de calibrare: 31/31 cu reparatia, 30/31 fara.

Ce NU s-a facut, desi era propus in handover: lista de formule de politete in
`rare_terms`. Masuratoarea nu o justifica — cu potrivirea pe radacina, cazurile cu
ancora politicoasa trec si fara ea, iar intr-una din configuratii facea rau.
Cauza reala nu era politetea din ancora, ci morfologia.

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-09-01 19:57:49 +00:00
parent 89145d437e
commit 653f5cbba9
6 changed files with 184 additions and 20 deletions

View File

@@ -358,6 +358,22 @@ Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea deci
distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in
chunk-urile gasite. chunk-urile gasite.
Termenii se compara **pe primele 5 litere**, nu pe cuvantul intreg (codurile fac
exceptie: `ORA-01722` si `ORA-01720` incep la fel). Documentatia si clientul nu
folosesc aceleasi forme — documentul scrie „token" si „tokenuri", omul scrie
„tokenul"; documentul „Generare", omul „generez". Comparate intregi, exact
cuvintele care conteaza nu se gaseau: pe setul de calibrare, trei continuari
legitime escaladau desi chunk-ul cu raspunsul era chiar in context.
**Pe un fir de discutie, interogarea are doua roluri diferite.** Regasirea se face
pe ancora plus mesajul nou — ancora chiar ajuta, „da, ma blocheaza" singur nu
gaseste nimic. Acoperirea se judeca insa pe **mesajul nou** (`dovada`, in
`rank.assess`): altfel raspunde la ce a intrebat omul acum SI la formulele si
numele proprii din primul mesaj („buna", „august", „AUTO SULEA" nu apar in niciun
document), iar raportul scade la fiecare replica — firul lung escaladeaza degeaba.
Cand mesajul nou n-are termeni distinctivi proprii, se cade inapoi pe interogarea
intreaga, ca inainte.
Fractiunea din pasul 2 nu e cosmetica: cu un singur termen gasit, „cum imi resetez Fractiunea din pasul 2 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.
@@ -373,7 +389,7 @@ L-am scos, nu urcat, fiindca **cosinusul creste cu lungimea interogarii**: pe un
de discutie se cauta dupa ancora plus mesajul nou, deci scorul urca la fiecare de discutie se cauta dupa ancora plus mesajul nou, deci scorul urca la fiecare
replica fara sa apara vreo dovada in plus. Aceeasi captura: 0,778 singura, 0,836 replica fara sa apara vreo dovada in plus. Aceeasi captura: 0,778 singura, 0,836
impreuna cu mesajul de dinainte. Orice prag fix de sus e trecut de o discutie destul impreuna cu mesajul de dinainte. Orice prag fix de sus e trecut de o discutie destul
de lunga. `ops/calibrate-rank.py`: 26/26, si niciunul dintre cazurile cu raspuns in de lunga. `ops/calibrate-rank.py`: 31/31, si niciunul dintre cazurile cu raspuns in
documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala. documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala.
Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde

View File

@@ -24,6 +24,7 @@ siguranta: daca un prag nou strica un caz care mergea, se vede imediat.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import inspect
import json import json
import pathlib import pathlib
import sys import sys
@@ -34,8 +35,10 @@ import config # noqa: E402
import consumer # noqa: E402 import consumer # noqa: E402
import rank # noqa: E402 import rank # noqa: E402
# (intrebare, are raspuns in documente?) # (intrebare, are raspuns in documente?) sau, pentru continuari pe un fir,
CAZURI: list[tuple[str, bool]] = [ # (ancora, mesaj_nou, are raspuns in documente?): regasirea se face pe ancora plus
# mesajul nou (exact ce produce fir.interogare), dovada doar pe mesajul nou.
CAZURI: list[tuple] = [
("token efactura expirat", True), ("token efactura expirat", True),
("Nu exista nici un CIF pentru care sa aveti drept in SPV", True), ("Nu exista nici un CIF pentru care sa aveti drept in SPV", True),
("cum trimit declaratia D406 SAF-T", True), ("cum trimit declaratia D406 SAF-T", True),
@@ -80,6 +83,31 @@ CAZURI: list[tuple[str, bool]] = [
("vremea de maine in Bucuresti", False), ("vremea de maine in Bucuresti", False),
("cum imi resetez parola de la Windows", False), ("cum imi resetez parola de la Windows", False),
("imi da eroare 0x80070005 la instalarea unui joc pe Steam", False), ("imi da eroare 0x80070005 la instalarea unui joc pe Steam", False),
# --- continuari pe fir, fara cod de eroare: aici decid termenii distinctivi ---
# Ancora politicoasa aduce in interogare „buna/ziua/puteti/ajutati", cuvinte care
# nu apar niciodata in documente. Raspunsul (token expirat) e in
# efactura_knowledge.md:118-134 (TOPIC token_anaf, expirare la 90 de zile).
("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?",
"imi zice ca tokenul e expirat", True),
# Aceeasi continuare, ancora fara formule de politete: verdictul trebuie sa fie
# acelasi. Daca difera, politetea din ancora e cea care escaladeaza.
("Am o problema la trimiterea in SPV",
"imi zice ca tokenul e expirat", True),
# Aceeasi ancora politicoasa, dar raspunsul NU e in documente: „CUI cumparator
# incorect" nu apare in niciun document (grep peste documents/, 2026-09-01).
("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?",
"imi da eroare CUI cumparator incorect la validare", False),
# Fir lung: a patra replica, ancora crescuta cu schimburile de dinainte.
# Verdictul nu are voie sa se degradeze cu lungimea (cosinusul urca oricum).
("Buna ziua . Am o problema la trimiterea in SPV . Puteti sa ma ajutati?\n"
"imi zice ca tokenul e expirat\n"
"am apasat Actualizare in borderoul eFactura dar tot nu merge",
"unde apas ca sa generez tokenul nou?", True),
# Continuare pe un fir deschis de o captura (escaladarea M-260901-06EB): ancora
# e mesajul omului plus OCR-ul, plin de zgomot de interfata. Eroarea din captura
# nu e in documente, deci si continuarea trebuie sa plece la suport.
("Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULEA MOBILE SRL . Poti te rog sa ma ajuti?\nTotal\nLit\nO) Pers. Juridice (798) O Netrimise DI Trimise (fara raspuns) [5 Timis (cu raspuns Erori) (1). O Tamise (cu diferente Reg. V2.) (1)\nNr Articol Cantitate UM | Pret unitar Valoare fara TVA Discount fara TVA % TVA Descriere Detalii\n5 Detalii raspuns\n663| | <2xml version=\"1.0” encoding=\"UTF-8\" standalone=\"yes\"?>\n<header xmins=\"mfp:anaf-dgtefactura:mesajroriFactuta:v1” Index_incarcare=\"\nMesaj raspuns. | Factura cu id incarcare=6638295792 nu po] | | <Error errorMessage=\"CUI cumparator incorect\"/>\nTri\nMesaj trimitere | Transmis cu succes. Index incarcare\n1670404158\" Cif_emitent=\"\n3137200\">\n@ inclusiv Pers. Fizice\nSelecteaz!",
"si acum ce fac cu factura asta?", False),
] ]
CACHE = config.STATE_DIR / "calibrare-cache.json" CACHE = config.STATE_DIR / "calibrare-cache.json"
@@ -92,14 +120,27 @@ def incarca_cache() -> dict[str, list[float]]:
return {} return {}
def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dict]]: def desfa(caz: tuple) -> tuple[str, str | None, bool]:
"""(interogare de regasire, interogare de dovada, asteptat).
Un 3-tuplu e o continuare pe fir: se cauta dupa ancora plus mesajul nou (ca in
fir.interogare), dar dovada e doar ce a intrebat omul acum.
"""
if len(caz) == 3:
ancora, mesaj, asteptat = caz
return f"{ancora}\n{mesaj}", mesaj, asteptat
return caz[0], None, caz[1]
def pregateste(cazuri: list[tuple]) -> tuple[consumer.Index, list[dict]]:
index = consumer.load_index() index = consumer.load_index()
if not len(index): if not len(index):
sys.exit(f"index gol sau lipsa: {config.INDEX_FILE}") sys.exit(f"index gol sau lipsa: {config.INDEX_FILE}")
cache = incarca_cache() cache = incarca_cache()
date = [] date = []
nou = 0 nou = 0
for intrebare, asteptat in cazuri: for caz in cazuri:
intrebare, dovada, asteptat = desfa(caz)
vec = cache.get(intrebare) vec = cache.get(intrebare)
if vec is None: if vec is None:
print(f" embed: {intrebare[:50]}…", file=sys.stderr) print(f" embed: {intrebare[:50]}…", file=sys.stderr)
@@ -112,6 +153,7 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic
: config.get_int("TOP_K", 3)] : config.get_int("TOP_K", 3)]
date.append({ date.append({
"intrebare": intrebare, "intrebare": intrebare,
"dovada": dovada,
"asteptat": asteptat, "asteptat": asteptat,
# ca in consumer.search: cosinusul chunk-urilor date modelului # ca in consumer.search: cosinusul chunk-urilor date modelului
"best": max((cos[i] for i in ordine), default=0.0), "best": max((cos[i] for i in ordine), default=0.0),
@@ -124,13 +166,19 @@ def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dic
return index, date return index, date
# rank.assess primeste `dovada` doar dupa reparatia termenilor distinctivi; pana
# atunci se poate rula numai varianta de azi.
ARE_DOVADA = "dovada" in inspect.signature(rank.assess).parameters
def evalueaza(index: consumer.Index, date: list[dict], weak: float, def evalueaza(index: consumer.Index, date: list[dict], weak: float,
min_ratio: float) -> tuple[int, list[str]]: min_ratio: float, dovada_separata: bool = False) -> tuple[int, list[str]]:
corecte, gresite = 0, [] corecte, gresite = 0, []
for d in date: for d in date:
extra = {"dovada": d["dovada"]} if dovada_separata else {}
ok, _ = rank.assess( ok, _ = rank.assess(
d["intrebare"], d["best"], d["top_texts"], index.bm25, d["intrebare"], d["best"], d["top_texts"], index.bm25,
weak=weak, min_ratio=min_ratio, weak=weak, min_ratio=min_ratio, **extra,
) )
if ok == d["asteptat"]: if ok == d["asteptat"]:
corecte += 1 corecte += 1
@@ -147,7 +195,7 @@ def main() -> None:
cazuri = CAZURI cazuri = CAZURI
if args.cases: if args.cases:
cazuri = [(q, bool(a)) for q, a in json.loads(pathlib.Path(args.cases).read_text())] cazuri = [tuple(c) for c in json.loads(pathlib.Path(args.cases).read_text())]
index, date = pregateste(cazuri) index, date = pregateste(cazuri)
print(f"index: {len(index)} chunk-uri | cazuri: {len(date)}\n") print(f"index: {len(index)} chunk-uri | cazuri: {len(date)}\n")
@@ -156,9 +204,11 @@ def main() -> None:
rezultate = [] rezultate = []
for weak in (0.50, 0.54, 0.58, 0.60, 0.64): for weak in (0.50, 0.54, 0.58, 0.60, 0.64):
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0): for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
corecte, _ = evalueaza(index, date, weak, ratio) corecte, _ = evalueaza(index, date, weak, ratio, ARE_DOVADA)
rezultate.append((corecte, weak, ratio)) rezultate.append((corecte, weak, ratio))
rezultate.sort(key=lambda r: (-r[0], r[1])) rezultate.sort(key=lambda r: (-r[0], r[1]))
mod = "cu dovada separata" if ARE_DOVADA else "fara separare (rank.assess nu are inca `dovada`)"
print(f"mod: {mod}\n")
print(f"{'corecte':>8} weak ratio") print(f"{'corecte':>8} weak ratio")
for corecte, weak, ratio in rezultate[:15]: for corecte, weak, ratio in rezultate[:15]:
print(f"{corecte:>5}/{len(date)} {weak:.2f} {ratio:.2f}") print(f"{corecte:>5}/{len(date)} {weak:.2f} {ratio:.2f}")
@@ -171,8 +221,12 @@ def main() -> None:
semn = "OK " if ok == d["asteptat"] else "GRESIT" semn = "OK " if ok == d["asteptat"] else "GRESIT"
stare = "ACOPERIT " if ok else "ESCALADAT" stare = "ACOPERIT " if ok else "ESCALADAT"
print(f"{semn} [{stare}] {d['intrebare'][:44]:46} {motiv}") print(f"{semn} [{stare}] {d['intrebare'][:44]:46} {motiv}")
corecte, gresite = evalueaza(index, date, weak, ratio) for eticheta, separata in (("fara separare (azi)", False), ("cu dovada separata ", True)):
print(f"\n{corecte}/{len(date)} corecte") if separata and not ARE_DOVADA:
print("\ncu dovada separata : rank.assess nu accepta inca `dovada`, sarit")
continue
corecte, gresite = evalueaza(index, date, weak, ratio, separata)
print(f"\n{eticheta}: {corecte}/{len(date)} corecte")
for g in gresite: for g in gresite:
print(f" - {g}") print(f" - {g}")

View File

@@ -128,8 +128,13 @@ def cosine(a: list[float], b: list[float]) -> float:
return dot / (na * nb) if na and nb else 0.0 return dot / (na * nb) if na and nb else 0.0
def search(index: Index, query: str, top_k: int) -> dict: def search(index: Index, query: str, top_k: int, dovada: str | None = None) -> dict:
"""Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log.""" """Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log.
`query` regaseste, `dovada` judeca. Pe un fir, `query` e ancora plus mesajul
nou (ancora chiar ajuta regasirea), dar acoperirea nu se judeca pe ea — vezi
`rank.assess`.
"""
if not len(index): if not len(index):
return {"chunks": [], "covered": False, "reason": "index gol", return {"chunks": [], "covered": False, "reason": "index gol",
"best_cosine": 0.0, "top": []} "best_cosine": 0.0, "top": []}
@@ -141,7 +146,7 @@ def search(index: Index, query: str, top_k: int) -> dict:
# cosinusul chunk-urilor CHIAR date modelului, nu cel mai bun din tot indexul: # 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. # altfel acoperirea se judeca pe o dovada care nu ajunge in context.
best = max((cosines[i] for i in order), default=0.0) 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, dovada=dovada)
return { return {
"chunks": texts, "chunks": texts,
"covered": covered, "covered": covered,
@@ -558,6 +563,7 @@ def handle(index: Index, msg: dict) -> None:
if not question: if not question:
return return
dovada = None
if continuare: if continuare:
fir_mod.adauga(fir, "client", text or "[captura de ecran]") fir_mod.adauga(fir, "client", text or "[captura de ecran]")
fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "") fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "")
@@ -571,6 +577,9 @@ def handle(index: Index, msg: dict) -> None:
send_reply(sender, raspuns) send_reply(sender, raspuns)
return return
fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal
# ce a intrebat omul ACUM (mesajul nou, sau textul retinut din captura lui):
# acoperirea se judeca pe asta, nu pe ancora care ramane in interogare.
dovada = search_query
search_query = fir_mod.interogare(fir, search_query) search_query = fir_mod.interogare(fir, search_query)
print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}", print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}",
file=sys.stderr) file=sys.stderr)
@@ -579,7 +588,7 @@ def handle(index: Index, msg: dict) -> None:
fir_mod.adauga(fir, "client", text or "[captura de ecran]") fir_mod.adauga(fir, "client", text or "[captura de ecran]")
try: try:
result = search(index, search_query, config.get_int("TOP_K", 3)) result = search(index, search_query, config.get_int("TOP_K", 3), dovada)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print(f"[consumer] cautare esuata: {exc}", file=sys.stderr) print(f"[consumer] cautare esuata: {exc}", file=sys.stderr)
send_reply(sender, "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta.") send_reply(sender, "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta.")

View File

@@ -17,6 +17,11 @@ escaladarii deschise si ultimele schimburi. Ancora intra in interogarea de cauta
la fiecare continuare — asa „da, ma blocheaza" se cauta impreuna cu eroarea, nu la fiecare continuare — asa „da, ma blocheaza" se cauta impreuna cu eroarea, nu
singur. singur.
Ancora ajuta REGASIREA, nu acoperirea. Decizia „raspund sau escaladez" se ia pe
mesajul nou (`rank.assess(..., dovada=...)`), fiindca ancora poarta si salutul, si
numele firmei, si luna — cuvinte care nu apar in niciun document si care trageau
raportul de termeni distinctivi in jos la fiecare replica.
Ce rupe firul, necontestat: o captura noua sau un cod de eroare diferit. Sunt Ce rupe firul, necontestat: o captura noua sau un cod de eroare diferit. Sunt
singurele semne sigure ca omul a trecut la altceva; restul (schimbarea subiectului singurele semne sigure ca omul a trecut la altceva; restul (schimbarea subiectului
in cuvinte) e prea usor de confundat cu o continuare, iar greseala aia produce in cuvinte) e prea usor de confundat cu o continuare, iar greseala aia produce

View File

@@ -209,6 +209,31 @@ def rank(
return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True) return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True)
RADACINA = 5 # cate litere din inceputul unui cuvant se compara cu documentele
def _apare(term: str, blob: str) -> bool:
"""Termenul e in text — si la alta forma gramaticala decat a scris-o omul.
Romana articuleaza si conjuga, documentatia si clientul nu folosesc aceleasi
forme: documentul scrie „token" si „tokenuri", omul scrie „tokenul"; documentul
„Generare", omul „generez"; documentul „trimise", omul „trimiterea". Comparat
pe cuvantul intreg, exact cuvantul care conteaza nu se gaseste — masurat pe
setul de calibrare (2026-09-01), trei continuari legitime din cinci escaladau
numai din cauza asta, desi chunk-ul cu raspunsul era chiar in context.
Codurile se compara intregi: `ora-01722` si `ora-01720` au aceleasi 5 litere
de la inceput si n-au nimic in comun.
# ponytail: prefix fix, nu stemmer. Poate potrivi gresit cuvinte scurte cu
# aceeasi radacina („capitala" ~ „capitaluri"); daca apare in practica, aici
# se pune un stemmer romanesc, nu un prag mai mare.
"""
if _COD.match(term):
return term in blob
return term[:RADACINA] in blob
def assess( def assess(
query: str, query: str,
best_cosine: float, best_cosine: float,
@@ -217,6 +242,7 @@ def assess(
*, *,
weak: float | None = None, weak: float | None = None,
min_ratio: float | None = None, min_ratio: float | None = None,
dovada: str | None = None,
) -> 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.
@@ -232,6 +258,19 @@ def assess(
deci un chunk despre ORA-12541 sta la 0,74 de o intrebare despre ORA-06550. 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. Fara asta, Maria raspundea sigur pe ea cu eroarea gresita, in loc sa escaladeze.
`dovada` desparte cele doua roluri ale interogarii pe un fir de discutie.
Regasirea are nevoie de ancora („da, ma blocheaza" singur nu gaseste nimic),
dar acoperirea nu: judecata pe ancora + mesaj nou, ea raspunde la ce a intrebat
omul acum SI la formulele si numele proprii din primul mesaj — „buna", „august",
„AUTO SULEA" nu apar in niciun document, deci raportul scade la fiecare replica.
Cand `dovada` (mesajul nou, plus OCR-ul capturii lui) are termeni distinctivi
proprii, acoperirea se judeca pe ei. Cand n-are, se cade inapoi pe interogarea
intreaga — comportamentul de dinainte; altfel o continuare fara niciun cuvant
al ei ar escalada.
Codurile (cazul 0) raman pe interogarea intreaga: codul din ancora e chiar
subiectul firului, iar continuarea nu-l repeta.
A existat si un prag „cosinus mare -> raspundem, indiferent de lexical" 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 (`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 sa treaca doua raspunsuri inventate pe 2026-09-01: **cosinusul creste cu
@@ -256,17 +295,19 @@ def assess(
) )
if best_cosine < weak: if best_cosine < weak:
return False, f"cosinus {best_cosine:.3f} < {weak}" return False, f"cosinus {best_cosine:.3f} < {weak}"
rare = bm25.rare_terms(dovada) if dovada else []
if not rare:
rare = bm25.rare_terms(query) rare = bm25.rare_terms(query)
if not rare: if not rare:
return False, f"cosinus {best_cosine:.3f}, 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)) blob = fold("\n".join(top_texts))
gasiti = [t for t in rare if t in blob] gasiti = [t for t in rare if _apare(t, blob)]
if gasiti and len(gasiti) / len(rare) >= min_ratio: if gasiti and len(gasiti) / len(rare) >= min_ratio:
return True, ( return True, (
f"cosinus {best_cosine:.3f} + {len(gasiti)}/{len(rare)} termeni distinctivi " f"cosinus {best_cosine:.3f} + {len(gasiti)}/{len(rare)} termeni distinctivi "
f"in context: {', '.join(gasiti)}" f"in context: {', '.join(gasiti)}"
) )
lipsa = [t for t in rare if t not in blob] lipsa = [t for t in rare if not _apare(t, blob)]
return False, ( return False, (
f"cosinus {best_cosine:.3f}, iar termenii distinctivi " f"cosinus {best_cosine:.3f}, iar termenii distinctivi "
f"({', '.join(lipsa[:5])}) nu apar in documente" f"({', '.join(lipsa[:5])}) nu apar in documente"

View File

@@ -45,6 +45,45 @@ def test_rare_terms_prinde_codurile_si_ignora_cuvintele_comune():
assert "ora-01722" in rare assert "ora-01722" in rare
def test_assess_gaseste_termenul_la_alta_forma_gramaticala():
# Documentul scrie „token"/„tokenuri", omul scrie „tokenul". Comparat pe cuvantul
# intreg, singurul cuvant care conteaza nu se gaseste si intrebarea escaladeaza.
bm = rank.Bm25(DOCS)
covered, motiv = rank.assess("imi zice ca tokenul a expirat", 0.64, DOCS[1:2], bm)
assert covered
assert "tokenul" in motiv
def test_assess_codurile_se_compara_intregi_nu_pe_radacina():
# „ora-01722" si „ora-01720" au aceleasi cinci litere de la inceput.
bm = rank.Bm25(DOCS)
covered, _ = rank.assess("eroarea ORA-01720", 0.64, DOCS[:1], bm)
assert not covered
def test_assess_dovada_judeca_mesajul_nou_nu_ancora():
# Continuare pe un fir: ancora e salutul plus reclamatia vaga, mesajul nou e
# chiar intrebarea. Fara dovada separata, „buna/august" (care nu apar nicaieri)
# trag raportul sub prag si escaladeaza degeaba.
bm = rank.Bm25(DOCS)
ancora = "Buna ziua . Am o problema pe luna august la firma AUTO SULEA . Ma ajutati?"
query = f"{ancora}\ntokenul a expirat"
fara, _ = rank.assess(query, 0.64, DOCS[1:2], bm)
cu, motiv = rank.assess(query, 0.64, DOCS[1:2], bm, dovada="tokenul a expirat")
assert not fara
assert cu and "expirat" in motiv
def test_assess_continuare_scurta_cade_inapoi_pe_interogarea_intreaga():
# „da, ma blocheaza complet" n-are termeni distinctivi proprii. Judecata singura
# ar escalada orice continuare scurta — exact regresia pentru care exista ancora.
bm = rank.Bm25(DOCS)
query = "token efactura expirat\nda"
covered, _ = rank.assess(query, 0.64, DOCS[1:2], bm, dovada="da")
fara, _ = rank.assess(query, 0.64, DOCS[1:2], bm)
assert covered is fara is True
def test_rank_urca_chunkul_confirmat_de_ambele_metode(): def test_rank_urca_chunkul_confirmat_de_ambele_metode():
# cosinusul favorizeaza documentul 3, dar lexical se potriveste documentul 0; # cosinusul favorizeaza documentul 3, dar lexical se potriveste documentul 0;
# documentul 0 e sustinut de amandoua (cosinus decent + BM25 mare) si iese primul # documentul 0 e sustinut de amandoua (cosinus decent + BM25 mare) si iese primul