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:
@@ -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:
|
||||
|
||||
137450
app/data/operatii-etichetate.json
Normal file
137450
app/data/operatii-etichetate.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
143
app/mapping.py
143
app/mapping.py
@@ -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
59
app/operatii_seed.py
Normal 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)
|
||||
Reference in New Issue
Block a user