fix(sugestii): inlocuirea de anvelope -> OE-8 + vot k-NN ponderat pe rang

Seed-ul Haiku eticheta sistematic "INLOCUIT ANVELOPE ..." cu OE-1 (223 vs 17
OE-8), desi RAR are cod dedicat OE-8 (inlocuire sezoniera anvelope), asa ca
votul k-NN propunea OE-1 pentru orice inlocuire de anvelope.

- tools/mapare-llm/fix_anvelope_oe8.py: re-etichetare deterministica (187
  randuri DB + seed JSON -> OE-8; regula exclude singular cu pozitie, D/R,
  janta, vulcanizare) + 13 intrari canonice curate manual pentru formularile
  sezoniere absente din corpus ("MONTAT CAUCIUCURI VARA" etc.); idempotent
- enrich_suggestions: ponderea votului = EMB_VOTE_DECAY^rang (0.7) in loc de
  similaritatea bruta — anizotropia modelului lasa zgomotul de coada (ex.
  INLOCUIT BECURII la 0.934 de INLOCUIRE ANVELOPE) sa invinga vecinii corecti
  de rang 1-2; LOO: precizie egala (93.1%), cod-gresit 4.67% -> 4.63%
- test_fix_anvelope_oe8: gardii pe regula + seed-ul din repo (regresie la
  regenerare); auditul k-NN al dezacordurilor nu a gasit alte erori
  sistematice (curatat=OE-2, reglat=OE-4, bujii/ulei=OE-3 raman apararabile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-07 10:26:00 +00:00
parent 809823a8ae
commit 6674d0e3e1
5 changed files with 722 additions and 384 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -638,11 +638,18 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
# 100% dar 7.4% cod gresit preselectat.
EMB_MIN_SIMILARITATE = 0.88
# Cati vecini intra in votul ponderat cu similaritatea. Vot > top-1: corpusul SILVER
# Cati vecini intra in votul ponderat pe rang. Vot > top-1: corpusul SILVER
# are etichete contradictorii pe denumiri aproape identice; votul e imun la ele
# (+~1pp precizie la acelasi coverage, masurat LOO).
EMB_VOTE_TOP_K = 5
# Ponderea unui vecin = EMB_VOTE_DECAY^rang (1-indexat). Ponderarea cu similaritatea
# bruta e pacalita de anizotropia modelului: vecini fara legatura scoreaza aproape
# cat cei buni (ex. INLOCUIT BECURII la 0.934 de INLOCUIRE ANVELOPE), deci coada
# de zgomot poate invinge 2 vecini corecti de rang 1-2. Ordinea rangurilor ramane
# informativa; decay 0.7 = cel mai mic cod-gresit la precizie egala (LOO).
EMB_VOTE_DECAY = 0.7
# Protejeaza secventa hash->load->embed->save->purge->index (embedding_cache) de
# executie concurenta intre warmup-ul de fundal (block=True) si calea de request
# (block=False, dupa ce modelul e deja incarcat) -- altfel purjarea uneia ar sterge
@@ -841,18 +848,18 @@ def enrich_suggestions(
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
# nu mai e configul sub care s-a masurat 94.3%.
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=EMB_VOTE_TOP_K)
# Vot ponderat cu similaritatea pe vecinii peste prag; NUL e eticheta
# Vot ponderat pe rang (decay^rang) pe vecinii peste prag; NUL e eticheta
# proprie (castiga -> supresie, nu cod). Vecinii sub prag nu voteaza.
scoruri: dict[str, float] = {}
sim_max: dict[str, float] = {}
for v in nn:
for rang, v in enumerate(nn, 1):
sim = float(v.get("similaritate", 0))
if sim < EMB_MIN_SIMILARITATE:
continue
lab = "NUL" if v.get("is_nul") else (str(v["cod"]) if v.get("cod") else None)
if lab is None:
continue
scoruri[lab] = scoruri.get(lab, 0.0) + sim
scoruri[lab] = scoruri.get(lab, 0.0) + EMB_VOTE_DECAY ** rang
sim_max[lab] = max(sim_max.get(lab, 0.0), sim)
if scoruri:
castigator = max(scoruri, key=lambda k: scoruri[k])

View File

@@ -0,0 +1,58 @@
"""Gardii pentru corectia anvelope->OE-8 (tools/mapare-llm/fix_anvelope_oe8.py).
Doua niveluri:
1. Regula `este_inlocuire_anvelope` pe cazuri pozitive/negative.
2. Seed-ul real din repo NU mai contine inlocuiri de anvelope etichetate
altfel decat OE-8 (regresie la o viitoare regenerare a seed-ului).
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(HERE, "..", "tools", "mapare-llm"))
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
from fix_anvelope_oe8 import SEED_PATH, este_inlocuire_anvelope # noqa: E402
def test_regula_pozitive():
for t in [
"INLOCUIT ANVELOPE B 55 XMH",
"INLOCUIT ANVELOPE 2 BUC",
"INLOCUIT + ECHILIBRAT ANVELOPE",
"SCHIMBAT ANVELOPE VARA",
"MONTAT ANVELOPE (2)",
"INL ANVELOPE + ECHILIBR",
]:
assert este_inlocuire_anvelope(t), t
def test_regula_negative():
for t in [
"INLOCUIT ANVELOPA DR FATA", # singular cu pozitie = context reparatie
"D/R ANVELOPE(4 BUC)", # demontat/remontat, nu inlocuire
"INLOCUIT ANVELOPA+JANTA", # janta = reparatie
"REPARAT ANVELOPE", # vulcanizare/reparatie
"CHIRIE ANVELOPE AUGUST", # non-operatie (fara verb de inlocuire)
"INLOCUIT PLACUTE FRANA", # alta piesa
"VERIFICAT PRESIUNE ANVELOPE", # verificare, nu inlocuire
]:
assert not este_inlocuire_anvelope(t), t
def test_seed_fara_inlocuiri_anvelope_gresite():
"""Orice intrare de inlocuire anvelope din seed trebuie sa fie OE-8."""
with open(SEED_PATH, encoding="utf-8") as f:
items = json.load(f)
gresite = [
it["denumire_normalizata"]
for it in items
if not it.get("is_nul")
and este_inlocuire_anvelope(it.get("denumire_normalizata") or "")
and it.get("cod") != "OE-8"
]
assert gresite == [], f"{len(gresite)} inlocuiri de anvelope ne-OE-8 in seed: {gresite[:5]}"

View File

@@ -0,0 +1,165 @@
"""Corectie etichete SILVER: inlocuirea de anvelope -> OE-8.
Seed-ul Haiku a etichetat sistematic "INLOCUIT ANVELOPE ..." cu OE-1 (REPARATIE),
desi nomenclatorul RAR are un cod dedicat: OE-8 (INLOCUIRE SEZONIERA A ANVELOPELOR).
Eroarea domina vecinatatea k-NN (223 OE-1 vs 17 OE-8), asa ca votul sugera OE-1
pentru orice "Inlocuire anvelope".
Regula (deterministica, aceeasi in DB si in seed):
text NORMALIZAT care contine un verb de inlocuire/montare
(INLOCUIT/INLOCUIRE/INL/SCHIMB*/MONTAT/MONTARE) SI anvelope la plural
(ANVELOPE/CAUCIUCURI), FARA context de reparatie (JANTA, D/R, D/M, REPARAT,
VULCANIZ, PRESIUNE, DEPRESAT) -> OE-8.
Singularul cu pozitie ("INLOCUIT ANVELOPA DR FATA") ramane OE-1: e tipic
inlocuire dupa pana/uzura in cadrul unei reparatii, nu schimb sezonier.
In plus, adauga intrari canonice OE-8 curate manual (`CURATE_OE8`) pentru
formularile uzuale de schimb sezonier absente din corpus ("MONTAT CAUCIUCURI
VARA" etc.) — fara ele, k-NN nu are niciun vecin relevant si votul cade pe
zgomot de coada.
Aplica idempotent pe:
- tabela `mapping_suggestions` (sursa corpusului k-NN la runtime)
- `app/data/operatii-etichetate.json` (seed-ul pentru instalari noi)
`source` devine "haiku_seed+fix_oe8" pe randurile corectate, "curat_manual"
pe cele adaugate (audit).
Rulare:
python3 tools/mapare-llm/fix_anvelope_oe8.py --db data/autopass.db [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
SEED_PATH = os.path.join(_ROOT, "app", "data", "operatii-etichetate.json")
VERB = re.compile(r"\b(INLOCUIT|INLOCUIRE|INL|SCHIMB|SCHIMBAT|SCHIMBARE|MONTAT|MONTARE)\b")
PLURAL = re.compile(r"ANVELOPE|CAUCIUCURI")
EXCLUDERE = re.compile(r"JANTA|D/R|D/M|REPARAT|VULCANIZ|PRESIUNE|DEPRESAT")
# Formulari canonice de schimb sezonier, absente din corpusul Haiku.
# Texte deja in forma normalizata (majuscule ASCII, fara diacritice).
CURATE_OE8 = [
"INLOCUIRE ANVELOPE",
"SCHIMBARE ANVELOPE",
"SCHIMB ANVELOPE",
"SCHIMBAT ANVELOPE IARNA",
"SCHIMBAT ANVELOPE VARA",
"MONTAT ANVELOPE IARNA",
"MONTAT ANVELOPE VARA",
"MONTAT CAUCIUCURI IARNA",
"MONTAT CAUCIUCURI VARA",
"SCHIMB CAUCIUCURI",
"SCHIMBAT ROTI IARNA",
"SCHIMBAT ROTI VARA",
"INLOCUIRE SEZONIERA ANVELOPE",
]
def este_inlocuire_anvelope(text_normalizat: str) -> bool:
"""True daca textul descrie o inlocuire/montare de anvelope (plural)."""
t = text_normalizat or ""
return bool(VERB.search(t) and PLURAL.search(t) and not EXCLUDERE.search(t))
def fix_db(conn: sqlite3.Connection, dry_run: bool) -> int:
rows = conn.execute(
"SELECT id, denumire_normalizata FROM mapping_suggestions "
"WHERE is_nul=0 AND cod_prestatie != 'OE-8' "
"AND (denumire_normalizata LIKE '%ANVELOP%' OR denumire_normalizata LIKE '%CAUCIUC%')"
).fetchall()
ids = [r[0] for r in rows if este_inlocuire_anvelope(r[1])]
if not dry_run and ids:
conn.executemany(
"UPDATE mapping_suggestions SET cod_prestatie='OE-8', "
"source='haiku_seed+fix_oe8', updated_at=datetime('now') WHERE id=?",
[(i,) for i in ids],
)
conn.commit()
return len(ids)
def adauga_curate_db(conn: sqlite3.Connection, dry_run: bool) -> int:
n = 0
for t in CURATE_OE8:
exists = conn.execute(
"SELECT 1 FROM mapping_suggestions WHERE denumire_normalizata=?", (t,)
).fetchone()
if exists:
continue
n += 1
if not dry_run:
conn.execute(
"INSERT INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) "
"VALUES (?, 'OE-8', 0, 'curat_manual', 1.0)",
(t,),
)
if not dry_run:
conn.commit()
return n
def adauga_curate_seed(items: list[dict], dry_run: bool) -> int:
existente = {it.get("denumire_normalizata") for it in items}
n = 0
for t in CURATE_OE8:
if t in existente:
continue
n += 1
if not dry_run:
items.append({
"denumire": t,
"denumire_normalizata": t,
"cod": "OE-8",
"is_nul": False,
"source": "curat_manual",
"confidence": 1.0,
})
return n
def fix_seed(dry_run: bool) -> tuple[int, int]:
with open(SEED_PATH, encoding="utf-8") as f:
items = json.load(f)
n = 0
for it in items:
if it.get("is_nul") or it.get("cod") == "OE-8":
continue
if este_inlocuire_anvelope(it.get("denumire_normalizata") or ""):
it["cod"] = "OE-8"
it["source"] = "haiku_seed+fix_oe8"
n += 1
n_curate = adauga_curate_seed(items, dry_run)
if not dry_run and (n or n_curate):
with open(SEED_PATH, "w", encoding="utf-8") as f:
# acelasi format ca genereaza_seed.py (diff git minimal la corectii)
json.dump(items, f, ensure_ascii=False, indent=2)
return n, n_curate
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--db", default=os.path.join(_ROOT, "data", "autopass.db"))
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
conn = sqlite3.connect(args.db)
n_db = fix_db(conn, args.dry_run)
n_db_curate = adauga_curate_db(conn, args.dry_run)
n_seed, n_seed_curate = fix_seed(args.dry_run)
eticheta = "(dry-run) " if args.dry_run else ""
print(
f"{eticheta}DB: {n_db} relabel + {n_db_curate} curate; "
f"seed JSON: {n_seed} relabel + {n_seed_curate} curate"
)
if __name__ == "__main__":
main()

View File

@@ -10,9 +10,12 @@ NU ground-truth uman — valorile absolute sunt optimiste; comparatia RELATIVA
intre politici si calibrarea pragului raman valide (aceeasi tinta pentru toate).
Politici evaluate:
top1 — eticheta primului vecin (comportamentul curent din enrich_suggestions)
vote<k> — vot ponderat cu similaritatea pe top-k vecini peste prag;
NUL e eticheta proprie (castiga -> supresie)
top1 — eticheta primului vecin
vote<k> — vot ponderat pe rang (EMB_VOTE_DECAY^rang) pe top-k vecini peste
prag; NUL e eticheta proprie (castiga -> supresie). Politica
livrata in enrich_suggestions. Ponderarea cu similaritatea bruta
a fost respinsa: anizotropia modelului lasa zgomotul de coada
(sim ~0.93 pe vecini fara legatura) sa invinga rangurile 1-2.
Metrice (per politica x prag):
coverage — % din exemple pentru care se emite o predictie (cod sau NUL)
@@ -85,12 +88,13 @@ def predict_top1(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> st
def predict_vote(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> str | None:
"""Vot ponderat cu similaritatea pe vecinii peste prag."""
"""Vot ponderat pe rang (decay^rang) pe vecinii peste prag — politica din enrich."""
from app.mapping import EMB_VOTE_DECAY
scoruri: dict[str, float] = {}
for lab, sim in zip(nbr_labels, nbr_sims):
for rang, (lab, sim) in enumerate(zip(nbr_labels, nbr_sims), 1):
if sim < prag:
continue
scoruri[lab] = scoruri.get(lab, 0.0) + float(sim)
scoruri[lab] = scoruri.get(lab, 0.0) + EMB_VOTE_DECAY ** rang
if not scoruri:
return None
return max(scoruri, key=lambda k: scoruri[k])