feat(5.18): corpus k-NN exemple etichetate + seed real Haiku (17181 op)

Seed app/data/operatii-etichetate.json regenerat cu subagenti Haiku pe TOATE
cele 17181 operatii distincte (ordine frecventa, 100%), inlocuind seed-ul Groq
(3758). Validare Haiku vs Groq pe 157 op etichetate: la dezacorduri Haiku corect
~22/30, Groq ~0. Haiku prinde gunoiul ratat de Groq (ITP, chirie anvelope, nume
piese fara actiune): NUL 2200 (12.8%) vs ~7.6% Groq; adaptare electronica OE-7
(nu OE-5), placute frana uzura OE-1 (nu OE-F avarie).

US-001..006: prefiltru NUL determinist, etichetator offline, generator seed,
seeder mapping_suggestions (in init_db, gated seed_operatii_enabled), embeddings
indexeaza corpus etichetat, enrich NUL+kNN. Distributie seed: OE-1 80.1%, NUL
12.8%, OE-2 3.5%, restul rar (OE-4/3/7/8/R/I/5, AITLV, R-ODO).

config: seed_operatii_enabled=True + embeddings_enabled=True implicit (SILVER
populat + sugestii semantice; ambele suggestion-only, dezactivabile prin env).

Suita: 1387 passed, 1 deselected (live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-29 06:59:15 +00:00
parent c05fa00007
commit 756f77730f
17 changed files with 139308 additions and 44 deletions

View File

@@ -117,11 +117,21 @@ class Settings(BaseSettings):
enforce_plans: bool = True
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari.
# Activeaza explicit in productie (start.sh/Docker/.env) cand vrei sugestii semantice.
# OFF pastreaza suita de teste rapida si /mapari instant (cade pe GOLD/SILVER+fuzzy).
embeddings_enabled: bool = False
# ACTIVAT implicit: editorul de mapari ofera sugestii semantice (model fastembed/ONNX).
# Cost: prima folosire lazy-load-eaza modelul (~230MB pe disc) sincron in thread-ul de
# cerere -> prima cerere /mapari poate dura 30-120s pana modelul intra in memorie; cererile
# urmatoare sunt instant. SUGGESTION-ONLY: nu intra in resolve_prestatii (nu auto-trimite).
# Pune-l pe False (start.sh/Docker/.env: AUTOPASS_EMBEDDINGS_ENABLED=false) cand vrei
# /mapari instant la prima cerere sau suita de teste rapida (cade pe GOLD/SILVER+fuzzy).
embeddings_enabled: bool = True
# --- Seed corpus operatii etichetate (SILVER, PRD 5.18 US-004) ---
# ACTIVAT implicit: la init_db, populeaza mapping_suggestions din artefactul comis
# `app/data/operatii-etichetate.json` (INSERT OR IGNORE). Asa SILVER nu mai e gol in
# productie -> sugestii exact-match + corpus k-NN reale. SUGGESTION-ONLY.
# Pune-l pe False (AUTOPASS_SEED_OPERATII_ENABLED=false) cand vrei SILVER gol —
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
seed_operatii_enabled: bool = True
@property
def rar_base_url(self) -> str:

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,13 @@ def init_db() -> None:
from .mapping import seed_nomenclator_if_empty
seed_nomenclator_if_empty(conn)
# Seed corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
# Gated: OFF in teste (conftest), ON in productie. INSERT OR IGNORE -> idempotent.
if get_settings().seed_operatii_enabled:
from .operatii_seed import seed_operatii_etichetate
seed_operatii_etichetate(conn)
conn.commit()
finally:
conn.close()

View File

@@ -135,10 +135,12 @@ class EmbeddingEngine:
denumire: str,
top_k: int = 3,
) -> list[dict]:
"""Returneaza top_k vecini cosine [{cod, similaritate}].
"""Returneaza top_k vecini cosine [{cod, is_nul, similaritate}].
Returneaza [] daca backend-ul lipseste, corpus-ul e gol sau apare
orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
`is_nul` (PRD 5.18 US-005): cand corpusul include exemple NUL (non-operatii),
un vecin NUL = semnal de SUPRESIE, nu cod. Default False pe corpusuri vechi
fara `is_nul` in itemi. Returneaza [] daca backend-ul lipseste, corpus-ul e gol
sau apare orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
"""
if not self.is_available() or not self._corpus_items:
return []
@@ -149,6 +151,7 @@ class EmbeddingEngine:
scored = [
{
"cod": item["cod"],
"is_nul": bool(item.get("is_nul", False)),
"similaritate": _cosine_similarity(query_vec, vec),
}
for item, vec in zip(self._corpus_items, self._corpus_vecs)

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from typing import Any
@@ -49,6 +50,60 @@ def normalize_for_match(value: object) -> str:
return " ".join(s.upper().split())
# --------------------------------------------------------------------------- #
# Pre-filtru determinist non-operatii (NUL) — US-001 PRD 5.18 #
# --------------------------------------------------------------------------- #
#
# Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar
# 64%: gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa
# semantic ca OE-1. Un pre-filtru text/regex il marcheaza NUL INAINTE de k-NN.
#
# Garantie: ZERO fals-pozitiv pe operatii reale. Regulile au fost calibrate pe
# `docs/operatii-service/*.csv` (toate aparitiile distincte). Triggerele NEambigue
# (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, TAXA) sunt neconditionate (0 FP masurat).
# Triggerele AMBIGUE (TRACTARE, NR INMATRICULARE + pattern placuta) apar si in
# operatii reale ("D/R CARLIG TRACTARE", "D/R ELECTROMOTOR CT 44 MKY") -> sunt
# ECRANATE de un context de piesa/operatie (`_NUL_CTX_PIESA`).
# Trigger-uri neambigue (substring/regex pe text normalizat).
_NUL_ITP = re.compile(r"(?:\bITP\b|\d\s*X\s*ITP|X\s*ITP\b|\bITP[.,])")
_NUL_PLATA = re.compile(r"\b(ACHITAT|ACHITARE|PLATA|PLATIT|PLATIRE)\b")
_NUL_DISCOUNT = re.compile(r"\b(DISCOUNT|REDUCERE)\b")
_NUL_TAXA = re.compile(r"\bTAXA\b")
# Trigger-uri ambigue — valide ca NUL DOAR in absenta unui context de piesa.
_NUL_TRACTARE = re.compile(r"\b(TRACTARE|TRACTARI)\b")
_NUL_NR_PLACUTA = re.compile(
r"(\bNR\s+INMATRICULARE\b|\bNUMAR\s+INMATRICULARE\b|\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b)"
)
# Daca apare oricare cuvant de aici, TRACTARE/placuta e nume de piesa sau operatie
# reala (carlig/capac de tractare, suport placuta, placuta lipita la o reparatie).
_NUL_CTX_PIESA = re.compile(
r"\b(D/R|D-R|CARLIG|CAPAC|BARA|PROTECTIE|MONTAT|MONTAJ|DEMONTAT|INLOCUIT|"
r"INLOCUIRE|REPARAT|REPARATIE|VOPSIT|SCHIMBAT|SUPORT)\b"
)
def prefiltru_nul(denumire: object) -> bool:
"""True daca operatia e gunoi evident (non-operatie de service) -> NUL determinist.
Ruleaza INAINTE de k-NN/embeddings in `enrich_suggestions` (US-006). Pur, fara DB.
Zero fals-pozitiv pe operatii reale (vezi comentariul de mai sus + tests).
"""
text = normalize_for_match(denumire)
if not text:
return False
# Neambigue: 0 FP masurat, fara ecranare.
if _NUL_ITP.search(text) or _NUL_PLATA.search(text) or _NUL_DISCOUNT.search(text) or _NUL_TAXA.search(text):
return True
# Ambigue: doar daca NU e context de piesa.
if _NUL_CTX_PIESA.search(text):
return False
if _NUL_TRACTARE.search(text) or _NUL_NR_PLACUTA.search(text):
return True
return False
def suggest_codes(
denumire: object,
nomenclator: list[dict],
@@ -576,51 +631,58 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
EMB_MIN_SIMILARITATE = 0.5
def _corpus_signature(nomenclator: list[dict]) -> str:
"""Semnatura stabila a nomenclatorului pentru cache-ul corpusului embeddings.
def _corpus_signature_silver(rows: list) -> str:
"""Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
Hash pe perechile (cod, denumire) sortate dupa cod -> se schimba la orice
add/remove/redenumire de cod, ramane stabila altfel (evita re-embed inutil).
Hash pe (denumire_normalizata, cod, is_nul) sortat -> se schimba la orice
add/remove/redenumire/relabel, ramane stabila altfel (evita re-embed inutil).
"""
pairs = sorted(
(str(n.get("cod_prestatie") or ""), str(n.get("nume_prestatie") or ""))
for n in nomenclator
triples = sorted(
(str(r["denumire_normalizata"] or ""), str(r["cod_prestatie"] or ""), int(r["is_nul"] or 0))
for r in rows
)
blob = "".join(f"{c}{d}" for c, d in pairs)
blob = "".join(f"{d}|{c}|{n}" for d, c, n in triples)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
"""Construieste/actualizeaza corpusul embeddings din nomenclator (Stratul 2 PRD 5.14).
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT (PRD 5.18 US-005).
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF): cand e dezactivat, e un
no-op total (nu atinge modelul, nu interogheaza nomenclatorul) -> /mapari instant
+ suita de teste rapida; sugestiile cad pe GOLD/SILVER + fuzzy.
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
{denumire_normalizata -> cod, is_nul}, NU cele 18 categorii generice din
`nomenclator_rar`. k-NN peste exemple reale e net mai precis (94.3% acord LLM).
Parametrul `nomenclator` e pastrat pentru compatibilitatea apelantilor, dar nu mai
e folosit ca sursa.
Cand e activat: indexeaza corpusul {denumire=nume_prestatie, cod=cod_prestatie}
o singura data (lazy-load modelul ~230MB la prima chemare), re-indexeaza doar
cand semnatura nomenclatorului s-a schimbat. Degradare gratioasa: orice eroare
(model absent, embed esuat) lasa corpusul gol -> enrich_suggestions cade pe restul.
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
Apelat de apelantii care imbogatesc sugestii (pending_unmapped,
_nemapate_pentru_submission) INAINTE de bucla de enrich_suggestions, NU din
enrich_suggestions (care ramane o interogare ieftina cu garda has_corpus()).
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie
(US-006). Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
"""
from .config import get_settings
if not get_settings().embeddings_enabled:
return
try:
from . import embeddings as _emb
nomen = nomenclator if nomenclator is not None else load_nomenclator(conn)
if not nomen:
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
if not rows:
return
sig = _corpus_signature(nomen)
sig = _corpus_signature_silver(rows)
if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi nomenclator -> nimic de facut
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
items = [
{"denumire": str(n["nume_prestatie"]), "cod": str(n["cod_prestatie"])}
for n in nomen
if n.get("nume_prestatie") and n.get("cod_prestatie")
{
"denumire": str(r["denumire_normalizata"]),
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
"is_nul": bool(r["is_nul"]),
}
for r in rows
if r["denumire_normalizata"]
]
_emb.index_corpus(items, signature=sig)
except Exception:
@@ -641,26 +703,38 @@ def enrich_suggestions(
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
Ordine completa (PRD 5.18 US-006):
pre-filtru NUL determinist -> (daca NUL: fara cod, `surse['nul']=True`)
altfel GOLD partajat > exact (SILVER) > k-NN embeddings.
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None}
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
}
INVARIANTE:
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4).
- Pre-filtru NUL (US-001) ruleaza PRIMUL: gunoiul evident (ITP/plata/discount...) e
marcat non-operatie INAINTE de k-NN, fara sugestie de cod.
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4); vecin k-NN NUL idem.
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
returneaza sugestia disponibila din celelalte surse, fara exceptie.
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None}
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
# 0. Pre-filtru NUL determinist (US-001) INAINTE de orice k-NN/lookup: non-operatie
# evidenta -> fara cod, scurtcircuit (nu interogheaza embeddings/SILVER pe gunoi).
if prefiltru_nul(denumire):
surse["nul"] = True
return {"sugestie_principala": None, "surse": surse}
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
# toate, independent de care castiga ca sugestie principala.
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
@@ -693,11 +767,18 @@ def enrich_suggestions(
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
# flagul e off, has_corpus() ramane False si calea e un no-op real.
if _emb.has_corpus():
nn = _emb.suggest_nearest(str(denumire), top_k=1)
# F1 (US-005): 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:
surse["embedding"] = str(nn[0]["cod"])
if nn[0].get("is_nul"):
# Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod (US-006).
surse["nul"] = True
elif nn[0].get("cod"):
surse["embedding"] = str(nn[0]["cod"])
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza

59
app/operatii_seed.py Normal file
View File

@@ -0,0 +1,59 @@
"""Seeder corpus operatii etichetate -> mapping_suggestions (SILVER, PRD 5.18 US-004).
Artefactul `app/data/operatii-etichetate.json` e produs offline de
`tools/mapare-llm/genereaza_seed.py` (etichetare LM Studio, o singura data) si comis
in repo. La `init_db` il incarcam in `mapping_suggestions` cu INSERT OR IGNORE, ca
SILVER sa nu mai fie gol in productie (sugestii exact-match + corpus k-NN reale).
Format seed: [{denumire, denumire_normalizata, cod, is_nul, source, confidence}].
Reutilizeaza `shared_store.seed_suggestions` (normalizeaza cheia + impune NUL->cod NULL,
INSERT OR IGNORE). NB (F10): confirmarile UMANE stau in `shared_mappings`, NU aici —
deci INSERT OR IGNORE pastreaza codul LLM existent la re-seed (v1 = ignore, nu upsert).
SUGGESTION-ONLY (invariant #13): nimic din SILVER nu intra in resolve_prestatii/load_mapping.
"""
from __future__ import annotations
import json
import os
import sqlite3
from .shared_store import seed_suggestions
SEED_PATH = os.path.join(os.path.dirname(__file__), "data", "operatii-etichetate.json")
def load_seed_file(path: str = SEED_PATH) -> list[dict]:
"""Citeste artefactul seed. Lipsa / invalid -> [] (degradare gratioasa)."""
if not path or not os.path.exists(path):
return []
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (ValueError, OSError):
return []
return data if isinstance(data, list) else []
def seed_operatii_etichetate(conn: sqlite3.Connection, path: str = SEED_PATH) -> int:
"""Incarca seedul in mapping_suggestions (INSERT OR IGNORE). Intoarce nr. randuri inserate.
Mapeaza cheia seedului `cod` -> `cod_prestatie` (forma asteptata de seed_suggestions);
`is_nul=True` forteaza cod NULL acolo. Idempotent: re-rularea nu dubleaza randuri.
"""
raw = load_seed_file(path)
if not raw:
return 0
items = [
{
"denumire": e.get("denumire") or e.get("denumire_normalizata") or "",
"cod_prestatie": e.get("cod"),
"is_nul": bool(e.get("is_nul")),
"source": e.get("source") or "llm_seed",
"confidence": e.get("confidence") or 0.0,
}
for e in raw
if isinstance(e, dict)
]
return seed_suggestions(conn, items)