Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Agent
6674d0e3e1 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>
2026-07-07 10:26:30 +00:00
Claude Agent
809823a8ae perf+calitate sugestii k-NN: matvec numpy, vot top-5 cu prag calibrat, indicatori import
- embeddings: corpus ca matrice numpy cu norme precalculate; suggest_nearest
  = un matvec (~0.6ms/query fata de ~500ms cosine pur-Python la 17k vectori)
- enrich_suggestions: vot ponderat cu similaritatea pe top-5 vecini (NUL =
  eticheta proprie); prag 0.5 -> 0.88, calibrat LOO pe corpusul SILVER
  (tools/mapare-llm/knn_calibrate.py): precizie 90.5% -> 93.1%, cod gresit
  preselectat 7.2% -> 4.7%; sub prag abtinere -> preselectie fuzzy
- UI: codul sugerat de sistem afisat explicit cu sursa si scorul, separat de
  lista fuzzy ("potrivire text"); indicator de progres reparat pe upload
  (display:inline anula .htmx-indicator) si adaugat pe pasii 2->3 si
  "Salveaza maparile"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:10:58 +00:00
16 changed files with 1070 additions and 398 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,11 @@ import math
import threading
from typing import Protocol, runtime_checkable
try:
import numpy as _np # garantat de fastembed/onnxruntime; fallback pur-Python daca lipseste
except ImportError: # pragma: no cover
_np = None
log = logging.getLogger(__name__)
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
@@ -94,6 +99,10 @@ class EmbeddingEngine:
self._corpus_vecs: list[list[float]] = []
self._corpus_items: list[dict] = []
self._corpus_sig: str | None = None
# Matrice numpy + norme precalculate: cautarea NN devine un matvec
# (~ms) in loc de cosine pur-Python per item (~0.5s la 17k vectori).
self._corpus_matrix = None
self._corpus_norms = None
def is_available(self) -> bool:
"""True daca backend-ul e disponibil si gata de folosire."""
@@ -128,6 +137,8 @@ class EmbeddingEngine:
self._corpus_vecs = []
self._corpus_items = []
self._corpus_sig = None
self._corpus_matrix = None
self._corpus_norms = None
if not items or not self.is_available():
return
@@ -147,10 +158,32 @@ class EmbeddingEngine:
self._corpus_vecs = self._backend.embed(texts)
self._corpus_items = list(items)
self._corpus_sig = signature
self._build_matrix()
except Exception as exc:
log.warning("embeddings: index_corpus esuat: %s", exc)
# corpus ramane gol -- suggest_nearest va returna []
def _build_matrix(self) -> None:
"""Precalculeaza matricea numpy + normele corpusului pentru cautarea NN.
Esecul (numpy lipsa, vectori neregulati) lasa matricea None --
suggest_nearest cade pe scanarea pur-Python, corpusul ramane valid.
"""
if _np is None:
return
try:
matrix = _np.asarray(self._corpus_vecs, dtype=_np.float32)
if matrix.ndim != 2:
raise ValueError(f"vectori corpus neregulati (ndim={matrix.ndim})")
norms = _np.linalg.norm(matrix, axis=1)
norms[norms == 0.0] = 1.0 # vector nul -> similaritate 0, fara div/0
self._corpus_matrix = matrix
self._corpus_norms = norms
except Exception as exc:
log.warning("embeddings: matrice numpy esuata, fallback pur-Python: %s", exc)
self._corpus_matrix = None
self._corpus_norms = None
def embed(self, texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin backend (folosit la miss-uri de cache).
@@ -179,6 +212,24 @@ class EmbeddingEngine:
try:
query_vecs = self._backend.embed([str(denumire)])
query_vec = query_vecs[0]
if self._corpus_matrix is not None:
q = _np.asarray(query_vec, dtype=_np.float32)
qn = float(_np.linalg.norm(q)) or 1.0
sims = (self._corpus_matrix @ q) / (self._corpus_norms * qn)
k = min(top_k, sims.shape[0])
if k <= 0:
return []
# argpartition + sort doar pe top_k: O(n) in loc de O(n log n)
idx = _np.argpartition(-sims, k - 1)[:k]
idx = idx[_np.argsort(-sims[idx])]
return [
{
"cod": self._corpus_items[i]["cod"],
"is_nul": bool(self._corpus_items[i].get("is_nul", False)),
"similaritate": float(sims[i]),
}
for i in idx
]
scored = [
{
"cod": item["cod"],

View File

@@ -631,10 +631,24 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
)
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
# irelevante cand corpus-ul e mic sau neindexat corect).
EMB_MIN_SIMILARITATE = 0.5
# Prag minim de similaritate cosine pentru un vecin k-NN luat in calcul la vot.
# Calibrat prin leave-one-out pe corpusul SILVER (tools/mapare-llm/knn_calibrate.py):
# modelul e anizotrop (perechi nelegate scoreaza 0.79-0.87), deci sub ~0.88 vecinul
# e zgomot. La 0.88: coverage 83%, precizie 93%; la 0.5 (vechiul prag): coverage
# 100% dar 7.4% cod gresit preselectat.
EMB_MIN_SIMILARITATE = 0.88
# 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
@@ -769,7 +783,8 @@ def enrich_suggestions(
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None,
'embedding_similaritate': float|None, 'nul': bool}
}
INVARIANTE:
@@ -783,7 +798,10 @@ def enrich_suggestions(
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
surse: dict = {
"gold_partajat": None, "silver": None,
"embedding": None, "embedding_similaritate": None, "nul": False,
}
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
@@ -829,15 +847,28 @@ def enrich_suggestions(
# Corpusul k-NN e text NORMALIZAT (denumire_normalizata),
# 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=1)
# Prag minim: similaritate prea mica = sugestie inutila.
# Evita recomandari irelevante cand corpus-ul e mic/partial.
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
if nn[0].get("is_nul"):
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod.
nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=EMB_VOTE_TOP_K)
# 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 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) + 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])
if castigator == "NUL":
# Vecinatate NUL (non-operatie) = semnal de SUPRESIE, nu cod.
surse["nul"] = True
elif nn[0].get("cod"):
surse["embedding"] = str(nn[0]["cod"])
else:
surse["embedding"] = castigator
surse["embedding_similaritate"] = sim_max[castigator]
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza

View File

@@ -1392,7 +1392,10 @@ def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list
"denumire": item.get("denumire"),
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
"sugestie_principala": None,
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
"surse_sugestie": {
"gold_partajat": None, "silver": None,
"embedding": None, "embedding_similaritate": None, "nul": False,
},
}
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:
@@ -3034,7 +3037,10 @@ def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict], con
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
# Init default pe FIECARE entry -> contract template identic (conn=None inclus).
entry["sugestie_principala"] = None
entry["surse_sugestie"] = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
entry["surse_sugestie"] = {
"gold_partajat": None, "silver": None,
"embedding": None, "embedding_similaritate": None, "nul": False,
}
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:
enriched = enrich_suggestions(conn, entry["denumire"])

View File

@@ -62,10 +62,17 @@
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
pentru k-NN si scorul de similaritate. #}
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
{%- endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% if e.suggestions %}
{% if e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}<span class="muted">· potrivire text:</span>{% endif %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}

View File

@@ -65,7 +65,8 @@
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#mapcol-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
@@ -162,6 +163,11 @@
style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
Salveaza si continua la preview
</button>
<span id="mapcol-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm);" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se verifica randurile si se calculeaza sugestiile...
</span>
{% if not prima_inreg %}
<span style="font-size:var(--fs-xs); color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.

View File

@@ -81,7 +81,8 @@
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
</p>
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
hx-target="#import-section" hx-swap="outerHTML">
hx-target="#import-section" hx-swap="outerHTML"
hx-indicator="#mapop-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
@@ -106,9 +107,16 @@
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% endif %}
{# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
pentru k-NN si scorul de similaritate. #}
<strong>{{ e.sugestie_principala.cod_prestatie }}</strong>
{%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
{%- endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
<span class="sugg-sursa sugg-sursa--nul" title="pare non-operatie (ITP/plata/discount...)">non-operatie</span>
{% endif %}
{% if e.suggestions and (e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul)) %}<span class="muted">· potrivire text:</span>{% endif %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
@@ -127,8 +135,13 @@
</div>
</div>
{% endfor %}
<div style="margin-top:12px;">
<div style="margin-top:12px; display:flex; align-items:center; gap:12px;">
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
<span id="mapop-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm);" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se salveaza si se recalculeaza preview-ul...
</span>
</div>
</form>
</div>

View File

@@ -122,9 +122,12 @@
</p>
{% endif %}
{# FARA display in stilul inline: ar suprascrie .htmx-indicator{display:none}
si indicatorul ar ramane vizibil permanent (deci invizibil ca progres). #}
<span id="upload-spinner" class="htmx-indicator muted"
style="font-size:var(--fs-sm); margin-top:6px; display:inline;">
se parseaza fisierul...
style="font-size:var(--fs-sm); margin-top:6px;" role="status">
<span class="spin-dot" aria-hidden="true"></span>
se parseaza fisierul si se pregatesc sugestiile...
</span>
</form>
</div>

View File

@@ -176,6 +176,11 @@
flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; }
/* Cerc rotitor pentru indicatorii de procesare (import: parsare + sugestii) */
.spin-dot { display:inline-block; width:13px; height:13px; border:2px solid var(--line);
border-top-color:var(--accent); border-radius:50%; vertical-align:-2px;
margin-right:6px; animation:spin-dot .7s linear infinite; }
@keyframes spin-dot { to { transform:rotate(360deg); } }
/* Selector tema stil pill — icon + eticheta temei curente.
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;

View File

@@ -25,3 +25,5 @@ dbfread==2.0.7
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
fastembed>=0.8.0
# Folosit direct la cautarea NN (matvec pe corpus); oricum tras de fastembed/onnxruntime.
numpy>=1.26

View File

@@ -113,6 +113,58 @@ def test_abtinere_sub_prag(conn, monkeypatch):
assert out["sugestie_principala"] is None
def _mock_embedding_multi(monkeypatch, vecini):
"""Mock suggest_nearest cu o lista de vecini [(cod, sim, is_nul), ...]."""
import app.embeddings as emb
monkeypatch.setattr(emb, "has_corpus", lambda: True)
monkeypatch.setattr(
emb, "suggest_nearest",
lambda text, top_k=1: [
{"cod": c, "is_nul": n, "similaritate": s} for c, s, n in vecini
][:top_k],
)
def test_vot_topk_bate_top1_pe_etichete_contradictorii(conn, monkeypatch):
"""Corpus cu etichete contradictorii pe denumiri aproape identice:
top-1 ar da OE-1, dar votul ponderat (2x OE-8 vs 1x OE-1) da OE-8."""
from app.mapping import enrich_suggestions
_mock_embedding_multi(monkeypatch, [
("OE-1", 0.94, False),
("OE-8", 0.93, False),
("OE-8", 0.92, False),
])
out = enrich_suggestions(conn, "INLOCUIRE ANVELOPE")
assert out["surse"]["embedding"] == "OE-8"
assert out["surse"]["embedding_similaritate"] == 0.93
def test_vot_vecini_sub_prag_nu_voteaza(conn, monkeypatch):
"""Vecinii sub EMB_MIN_SIMILARITATE nu intra in vot, chiar daca sunt majoritari."""
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
_mock_embedding_multi(monkeypatch, [
("OE-3", EMB_MIN_SIMILARITATE + 0.01, False),
("OE-1", EMB_MIN_SIMILARITATE - 0.05, False),
("OE-1", EMB_MIN_SIMILARITATE - 0.05, False),
])
out = enrich_suggestions(conn, "CEVA NEVAZUT")
assert out["surse"]["embedding"] == "OE-3"
def test_vot_nul_majoritar_supreseaza(conn, monkeypatch):
"""Majoritate NUL in vecinatate -> supresie, chiar daca top-1 e un cod."""
from app.mapping import enrich_suggestions
_mock_embedding_multi(monkeypatch, [
("OE-1", 0.93, False),
(None, 0.92, True),
(None, 0.92, True),
])
out = enrich_suggestions(conn, "CEVA CARE SEAMANA CU GUNOI")
assert out["surse"]["embedding"] is None
assert out["surse"]["nul"] is True
assert out["sugestie_principala"] is None
def test_vecin_knn_nul_supreseaza(conn, monkeypatch):
from app.mapping import enrich_suggestions
_mock_embedding(monkeypatch, None, 0.99, is_nul=True) # vecin NUL peste prag

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

@@ -278,7 +278,7 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
conn.execute(
"INSERT OR REPLACE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
("Schimb ulei", "UL-1", 0, "llm", 0.95),
("Schimb ulei motor", "UL-1", 0, "llm", 0.95),
)
conn.execute(
"INSERT OR REPLACE INTO mapping_suggestions "
@@ -292,7 +292,8 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
ensure_embeddings_corpus(conn)
assert emb_mod.has_corpus(), "corpusul trebuie indexat cand flagul e activ"
# "schimbat uleiul motor" -> vector [1,1,0] -> cel mai apropiat = UL-1 (Schimb ulei).
# "schimbat uleiul motor" -> vector [1,1,0] -> identic cu "Schimb ulei motor"
# (cosine 1.0, peste EMB_MIN_SIMILARITATE calibrat) -> UL-1.
result = enrich_suggestions(conn, "schimbat uleiul motor", include_embeddings=True)
assert result["surse"]["embedding"] == "UL-1", (
f"embeddings trebuie sa sugereze UL-1, got {result['surse']}"

View File

@@ -217,4 +217,7 @@ def test_collect_unmapped_ops_conn_none_contract_template(env):
assert len(out) == 1
e = out[0]
assert e["sugestie_principala"] is None
assert e["surse_sugestie"] == {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
assert e["surse_sugestie"] == {
"gold_partajat": None, "silver": None,
"embedding": None, "embedding_similaritate": None, "nul": False,
}

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

@@ -0,0 +1,165 @@
"""Calibrare politica k-NN pentru sugestia embeddings (top-1 vs vot ponderat top-k).
Metodologie: leave-one-out pe corpusul SILVER (`mapping_suggestions`) folosind
vectorii persistati in `embedding_cache` (zero re-embedding). Pentru fiecare
exemplu: se exclude din corpus, se prezice din vecinii ramasi, se compara cu
eticheta lui SILVER (cod sau NUL).
ATENTIE la interpretare: SILVER e etichetat de LLM (~95% calitate la scara),
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
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)
precizie — % predictii corecte din cele emise
cod-gresit — % din TOATE exemplele unde s-a emis un COD diferit de gold
(critic: cod gresit preselectat = risc FINALIZATA eronata)
Rulare:
python3 tools/mapare-llm/knn_calibrate.py --db data/autopass.db
python3 tools/mapare-llm/knn_calibrate.py --db data/autopass.db --sample 5000 --seed 42
"""
from __future__ import annotations
import argparse
import os
import random
import sqlite3
import sys
import numpy as np
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from app import embedding_cache as cache # noqa: E402
from app.embeddings import FASTEMBED_MODEL # noqa: E402
NUL = "NUL"
def load_corpus(db_path: str):
"""Incarca (etichete, matrice vectori) aliniate pozitional din SQLite."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions "
"WHERE denumire_normalizata IS NOT NULL"
).fetchall()
vecmap = {
h: np.frombuffer(b, dtype=np.float32)
for h, b in conn.execute(
"SELECT text_hash, vector FROM embedding_cache WHERE model=?", (FASTEMBED_MODEL,)
)
}
labels: list[str] = []
vecs: list[np.ndarray] = []
lipsa = 0
for r in rows:
v = vecmap.get(cache.text_hash(r["denumire_normalizata"]))
if v is None:
lipsa += 1
continue
labels.append(NUL if r["is_nul"] else str(r["cod_prestatie"]))
vecs.append(v)
if lipsa:
print(f"AVERTISMENT: {lipsa} exemple fara vector in cache (excluse)")
matrix = np.vstack(vecs)
norms = np.linalg.norm(matrix, axis=1)
norms[norms == 0.0] = 1.0
return labels, matrix / norms[:, None]
def predict_top1(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> str | None:
"""Politica curenta: eticheta celui mai apropiat vecin, daca trece pragul."""
if nbr_sims[0] < prag:
return None
return nbr_labels[0]
def predict_vote(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> str | None:
"""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 rang, (lab, sim) in enumerate(zip(nbr_labels, nbr_sims), 1):
if sim < prag:
continue
scoruri[lab] = scoruri.get(lab, 0.0) + EMB_VOTE_DECAY ** rang
if not scoruri:
return None
return max(scoruri, key=lambda k: scoruri[k])
def evalueaza(labels, unit, indices, k_max, politici, praguri):
"""LOO: pentru fiecare index, top-k_max vecini (fara el insusi), apoi
aplica fiecare (politica, prag) pe aceiasi vecini. Intoarce metrice agregate."""
stats = {(p, prag): {"emis": 0, "corect": 0, "cod_gresit": 0} for p in politici for prag in praguri}
n = len(indices)
for count, i in enumerate(indices, 1):
sims = unit @ unit[i]
sims[i] = -1.0 # exclude exemplul insusi (leave-one-out)
top = np.argpartition(-sims, k_max)[:k_max]
top = top[np.argsort(-sims[top])]
nbr_labels = [labels[j] for j in top]
nbr_sims = sims[top]
gold = labels[i]
for (pname, prag), st in stats.items():
pred = politici[pname](nbr_labels, nbr_sims, prag)
if pred is None:
continue
st["emis"] += 1
if pred == gold:
st["corect"] += 1
elif pred != NUL:
st["cod_gresit"] += 1 # a emis un COD gresit (sau cod in loc de NUL)
if count % 2000 == 0:
print(f" ...{count}/{n}", file=sys.stderr)
return stats, n
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--db", default=os.path.join(_ROOT, "data", "autopass.db"))
ap.add_argument("--sample", type=int, default=0, help="LOO doar pe un esantion (0 = tot corpusul)")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--k", type=int, default=5, help="k pentru politica de vot")
args = ap.parse_args()
labels, unit = load_corpus(args.db)
print(f"corpus: {len(labels)} exemple ({sum(1 for l in labels if l == NUL)} NUL)")
indices = list(range(len(labels)))
if args.sample and args.sample < len(indices):
random.Random(args.seed).shuffle(indices)
indices = indices[: args.sample]
print(f"evaluare LOO pe {len(indices)} exemple, k={args.k}")
politici = {
"top1": predict_top1,
f"vote{args.k}": predict_vote,
}
praguri = [0.5, 0.7, 0.8, 0.85, 0.88, 0.9, 0.92, 0.95]
stats, n = evalueaza(labels, unit, indices, args.k, politici, praguri)
print(f"\n{'politica':<8} {'prag':>5} {'coverage':>9} {'precizie':>9} {'cod-gresit':>11}")
for pname in politici:
for prag in praguri:
st = stats[(pname, prag)]
cov = st["emis"] / n
prec = (st["corect"] / st["emis"]) if st["emis"] else 0.0
wc = st["cod_gresit"] / n
print(f"{pname:<8} {prag:>5.2f} {cov:>8.1%} {prec:>8.1%} {wc:>10.2%}")
if __name__ == "__main__":
main()