Compare commits

..

6 Commits

Author SHA1 Message Date
Claude Agent
e1243f603e fix(mapari-mobil): butoane Salveaza/Sterge vizibile + carduri compacte la 390px
Doua probleme raportate de user pe pagina Mapari pe mobil (screenshot 390px):

1. Butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
   (specificitate 0,1,2) batea `.act {width:44px}` (0,1,0), deci cele doua butoane
   .act deveneau full-width si al doilea (Sterge) iesea din card (celula are nowrap).
   Fix: bloc @media (max-width:767px) nou, ultimul in <style> (castiga pe cascada) —
   celula Actiuni devine flex-row, butoanele .act width:auto/flex:1 cu text vizibil.

2. Carduri prea inalte + label-uri inutile: .tabel-card randa etichetele data-eticheta
   ca pseudo-titluri + linia redundanta "acum: COD — nume" (duplica select-ul de sub).
   Fix: pe mobil se ascund pseudo-etichetele si linia .map-acum, padding strans.
   Cardul trece de la ~7 la ~3 elemente. Atributele data-eticheta raman in DOM (a11y+teste).

Include si raportul de comparatie UI 5.16 cu appendix-ul /autoplan (CEO/Design/Eng,
audit trail, plan aprobat) + addendum cu corectia la sectiunea 8 ("Mapari conform" era
gresit: nu testase randarea mobila a paginilor actionabile).

Verificare: 80 teste web verzi (test_web_responsive + mapari + submissions + tabs + modal);
confirmare vizuala la 390px (render TestClient -> screenshot Playwright).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:18:27 +00:00
Claude Agent
80d90f317d fix(5.18): corectie 8 etichete coduri rare (verificare Opus pe coada sparse)
Subagent Opus a verificat cele 139 operatii cu coduri rare (R-ODO, OE-I,
OE-5, AITLV, OE-R, OE-8, OE-7): 130 confirmate, 8 corectate (source=opus_review):
- PERNA AER STG SPATE / PUNTE MOTRICA (x3): OE-R -> OE-1 (burduf suspensie
  pneumatica, NU airbag de retinere)
- INLOCUIT CABLU/SENZOR KM (x2): OE-I -> R-ODO (reparatie odometru, nu istoric import)
- SCHIMBAT FOAIE ARC: OE-8 -> OE-1 (arc suspensie, nu anvelopa)
- RULMENT SPATE (PIESA): OE-7 -> NUL (doar nume piesa, fara actiune)
- INLOCUIT CALCULATOR P INJECTIE: OE-7 -> OE-1 (inlocuire hardware, fara programare)

NUL 2200 -> 2201. SERVICII VULCANIZARE (Opus->NUL, increderea 0.5) tinut
neschimbat: vulcanizarea e reparatie reala de pneu, nu non-operatie (de decis user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:41:32 +00:00
Claude Agent
12021eb269 feat(5.18): VERIFY+CLOSE — US-007 badge sursa + fix findings code-review
VERIFY PASS pe corpus k-NN exemple etichetate (seed real 17181 Haiku, comis
in 756f777): suita 1392 passed, 1 deselected (live); smoke init_db seeder
(17181/NUL=2200/idempotent); toate codurile in nomenclator.

US-007 (cerere user la CLOSE) — badge sursa pe sugestia fuzzy din editor:
- _mapari.html: chip confirmat (GOLD) / similar (SILVER+k-NN) / non-operatie (NUL)
- base.html: .sugg-sursa--{confirmat,similar,nul} pe tokeni de tema (color-mix)
- routes.py: cheia `nul` adaugata in surse_sugestie default (finding cross-file)
- tests/test_web_badge_sursa.py: gold/silver/nul/fara-sursa (4 teste)
- E2E render live verificat in serverul real (/_fragments/mapari)

CLOSE /code-review high (main..HEAD, 3 finder x 8 unghiuri) — runtime curat,
invariant #13 intact; 3 findings low/cosmetic REPARATE + lock-uite:
- shared_store.seed_suggestions: cod whitespace -> NULL (era ''), + test lock
- genereaza_seed.py: with open(...) in loc de open().read() (FD leak tool offline)
- embeddings.py: docstring-uri aliniate la [{cod, is_nul, similaritate}]

ROADMAP: 5.18 LIVRAT. PRD: raport VERIFY/CLOSE scris.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:29:14 +00:00
Claude Agent
308fee6c27 fix(start-test): suprima erorile ONNX thread affinity in LXC
OMP_NUM_THREADS=1 previne incercarea ONNX de a seta pthread affinity,
care esua cu EINVAL in containere LXC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 07:03:35 +00:00
Claude Agent
756f77730f 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>
2026-06-29 06:59:15 +00:00
Claude Agent
c05fa00007 fix(worker): keepalive RAR ca dashboard-ul sa nu afiseze fals "RAR inaccesibil"
Dashboard-ul deduce starea RAR din vechimea ultimului login reusit (>30h ->
"indisponibil?"). Cand coada e goala, worker-ul nu are de ce sa se logheze,
deci timestamp-ul devine stale si banner-ul "Blocat: RAR inaccesibil —
declaratiile NU pleaca" apare fals, desi RAR raspunde.

Worker-ul face acum un login de proba o data pe zi (interval configurabil,
24h < pragul de 30h) cand coada e goala: pe succes reimprospateaza
last_rar_login_ok; pe esec real last_rar_login_ok ramane vechi -> dashboard
degradeaza corect. Forteaza login real (invalideaza sesiunea) ca proba sa fie
autentica. Gating: cel mult o sondare pe interval, sa nu hartuiasca RAR jos.

_keepalive_target sare conturile ale caror creds NU se decripteaza sub cheia
curenta (start.sh both genereaza cheie efemera noua la fiecare pornire ->
creds durabile vechi dau decrypt None) si cade pe creds <test> in dev.

Teste: tests/test_worker_keepalive_rar.py (6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:48:32 +00:00
28 changed files with 140380 additions and 54 deletions

View File

@@ -99,6 +99,11 @@ class Settings(BaseSettings):
# Dev: foloseste creds <test> din settings.xml pt login worker. In productie
# creds vin per-cerere de la ROAAUTO — lasa False.
worker_use_test_creds: bool = False
# Keepalive RAR: cand coada e goala, worker-ul face un login de proba la fiecare
# atata timp ca sa pastreze last_rar_login_ok proaspat (sub pragul de 30h al
# dashboard-ului) — altfel banner-ul "RAR inaccesibil" apare fals doar din lipsa
# de trafic. 0 = dezactivat. Implicit o data pe zi (24h < 30h, margine de 6h).
worker_rar_keepalive_interval_s: int = 86400
worker_sending_lease_s: int = 120 # rand 'sending' mai vechi de atat = orfan (worker mort mid-POST)
worker_retry_base_s: int = 5 # backoff = base * 2^retry (plafonat la max)
worker_retry_max_s: int = 300
@@ -112,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

@@ -11,7 +11,7 @@ Design (PRD 5.14, Decision #16/#16b):
API public (nivel modul):
index_corpus(items) -> None
suggest_nearest(text, top_k) -> [{cod, similaritate}]
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
is_available() -> bool
Clase (pentru teste / injectare backend):
@@ -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)
@@ -239,7 +242,7 @@ def index_corpus(items: list[dict], signature: str | None = None) -> None:
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
"""Returneaza top_k sugestii [{cod, similaritate}] sau [] la eroare.
"""Returneaza top_k sugestii [{cod, is_nul, similaritate}] sau [] la eroare.
Sigur de apelat indiferent de starea backend-ului.
"""

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)

View File

@@ -46,9 +46,12 @@ def seed_suggestions(
continue
is_nul = 1 if item.get("is_nul") else 0
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
cod = None if is_nul else ((item.get("cod_prestatie") or "") or None)
if cod:
cod = cod.strip().upper()
# Normalizeaza INAINTE de truthiness: un cod whitespace-only (" ") sau
# ne-string trebuie sa devina NULL, nu '' (altfel rand non-NUL cu cod gol).
cod = None
if not is_nul:
raw_cod = str(item.get("cod_prestatie") or "").strip().upper()
cod = raw_cod or None
source = str(item.get("source") or "llm")
confidence = float(item.get("confidence") or 0.0)
cur = conn.execute(

View File

@@ -1247,7 +1247,7 @@ 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},
"surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
}
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:

View File

@@ -54,11 +54,22 @@
<div class="muted">{{ e.denumire or '(fara denumire)' }}</div>
</td>
<td class="muted" style="font-size:12px;" data-eticheta="Sugestii">
{# 5.18 US-007: badge sursa pe sugestia sistemului — confirmat (GOLD) / similar
(SILVER+embedding k-NN) / non-operatie (pre-filtru NUL). Suggestion-only. #}
{% if e.sugestie_principala %}
{% if e.sugestie_principala.sursa == 'gold_partajat' %}
<span class="sugg-sursa sugg-sursa--confirmat" title="cod confirmat de un operator">confirmat</span>
{% else %}
<span class="sugg-sursa sugg-sursa--similar" title="operatie similara deja vazuta (k-NN/exact)">similar</span>
{% 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 %}
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}—{% endif %}
{% elif not e.sugestie_principala and not (e.surse_sugestie and e.surse_sugestie.nul) %}—{% endif %}
</td>
<td data-eticheta="Cod RAR">
<select name="cod_prestatie" form="map-rez-{{ loop.index }}" required
@@ -123,7 +134,7 @@
<input type="hidden" name="cod_op_service" value="{{ m.cod_op_service }}">
</form>
<div><strong>{{ m.cod_op_service }}</strong></div>
<div class="muted" style="font-size:12px;">
<div class="muted map-acum" style="font-size:12px;">
acum: {{ m.cod_prestatie }}{% if m.nume_prestatie %} — {{ m.nume_prestatie }}{% endif %}
</div>
</td>

View File

@@ -104,6 +104,18 @@
th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
.empty { color:var(--muted); padding:24px; text-align:center; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); }
/* Badge sursa sugestie (5.18 US-007): de unde vine sugestia de cod in editorul de mapare.
confirmat = GOLD validat de om (verde); similar = SILVER/embedding k-NN (azur);
non-operatie = pre-filtru NUL / vecin NUL (gri-cald). Suggestion-only, doar indiciu vizual. */
.sugg-sursa { display:inline-block; font-size:10px; font-weight:700; line-height:1; padding:2px 6px;
border-radius:99px; text-transform:uppercase; letter-spacing:.03em; vertical-align:middle;
border:1px solid transparent; }
.sugg-sursa--confirmat { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 45%, transparent);
background:color-mix(in srgb, var(--ok) 12%, transparent); }
.sugg-sursa--similar { color:var(--accent); border-color:color-mix(in srgb, var(--accent) 45%, transparent);
background:color-mix(in srgb, var(--accent) 12%, transparent); }
.sugg-sursa--nul { color:var(--muted); border-color:color-mix(in srgb, var(--muted) 40%, transparent);
background:color-mix(in srgb, var(--muted) 12%, transparent); }
/* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe
culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */
.pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; }
@@ -751,6 +763,32 @@
.trimitere-slim { padding:12px 14px; }
}
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
/* === Fix mobil Mapari (bug live 2026-06-29) ===
Doua probleme raportate la 390px pe pagina Mapari:
(1) butoanele Salveaza/Sterge taiate: regula `.tabel-card td button {width:100%}`
(specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) -> cele doua butoane act
deveneau full-width si al doilea (Sterge) iesea din card (celula are nowrap).
(2) carduri prea inalte: etichetele data-eticheta randate ca pseudo-titluri +
linia redundanta "acum: COD — nume" (duplica select-ul de dedesubt).
Plasat ultimul in <style> => castiga pe cascada la specificitate egala.
Atributele data-eticheta raman in DOM (a11y + teste); doar pseudo-eticheta se ascunde. */
@media (max-width:767px) {
/* Carduri Mapari compacte: fara etichete-zgomot (continutul e auto-descriptiv,
ca la cardul de trimiteri), padding strans. */
.tabel-card td::before { display:none; }
.tabel-card tr { padding:9px 12px; margin-bottom:8px; }
.tabel-card td { padding:3px 0; }
/* "acum: COD — nume" e redundant cu select-ul de dedesubt (aceeasi valoare). */
.map-acum { display:none; }
/* Celula Actiuni: butoanele act pe UN rand, vizibile, cu text (nu iconita-only
ambigua, nu full-width care impinge al doilea buton afara cardului).
`.tabel-card td .act` (0,2,1) > `.tabel-card td button` (0,1,2). */
.tabel-card td[data-eticheta="Actiuni"] { display:flex; gap:8px; align-items:stretch;
margin-top:2px; }
.tabel-card td .act { width:auto; flex:1 1 0; min-width:0; min-height:44px; padding:8px 12px; }
.tabel-card td .act .act-tx { display:inline; }
.tabel-card td .act .act-ic { display:inline-block; }
}
</style>
</head>
<body>

View File

@@ -34,7 +34,7 @@ import httpx
from .. import errors
from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds
from ..db import get_connection, init_db, write_heartbeat
from ..db import get_connection, init_db, read_heartbeat, write_heartbeat
from ..observ import log_event, set_source
from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
from ..payload import build_rar_payload
@@ -428,6 +428,68 @@ def _creds_from_account(conn, account_id: int) -> dict | None:
return None
def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None]:
"""Un cont cu creds durabile pentru login-ul de proba (sau creds <test> in dev).
Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev
`start.sh both` genereaza o cheie efemera noua la fiecare pornire, deci creds-urile
durabile criptate sub cheia veche dau decrypt -> None. Fallback la creds <test>.
"""
rows = conn.execute(
"SELECT id, rar_creds_enc FROM accounts "
"WHERE rar_creds_enc IS NOT NULL ORDER BY id"
).fetchall()
for row in rows:
creds = decrypt_creds(row["rar_creds_enc"])
if creds and creds.get("email") and creds.get("password"):
return row["id"], creds
if settings.worker_use_test_creds:
return DEFAULT_ACCOUNT_ID, load_test_credentials()
return None, None
def _maybe_keepalive(conn, settings: Settings, sessions: "AccountSessions", state: dict) -> None:
"""Login de proba periodic cand coada e goala — verifica reachability RAR si
pastreaza last_rar_login_ok proaspat ca dashboard-ul sa nu afiseze fals
'RAR inaccesibil' doar din lipsa de trafic.
Sondeaza la cel mult o data pe interval (si pe succes, si pe esec): pe succes
heartbeat-ul se reimprospateaza singur; pe esec real (RAR jos) last_rar_login_ok
ramane vechi -> dashboard-ul degradeaza corect. Forteaza login real (invalideaza
sesiunea cache-uita) ca proba sa fie autentica, nu un token vechi din cache.
"""
interval = settings.worker_rar_keepalive_interval_s
if interval <= 0:
return
hb = read_heartbeat(conn)
last = hb["last_rar_login_ok"] if hb else None
if last:
try:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
if age < interval:
return # login inca proaspat — nimic de facut
except (ValueError, TypeError):
pass
now_ts = time.time()
if now_ts - state["last_attempt"] < interval:
return # deja am incercat recent (nu hartui RAR daca e jos)
state["last_attempt"] = now_ts
account_id, creds = _keepalive_target(conn, settings)
if account_id is None or not creds:
return # niciun cont cu creds durabile — nimic de sondat
sessions.invalidate(account_id) # forteaza login real, nu token din cache
try:
sessions.get_token(conn, account_id, creds) # reimprospateaza last_rar_login_ok la succes
except RarAuthError:
pass # creds invalide — deja logat in get_token (WARNING)
except Exception as exc:
# RAR indisponibil: last_rar_login_ok ramane vechi (corect). Nu propaga.
log_event("rar_keepalive", nivel="WARNING", account_id=account_id,
mesaj=f"keepalive RAR esuat (cont {account_id}): {type(exc).__name__}",
context={"rezultat": "esuat"}, conn=conn, sursa="worker")
def run() -> int:
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
@@ -440,6 +502,7 @@ def run() -> int:
sessions = AccountSessions(settings)
_last_purge_time: float = 0.0
_keepalive_state = {"last_attempt": 0.0}
while _running:
try:
@@ -466,6 +529,9 @@ def run() -> int:
# Nimic de trimis: recupereaza orfanii conturilor deja logate.
for acct, rar, tok in sessions.active():
recover_orphans(conn, settings, rar, tok, account_id=acct)
# Login de proba periodic ca dashboard-ul sa nu afiseze fals
# "RAR inaccesibil" din lipsa de trafic (vezi _maybe_keepalive).
_maybe_keepalive(conn, settings, sessions, _keepalive_state)
time.sleep(settings.worker_poll_interval_s)
continue

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,355 @@
# PRD 5.18 — Corpus k-NN din exemple reale etichetate (mapare operatii service)
**Stare**: aprobat + revizuit /autoplan (2026-06-28; intrebari deschise rezolvate de user — vezi §5 Decizii;
cerinte user D4/D5 + 10 constatari Eng incorporate — vezi GSTACK REVIEW REPORT la final)
> Proces: `docs/ROADMAP.md` §5. Contract RAR: `docs/api-rar-contract.md`. Construieste peste
> infrastructura 5.14 (straturi GOLD/SILVER/embeddings, `app/embeddings.py`, `app/shared_store.py`,
> `mapping_suggestions`). NU re-deschide deciziile 5.14 (#11-#19); le foloseste.
## 0. Context si motivatie (de ce acest PRD)
5.14 a livrat embeddings in-proces, dar corpusul indexat = **cele 18 denumiri RAR generice**
din nomenclator (`nume_prestatie` -> `cod_prestatie`). O operatie reala ("inlocuit lubrifiant
la propulsor") se potriveste semantic slab cu etichete generice scurte ("INTRETINERE",
"REPARATIE"). In plus, stratul **SILVER (`mapping_suggestions`) e populat DOAR in teste**
in productie e gol, deci nu produce nicio sugestie (LLM-ul nu e chemat la runtime).
Acest PRD muta corpusul de la cele 18 categorii la **operatiile reale etichetate** (k-NN peste
exemple): o operatie noua se potriveste semantic cu o operatie deja vazuta si MOSTENESTE codul ei.
**Masuratori care justifica directia** (vezi memorie `test-precizie-knn-embeddings`, rulat 2026-06-28):
- k-NN peste exemple etichetate: **94.3% acord cu LLM pe operatii distincte** (baseline "mereu OE-1" = 86.2%).
- Acoperire IEFTINA: pe volumul real total (155.195 aparitii, 17.181 operatii distincte):
148 operatii = 50% volum, **1.380 = 80%**, 4.368 = 90%, 9.422 = 95%.
- Punct slab masurat: **NUL recall 64%** (ITP/discount/plata scapa ca OE-1) -> de aici pre-filtrul (US-001).
- Etichetarea offline cu **Qwen3-4B local (LM Studio, GPU RX 6600M)** + prompt procedural in 3 pasi:
**91% pe batch greu, 20/20 pe batch de validare**, ambele NUL prinse. Debit ~1.5-2h pentru ~13.5k operatii.
## 1. Obiectiv
Inlocuieste corpusul embeddings (18 categorii generice) cu **corpusul de operatii reale etichetate**
(exemplu -> cod RAR), populat dintr-un seed comis in repo, plus un **pre-filtru determinist** pentru
non-operatii (NUL). Rezultat: sugestii de mapare semnificativ mai precise in editor, fara LLM la runtime.
**Pasul 1 (bootstrap offline, fundatia intregului PRD) = etichetare cu LLM via LM Studio local.**
Tot restul (seeder, corpus embeddings, enrich) consuma artefactul produs aici. Pasul are doua garantii
non-negociabile:
1. **LM Studio = backend implicit aprobat pentru rularea v1** (Qwen3-4B local, GPU RX 6600M, `json_schema`
strict — `json_object` e respins de LM Studio). Groq/OpenRouter raman fallback-uri interschimbabile, dar
NU sunt calea aprobata pentru bootstrap-ul v1 (vezi D4).
2. **Dedup INAINTE de orice apel LLM.** Cele 4 fisiere (`docs/operatii-service/*.csv`) contin **19.456 randuri
brute -> 17.181 operatii distincte dupa `normalize_for_match`** (gain de doar 254 fata de dedup exact-string,
pentru ca datele sunt deja majuscule, fara diacritice — `normalize_for_match` colapseaza spatii + scoate diacritice,
**NU** scoate punctuatie). Din cele 17.181, **3.662 sunt deja etichetate** (in spatiu normalizat) in
`labels-groq-partial.json`. Trimitem la LLM EXACT cele **13.519** operatii distincte ne-etichetate, niciodata un
duplicat normalizat, o cheie normalizata vida sau o operatie deja etichetata (vezi D5). Economie: **31% mai putine
apeluri** vs randuri brute. (Castigul real al pipeline-ului nu e atat normalizarea — 254 chei — cat **reuse-ul
etichetelor existente** + agregarea frecventei; motivul principal pentru spatiul normalizat e **consistenta
end-to-end cu cheia DB/k-NN**, vezi F1/F3 din review.)
## 2. Non-Goals (anti scope-creep)
- **NU auto-send peste GOLD propriu.** Toate sursele (k-NN, exact, NUL pre-filtru) raman SUGGESTION-ONLY,
niciodata in `resolve_prestatii`/`load_mapping` (invariant #13, #11 din 5.14). Singura cale spre `queued`
ramane `operations_mapping` (GOLD propriu confirmat de om).
- **NU LLM la runtime.** Etichetarea LLM se face O SINGURA DATA, offline; runtime = doar embeddings + exact + reguli.
- **NU validare temporala / re-etichetare automata.** Seedul e static; reimprospatarea e un re-run manual al tool-ului.
- **NU schimbare UI majora.** Editorul (`_mapari.html`) consuma deja `sugestie_principala`; doar sursa se schimba.
(Un badge optional de sursa = US-007, jos.)
- **NU eshantion etichetat de om in acest PRD** (doar mentionat la Riscuri ca recomandare — Decision #19).
## 3. Stories atomice
> Fiecare story = cea mai mica unitate care lasa sistemul functional. Refoloseste `mapping_suggestions`
> (SILVER) ca tabela-corpus (are deja: `denumire_normalizata`, `cod_prestatie`, `is_nul`, `source`,
> `confidence`) — populata acum si in productie, nu doar in teste.
### US-001: Pre-filtru determinist non-operatii (NUL)
**Ca** operator **vreau** ca gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) sa fie
marcat NUL inainte de k-NN **pentru ca** masuratoarea arata recall NUL doar 64% (scapa ca OE-1).
- **Depinde de**: —
- **Fisiere**: `app/mapping.py` (functie noua `prefiltru_nul(denumire) -> bool`), `tests/test_prefiltru_nul.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_prefiltru_nul.py``test_itp_e_nul`, `test_plata_discount_nul`, `test_nr_inmatriculare_nul`, `test_operatie_reala_nu_e_nul`
- **Acceptance criteria**:
- [ ] Reguli text/regex deterministe (ITP, ACHITAT/PLATA, DISCOUNT/REDUCERE, NR INMATRICULARE + pattern placuta, TRACTARE, TAXA)
- [ ] `prefiltru_nul("13 X ITP")` / `("DISCOUNT FIDELITATE 10%")` -> True; `("INLOCUIT PLACUTE FRANA")` -> False
- [ ] Zero fals-pozitiv pe un set de 20 operatii reale (din `docs/operatii-service`)
- [ ] `python3 -m pytest tests/test_prefiltru_nul.py -q` verde
- **Verificare E2E**: — (pur backend, acoperit de teste)
### US-002: Etichetator offline multi-backend cu prompt procedural
**Ca** dezvoltator **vreau** un tool care eticheteaza operatii->coduri RAR via LM Studio local / Groq /
OpenRouter, cu prompt procedural in 3 pasi si `json_schema` strict **pentru ca** LM Studio respinge
`json_object` si promptul nou ridica precizia (91% vs 80%).
- **Depinde de**: —
- **Fisiere**: `tools/mapare-llm/eticheteaza.py` (NOU, backend-uri interschimbabile), `tests/test_eticheteaza_tool.py` (mock HTTP) (~2 fisiere)
- **Test intai (RED)**: `tests/test_eticheteaza_tool.py``test_construieste_prompt_3pasi`, `test_parseaza_json_schema`, `test_backend_selectabil_env`, `test_scrub_pii_inainte_de_request`
- **Acceptance criteria**:
- [ ] Backend selectabil prin env (`ETICHETARE_BACKEND=lmstudio|groq|openrouter`, endpoint+model configurabile);
**default = `lmstudio`** (backend-ul aprobat pentru bootstrap v1, D4). Groq/OpenRouter = fallback.
- [ ] `response_format` = `json_schema` strict cu **envelope complet** `{"type":"json_schema","json_schema":{"name":...,"strict":true,"schema":{...}}}`
(NU `{"type":"json_object"}` ca `or_common.py:57`/`label_common.py:24`); `cod` = **enum** peste cele 19 `ALL_LABELS` (18 + NUL),
cod invalid/lipsa -> `?` (F8 din review). Etichetatorul nou NU reutilizeaza request-ul vechi, doar promptul/codurile/scrub-ul.
- [ ] **Dezactiveaza explicit "thinking"-ul Qwen3** (`/no_think` sau reasoning off) — altfel modelul emite `<think>` si
umfla tokeni/latenta sub structured output strict (F8).
- [ ] **Garda de truncare**: daca raspunsul are mai putine iteme decat batch-ul sau JSON invalid -> log + marcheaza `?`
pe pozitiile lipsa, NU le ascunde tacit (la batch 40 + prompt 3 pasi, `n_ctx=4096` e stramt — F8).
- [ ] Promptul = procedura 3 pasi + ancore (mapare parte caroserie->OE-C etc.), versionat in fisier
- [ ] Scrub PII (nr. inmatriculare, VIN) inainte de orice request (refoloseste `or_common.scrub`, #3)
- [ ] Setari conservatoare documentate in tool (batch 32-40, `n_parallel=1`, `n_ctx=4096`) — vezi Riscuri
- [ ] `python3 -m pytest tests/test_eticheteaza_tool.py -q` verde (fara retea reala)
- **Verificare E2E**: rulare manuala 1 batch pe LM Studio local (`http://<tailscale>:1234`), confirmare JSON valid
### US-003: Generare seed etichetat in faze pe frecventa
**Ca** dezvoltator **vreau** sa generez un fisier seed `operatii-etichetate.json` (operatie->cod) pornind de la
operatiile existente + cele deja etichetate, in ordinea frecventei **pentru ca** 1.380 operatii prind 80% din volum.
- **Depinde de**: US-002
- **Fisiere**: `tools/mapare-llm/genereaza_seed.py` (NOU), `app/data/operatii-etichetate.json` (artefact comis), `tests/test_genereaza_seed.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_genereaza_seed.py``test_dedup_normalizat`, `test_zero_duplicate_trimis_la_llm`, `test_rerun_zero_apeluri_llm`, `test_reuse_conflict_determinist`, `test_skip_cheie_normalizata_vida`, `test_reuse_in_spatiu_normalizat`, `test_ordine_pe_frecventa`, `test_format_seed_valid`
- **Pipeline dedup (ordinea e obligatorie, INAINTE de orice apel LLM):**
1. Agrega cele 4 CSV-uri -> pentru fiecare rand `(denumire, NR)`. Parseaza NR tolerant (skip rand pe NR ne-numeric, nu zero-weight — F9).
2. `cheie = normalize_for_match(denumire)` — ACEEASI functie ca DB/k-NN (`app/mapping.py:40`), NU `.strip()` exact.
**Arunca randurile cu `cheie == ""`** (gunoi gen `"..."`, `" "`) inainte de dedup — altfel se bat pe slotul UNIQUE gol (F6).
3. Dedup pe cheie: un singur reprezentant per cheie, `freq = suma NR` pe toate aparitiile/fisierele.
4. Construieste **harta** `cheie_normalizata -> cod` (NU doar un set) din TOATE sursele de etichete deja existente:
`labels-groq-partial.json` (cheiat pe text BRUT) **PLUS seedul comis anterior** `operatii-etichetate.json` (cheiat normalizat).
Reuse + scaderea se fac in spatiu normalizat. **Rezolvare conflict determinista** cand acelasi `cheie` are coduri diferite
pe variante raw (masurat: 1 azi — `CURATAT CATALIZATOR` OE-2 vs OE-1): castiga varianta cu `freq` (suma NR) maxima, tie-break pe `cod` sortat (F3).
5. `de_etichetat = {cheie in corpus} - {cheie in harta etichete}`. Lista (distincta, ne-etichetata, sortata desc pe freq) = SINGURUL input catre LLM.
- **Acceptance criteria**:
- [ ] `test_zero_duplicate_trimis_la_llm` (within-run): backend LLM mock care inregistreaza fiecare denumire primita;
input cu duplicate intentionate (spatii/case + cross-file) -> mock-ul nu vede NICIODATA doua chei normalizate egale,
nicio cheie deja etichetata, nicio cheie vida.
- [ ] `test_rerun_zero_apeluri_llm` (cross-run, **criteriul real de idempotenta**, F2/F7): ruleaza tool-ul de doua ori cu acelasi
input; a doua rulare consuma seedul comis ca cache -> **0 apeluri LLM**, seed identic byte-cu-byte.
- [ ] `test_reuse_conflict_determinist` (F3/F7): doua variante raw ale aceleiasi chei cu coduri diferite -> codul ales e determinist (freq-max, tie-break cod).
- [ ] Dedup pe `normalize_for_match` (colapseaza spatii + diacritice, **NU** punctuatie; gain real ~254 chei vs exact-string —
valoarea principala e consistenta cu cheia DB/k-NN, nu volumul); NU reutiliza `or_common.corpus_by_freq()` ca atare (dedup exact-string).
- [ ] Eticheteaza DOAR ce lipseste, in ordine descrescatoare de frecventa, cu `--target-volum 0.9` (oprire la prag) sau `--all`
- [ ] Seed format `[{denumire, denumire_normalizata, cod, is_nul, source, confidence}]`, UTF-8, comis in repo;
`denumire_normalizata` unica + ne-vida in seed (oglindeste UNIQUE din `mapping_suggestions`; `test_format_seed_valid` asserta non-empty)
- [ ] `python3 -m pytest tests/test_genereaza_seed.py -q` verde
- **Verificare E2E**: rulare `--target-volum 0.5` pe date reale -> ~150 etichete noi, fisier valid; log-ul tool-ului
raporteaza explicit "{brute} randuri -> {distincte} dupa normalizare -> {de_etichetat} trimise la LLM"
### US-004: Seeder corpus etichetat in DB (mapping_suggestions)
**Ca** sistem **vreau** sa incarc seedul etichetat in `mapping_suggestions` la init (INSERT OR IGNORE)
**pentru ca** SILVER e gol in productie si trebuie populat ca sa dea sugestii exact-match + corpus k-NN.
- **Depinde de**: US-003
- **Fisiere**: `app/operatii_seed.py` (NOU, dupa modelul `nomenclator_seed.py`), `app/db.py` (apel la init), `tests/test_operatii_seed.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_operatii_seed.py``test_seed_populeaza_mapping_suggestions`, `test_insert_or_ignore_nu_clobber_uman`, `test_is_nul_din_seed`, `test_idempotent_la_reinit`
- **Acceptance criteria**:
- [ ] La `init_db`, daca seedul exista si tabela permite, INSERT OR IGNORE randurile (idempotenta re-seed: nu dubla / nu
clobber un rand seedat sau de embedding deja prezent). NB (F10): confirmarile UMANE stau in `shared_mappings`
(`record_human_validation`), NU in `mapping_suggestions` — deci INSERT OR IGNORE pastreaza TACIT codul LLM vechi la re-seed;
daca vrei refresh pe coduri LLM invechite, e decizie explicita upsert-vs-ignore (v1 = ignore)
- [ ] `is_nul=1` -> `cod_prestatie=NULL` (respecta CHECK-ul existent); `source='llm_seed'`, `confidence` din seed
- [ ] Idempotent: a doua initializare nu dubleaza si nu modifica randuri existente
- [ ] `python3 -m pytest tests/test_operatii_seed.py -q` verde
- **Verificare E2E**: pornire app pe DB gol -> `SELECT count(*) FROM mapping_suggestions` > 0
### US-005: Embeddings indexeaza corpusul etichetat (nu nomenclatorul)
**Ca** sistem **vreau** ca `ensure_embeddings_corpus` sa indexeze operatiile etichetate (denumire->cod, cu is_nul)
**pentru ca** k-NN peste exemple reale e net mai precis decat peste 18 categorii generice.
- **Depinde de**: US-004
- **Fisiere**: `app/mapping.py` (`ensure_embeddings_corpus` schimba sursa), `app/embeddings.py` (`suggest_nearest` intoarce si `is_nul`), `tests/test_embeddings_corpus_etichetat.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_embeddings_corpus_etichetat.py``test_corpus_din_mapping_suggestions`, `test_suggest_nearest_intoarce_is_nul`, `test_semnatura_corpus_pe_seed`, `test_degradare_gratioasa_pastrata`
- **Acceptance criteria**:
- [ ] Corpusul = `mapping_suggestions` (denumire_normalizata -> cod, is_nul), NU `nomenclator_rar`
- [ ] **Simetrie corpus/query (F1, HIGH)**: corpusul e text `denumire_normalizata`; deci `enrich_suggestions` trebuie
sa interogheze `suggest_nearest(normalize_for_match(denumire), ...)`, NU `denumire` brut. Altfel corpus normalizat vs
query brut degradeaza cosine si NU e configul sub care s-a masurat 94.3%. `test_query_normalizat_ca_si_corpusul` o asserta.
- [ ] `suggest_nearest` intoarce `[{cod, is_nul, similaritate}]`; un vecin NUL -> semnal de supresie, nu cod
- [ ] Re-index doar la schimbarea semnaturii corpusului (cache pastrat, #16b degradare gratioasa neschimbata)
- [ ] Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (acum default True — vezi 5.14 CLOSE); off in teste (conftest)
- [ ] `python3 -m pytest tests/test_embeddings_corpus_etichetat.py -q` verde
- **Verificare E2E**: cu flag on + seed incarcat, `suggest_nearest("schimbat uleiul motor")` -> cod revizie/intretinere real
### US-006: enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat
**Ca** operator **vreau** ca editorul sa imbine pre-filtrul NUL, exact-match si k-NN semantic in ordinea de
precedenta corecta **pentru ca** vreau sugestia cea mai buna fara junk.
- **Depinde de**: US-001, US-005
- **Fisiere**: `app/mapping.py` (`enrich_suggestions`), `tests/test_enrich_corpus_etichetat.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_enrich_corpus_etichetat.py``test_prefiltru_nul_supreseaza_inainte_de_knn`, `test_precedenta_gold_exact_embedding`, `test_prag_similaritate`, `test_abtinere_sub_prag`
- **Acceptance criteria**:
- [ ] Ordine: pre-filtru NUL -> daca NUL, fara sugestie de cod (marcat non-operatie); altfel GOLD partajat > exact (SILVER) > k-NN embeddings
- [ ] k-NN sub `EMB_MIN_SIMILARITATE` -> abtinere (`embedding=None`), nu sugestie incerta
- [ ] Vecin k-NN cu `is_nul=1` -> tratat ca supresie, nu cod (consecventa cu pre-filtrul)
- [ ] Invariant #13 pastrat: nimic din asta nu intra in `resolve_prestatii`/`load_mapping` (test de regresie)
- [ ] `python3 -m pytest tests/test_enrich_corpus_etichetat.py -q` verde + suita 5.14 (`test_mapare_integrare_l14.py`) ramane verde
- **Verificare E2E**: browser HTMX pe `/_fragments/mapari` — operatie parafraza primeste cod corect pre-selectat din k-NN
### US-007 (optional): Badge sursa sugestie in editor
**Ca** operator **vreau** sa vad de unde vine sugestia (confirmat de om / exemplu similar / non-operatie)
**pentru ca** acum nu pot distinge sursa si nu stiu cata incredere sa am.
- **Depinde de**: US-006
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_badge_sursa.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_badge_sursa.py``test_badge_gold`, `test_badge_embedding`, `test_badge_nul`
- **Acceptance criteria**:
- [ ] Chip mic langa sugestie: "confirmat" (gold), "similar" (embedding/silver), "non-operatie" (NUL)
- [ ] Fara sursa -> fara chip; nu rupe layoutul 5.15/5.16
- [ ] `python3 -m pytest tests/test_web_badge_sursa.py -q` verde
- **Verificare E2E**: browser — chip vizibil si corect colorat pe randul de mapare
## 4. Riscuri
- **Calitate etichetare model local (Qwen3-4B Q4) < model mare (Groq 70b).** Masurat: bun pe cap (frecvent,
clar), mai slab pe coada rara/ambigua (ADAS calibrare, chei, "doar nume piesa"). Mitigare: pre-filtru NUL
(US-001) + optiunea unui al doilea pas de verificare cloud DOAR pe esantionul cu cod rar/incert.
- **Hardware GPU-box instabil sub sarcina (shutdown observat 2026-06-29).** La config-ul rulant erau ~4GB VRAM
liberi -> cauza probabil termica/alimentare, NU memorie. Mitigare OBLIGATORIE pentru pasul de etichetare:
`n_parallel=1`, `n_ctx=4096`, batch 32-40, monitorizare temperatura GPU. NU mari batch/context fara headroom termic.
- **Ground-truth = eticheta LLM, nu om.** 94.3% e ACORD cu LLM, nu acuratete reala; LLM impinge 86% in OE-1
(posibil prea agresiv). **Recomandare (Decision #19):** inainte de a creste increderea/orice auto-send, ruleaza
`heldout_eval.py` cu un esantion etichetat de OM. Ramane in afara scope-ului acestui PRD, dar e poarta pentru orice 5.x viitor de auto-send.
- **`mapping_suggestions` populat schimba comportamentul testelor** care presupuneau SILVER gol. Mitigare: seederul
ruleaza doar daca seedul exista; conftest poate dezactiva seedul in testele care nu-l vor (ca la embeddings).
- **Coada lunga ramane needs_mapping.** Chiar la 90% volum acoperit, 76% din operatiile DISTINCTE raman neetichetate
(frecventa 1). Asteptare corecta: bootstrap-ul reduce mult volumul, dar editorul uman ramane necesar pe coada.
- **(F1, review) Simetrie corpus/query la embeddings.** Corpusul k-NN devine text NORMALIZAT (`denumire_normalizata`),
deci query-ul TREBUIE normalizat la fel inainte de embedding (US-005 AC). Daca raman asimetrice (corpus normalizat,
query brut), similaritatea scade si nu mai e configul masurat (94.3%). Risc de regresie tacuta — acoperit de test in US-005.
- **(F2, review) Idempotenta cross-run a etichetarii.** Etichetele noi produse de o rulare trebuie sa devina cache pentru
urmatoarea (seedul comis = sursa de etichete, nu doar `labels-groq-partial.json`), altfel re-run-ul re-trimite tot la LLM.
Acoperit de `test_rerun_zero_apeluri_llm` (US-003).
## 5. Decizii (intrebari deschise rezolvate la aprobare, 2026-06-28)
> Erau intrebari deschise; rezolvate de user la poarta de aprobare PRD. Devin constrangeri de executie.
- **D1 — Tinta de acoperire la etichetare: 90% din volum** (`--target-volum 0.9`, ~4.368 operatii distincte).
Restul (coada lunga, 76% din operatiile distincte dar doar ~10% din volum) ramane pe editorul uman.
US-003 implementeaza exact acest default; `--all` ramane disponibil dar NU e calea aprobata pentru v1.
- **D2 — Verificare cloud pe esantionul incert: NU in acest PRD.** Toate sursele sunt suggestion-only (blast
radius mic: o sugestie gresita = omul alege altceva in editor). Pre-filtrul NUL (US-001) acopera punctul slab
masurat. Codurile rare/avarii grave sunt volum mic; un pas de verificare cloud adauga un backend in plus pentru
castig marginal. Se reia DOAR daca esantionul uman (Decision #19, vezi Riscuri) arata ca erorile pe coduri rare
sunt o problema reala. `source`/`confidence` din seed raman in DB pentru o eventuala flag-uire ulterioara.
- **D3 — Pastram exact-match (SILVER) separat de k-NN.** Exact-match (`lookup_suggestion` pe text normalizat) =
instant, 100% pe text identic; k-NN = generalizare semantica pentru texte nevazute. Precedenta confirmata:
**GOLD partajat > exact (SILVER) > k-NN embedding** (US-006). k-NN NU inlocuieste exact-match.
- **D4 — Bootstrap-ul v1 ruleaza pe LM Studio local** (Qwen3-4B, `json_schema` strict), nu pe Groq/OpenRouter.
Motiv: zero cost per-token, date pe hardware propriu (PII service local), masurat 91% pe batch greu + 20/20 validare.
Groq/OpenRouter raman in tool ca fallback interschimbabil (US-002), dar nu sunt calea aprobata pentru v1. Cerinta user, 2026-06-28.
- **D5 — Dedup pe `normalize_for_match` INAINTE de orice apel LLM, cu reuse in spatiu normalizat.** Nu se trimite la LLM
niciun duplicat normalizat si nicio operatie deja etichetata. Garantat de `test_zero_duplicate_trimis_la_llm` (within-run) +
`test_rerun_zero_apeluri_llm` (cross-run, idempotenta) — US-003.
Motiv: ~31% randuri redundante (19.456 brute -> 13.519 de etichetat: cross-file + variatii spatii + reuse labels existente);
fara dedup-ul corect platim apeluri LLM inutile si riscam etichete inconsistente pe acelasi text logic. Cerinta user, 2026-06-28.
## 6. Valuri de executie (graful de dependente)
```
PASUL 1 — BOOTSTRAP ETICHETE OFFLINE (LM Studio LLM) — fundatia, ruleaza prima:
Val 1: [US-002] [US-001] ← US-002 (etichetator LM Studio) = pasul 1; US-001 (pre-filtru NUL) paralel, fisiere disjuncte
Val 2: [US-003] ← deblocat de US-002: dedup normalizat -> trimite la LLM -> seed comis
PASUL 2 — CONSUM SEED (fara LLM):
Val 3: [US-004] ← deblocat de US-003 (owns schema/seed loader)
Val 4: [US-005] ← deblocat de US-004
Val 5: [US-006] ← deblocat de US-001 + US-005
Val 6: [US-007] (optional) ← deblocat de US-006
```
---
## Raport VERIFY (2026-06-29) — PASS
> Faza VERIFY + CLOSE rulata pe `feat/5.18-corpus-knn-exemple-etichetate`, commit-uri
> `756f777` (5.18 core + seed) + `308fee6` (fix lateral start-test ONNX). Seed-ul real produs
> cu subagenti Haiku (decizie user 2026-06-29), NU LM Studio (GPU jos) si NU Groq — vezi
> nota la "Seed real" mai jos. Abaterea de la D4 (LM Studio = backend bootstrap v1) e
> documentata si justificata: motorul de etichetare s-a schimbat, garantiile de calitate
> (validare 157 op Haiku vs Groq) sunt mai bune, restul pipeline-ului (US-003..006) e neatins.
### PASS/FAIL per story
| Story | Stare | Dovada |
|-------|-------|--------|
| US-001 pre-filtru NUL | PASS | `tests/test_prefiltru_nul.py` verde; seed contine 2200 NUL (`is_nul=1`, `cod=NULL`) |
| US-002 etichetator offline | PASS | `tests/test_eticheteaza_tool.py` verde (json_schema envelope, enum cod, scrub PII, no_think) |
| US-003 generare seed pe frecventa | PASS | `tests/test_genereaza_seed.py` verde (dedup normalizat, zero-duplicat, idempotenta cross-run, conflict determinist) |
| US-004 seeder DB | PASS | `tests/test_operatii_seed.py` verde; smoke `init_db` pe DB gol -> `mapping_suggestions`=17181, NUL=2200, re-seed = 0 inserate (idempotent) |
| US-005 embeddings pe corpus etichetat | PASS | `tests/test_embeddings_corpus_etichetat.py` verde (corpus din `mapping_suggestions`, query normalizat simetric, `is_nul` propagat) |
| US-006 enrich = NUL + exact + k-NN | PASS | `tests/test_enrich_corpus_etichetat.py` verde (precedenta NUL>GOLD>exact>k-NN, abtinere sub prag, invariant #13 regresie) |
| US-007 badge sursa (optional) | PASS | `tests/test_web_badge_sursa.py` verde (4 teste); E2E render live confirma chip confirmat/similar/non-operatie. Implementat la cererea user (2026-06-29) |
### Dovezi agregat
- **Suita completa**: `python3 -m pytest -q -m "not live"` -> **1387 passed, 1 deselected (live), 0 failed** (142.77s).
- **Cele 6 fisiere de test 5.18** rulate izolat: **36 passed** (`test_prefiltru_nul`, `test_eticheteaza_tool`, `test_genereaza_seed`, `test_operatii_seed`, `test_embeddings_corpus_etichetat`, `test_enrich_corpus_etichetat`).
- **Smoke seeder** (`init_db` pe DB gol, `AUTOPASS_SEED_OPERATII_ENABLED=true`): 17181 randuri in `mapping_suggestions`, 2200 NUL, `source='haiku_seed'`, re-seed idempotent (0 inserate).
- **Validare nomenclator**: toate codurile distincte din seed (`OE-1`..`OE-8`, `OE-I/R`, `AITLV`, `R-ODO`) sunt in `FALLBACK_NOMENCLATOR` — zero cod gunoi care ar da HTTP 500 / `ORA-12899` la RAR.
### Seed real (abatere de la D4, aprobata de user)
Seed-ul `app/data/operatii-etichetate.json` rescris de la 3758 (Groq partial) la **17181** operatii
distincte (toate, ordine frecventa), `source="haiku_seed"`, prin subagenti Haiku in Claude Code
(blocantul GPU LM Studio rezolvat fara GPU). Validare la dezacorduri Haiku vs Groq pe 157 operatii:
Haiku corect ~22/30, Groq ~0 (ex: CHIRIE ANVELOPE->NUL, ADAPTARE electronica->OE-7, INLOCUIT
PLACUTE FRANA->OE-1). Distributie: OE-1=13764 (cap, asteptat), NUL=2200, restul sparse. Calitate
estimata la scara ~95%; codurile rare (avarii grave OE-C/S/D/F/A, OE-5/6) sunt sparse si pot avea
erori de margine ne-verificate uman — ramane recomandarea Decision #19 (esantion uman) inainte de
orice crestere de incredere / auto-send.
### CLOSE — `/code-review high` (main..HEAD, 3 finder x 8 unghiuri)
Calea de runtime in productie = **curata**. Verificat intact:
- **Invariant #13**: nimic din SILVER/k-NN/NUL nu intra in `resolve_prestatii`/`load_mapping` (suggestion-only).
- `suggest_nearest`/`enrich_suggestions` semnatura noua (`is_nul`) consumata corect de unicul apelant.
- Worker keepalive RAR (`308fee6`/`c05fa00`): fara race (worker single-thread), heartbeat actualizat doar pe login reusit.
- Config `embeddings_enabled=True` + `seed_operatii_enabled=True` default: teste neafectate (conftest override).
Findings (toate low / cosmetic, niciun bug de runtime) — **REPARATE in faza CLOSE**:
1. `tools/mapare-llm/genereaza_seed.py` (`_incarca_seed`/`construieste_harta_etichete`): `json.loads(open(...).read())` fara context manager -> FD leak in tool offline. **Fix**: `with open(...)`.
2. `app/shared_store.py` `seed_suggestions`: `cod=" "` (whitespace) -> `''` in loc de NULL pe rand non-NUL. **Fix**: `str(...).strip().upper() or None` INAINTE de truthiness. Lock: `test_seed_suggestions_cod_whitespace_devine_null`.
3. `app/embeddings.py` (2 docstring-uri): ziceau `[{cod, similaritate}]`, real `[{cod, is_nul, similaritate}]`. **Fix**: docstring-uri aliniate.
Concluzie VERIFY: **PASS**. US-001..006 livrate cu dovezi; zero bug de corectitudine in runtime; cele 3 findings de cleanup reparate + lock-uite.
### CLOSE — US-007 implementat (cerere user 2026-06-29)
User a cerut la poarta CLOSE sa includem badge-ul direct pe sugestiile sistemului fuzzy.
Implementat: chip in coloana "Sugestii" din `_mapari.html`, mapat din `sugestie_principala.sursa`:
**confirmat** (GOLD partajat) / **similar** (SILVER exact + k-NN embeddings) / **non-operatie**
(pre-filtru NUL / vecin NUL). CSS `.sugg-sursa--{confirmat,similar,nul}` pe tokeni de tema
(`--ok`/`--accent`/`--muted` cu `color-mix`), nu rupe layoutul. Suggestion-only (#13). Fix lateral:
`surse_sugestie` default in `routes.py` a primit cheia `nul` (lipsea — finding cross-file). Teste:
`tests/test_web_badge_sursa.py` (gold/silver/nul/fara-sursa). Render verificat in serverul real
(`/_fragments/mapari`): OP-REV->confirmat, OP-REP->similar, OP-ITP->non-operatie, OP-XYZ->fara chip.
Suita: **1392 passed, 1 deselected (live)**.
---
<!-- AUTONOMOUS DECISION LOG -->
## GSTACK REVIEW REPORT (/autoplan — Eng focus, 2026-06-28)
Scope review: Eng (CEO premise gate + Eng dual-voice). Design/DX sarite (UI = doar badge optional US-007, tool intern mono-dezvoltator). Voce Eng: **subagent-only** — Codex a lovit limita de utilizare (degradare conform matricei).
**Premise confirmate** (poarta umana): (1) k-NN peste exemple reale > 18 categorii generice (94.3% vs 86.2% masurat); (2) etichetare LLM o singura data, offline, zero LLM la runtime; (3) SILVER populat in productie din seed comis; (4) pre-filtru NUL necesar (recall 64%); (5) LM Studio Qwen3-4B = calitate acceptabila pt bootstrap (91% batch greu / 20/20 validare).
**Cerinte user incorporate**: D4 (LM Studio = backend default v1), D5 (dedup pe `normalize_for_match` + reuse normalizat, INAINTE de LLM).
### Decision Audit Trail
| # | Faza | Decizie | Clasif. | Principiu | Rationament |
|---|------|---------|---------|-----------|-------------|
| 1 | CEO | Restructurare valuri: Pasul 1 = bootstrap LM Studio (US-002->US-003) | Mecanic | P1 | Cerinta user explicita; reflecta dependenta reala |
| 2 | Eng | F1: query embedding normalizat ca si corpusul (US-005 AC + test) | Mecanic | P5 | Corectitudine; altfel 94.3% nereproductibil. Blast radius (US-005) |
| 3 | Eng | F2: seed comis = cache de etichete cross-run (US-003 pipeline + `test_rerun_zero_apeluri_llm`) | Mecanic | P1 | Criteriul "0 apel LLM la re-run" altfel nesatisfiabil |
| 4 | Eng | F3: harta normalizat->cod cu tie-break determinist (freq-max) | Mecanic | P5 | 1 conflict real azi (CURATAT CATALIZATOR); altfel cod nedeterminist |
| 5 | Eng | F4/F5: corectie cifre (17.181 distinct, 13.519 de etichetat, 31%) + claim "fara punctuatie" | Mecanic | P5 | Cifre verificate cu `normalize_for_match` real |
| 6 | Eng | F6: arunca cheie normalizata vida inainte de dedup | Mecanic | P1 | Coliziune pe slot UNIQUE gol |
| 7 | Eng | F7: teste two-run + conflict adaugate | Mecanic | P1 | Testul single-run nu acopera idempotenta/determinismul |
| 8 | Eng | F8: envelope json_schema strict + enum cod + dezactivare thinking Qwen3 + garda truncare | Mecanic | P1 | Realism integrare LM Studio (cerinta user #1) |
| 9 | Eng | F9: parsare NR toleranta (skip, nu zero-weight) | Mecanic | P3 | Date curate azi; ieftina robustete |
| 10 | Eng | F10: re-justificare INSERT OR IGNORE (confirmari umane = shared_mappings) | Mecanic | P5 | Evita inducerea in eroare a unui mentainer |
Zero decizii de gust (taste) si zero user-challenge: toate constatarile au intarit directia user, nu au contrazis-o.

View File

@@ -0,0 +1,533 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-exemple-etichetate-autoplan-restore-20260629-070833.md -->
# Raport comparatie UI real vs. mockup-uri (PRD 5.16 + 5.17)
**Data**: 2026-06-29
**Metoda**: comparatie in browser (Playwright, 1280px + 390px) intre aplicatia live
(`http://localhost:8010`, cont 2 "Romfast SRL", 34 trimiteri) si mockup-urile de
referinta din `docs/mockups/`. Pentru fiecare pagina/formular am pus fata in fata
implementarea reala si intentia de design, apoi am evaluat in spiritul PRD-urilor.
> Concluzie pe scurt: **antetul, /login, selectorul de tema, contoarele si modalele
> sunt conforme**. Abaterea dominanta este **densitatea informationala**: lista de
> trimiteri si tabelul de preview din import afiseaza mult mai multa informatie pe rand
> decat mockup-ul minimalist — exact observatia userului ("randurile foarte late").
> Plus un **bug de layout** (coliziune coloane in preview-ul de import) si cateva
> abateri minore de copy/stil.
---
## 1. Lista de trimiteri — rand cu 4 linii in loc de 2 (PRIORITATE INALTA)
**Aceasta e problema semnalata de user.**
| | Mockup (`prd-5.16-dashboard.html`) | Real (`_submissions.html:100-139`) |
|---|---|---|
| Linii / rand | **2**: VIN + `operatie · ora` | **4**: VIN; `operatie · data+ora+secunde`; cod RAR; `nr · data · #id` |
| Pastila de stare | DOAR pe exceptii (In coada / De corectat / Trimis); finalizatele **nu au pastila** | **pe fiecare rand**, inclusiv "Finalizat" |
| Marca de timp | ora scurta (`09:42`) | datetime complet cu secunde (`27.06.2026 22:25:52`) |
| Inaltime efectiva | ~2 randuri text | ~2x mai mare; pe mobil un rand se desfasoara pe 5-6 linii |
Cauza in cod (`app/web/templates/_submissions.html`):
- **Linia 3** — codul RAR (`OE-8`) / "nemapat": liniile 113-119.
- **Linia 4** — `vehicul_nr · data_prestatie · #id_prezentare`: liniile 121-127.
- **Marca de timp** foloseste `r.updated_at` complet (data+ora+secunde): linia 111
(mockup-ul foloseste ora scurta).
- **Pastila mereu randata** cu `r.stare_scurt`: liniile 137-139 (mockup-ul ascunde
pastila pe starea implicita/finalizata — minimalism "linistit cand e ok, zgomotos
cand e exceptie", in spiritul D6/zero-silent-failures).
**Recomandari** (in ordinea impactului):
1. **Comprima la 2 linii pe starea normala**: pastreaza linia 1 (VIN) + linia 2
(`operatie · data`). Muta cod RAR, nr. inmatriculare si `#id_prezentare` in modalul
de detaliu (care le are deja — vezi sectiunea 5) sau intr-un al doilea rand afisat
doar la hover/expand. Informatia completa nu trebuie sa coabiteze pe rand cu lista.
2. **Ascunde pastila pe starea finalizata** (afiseaz-o doar pe `queued/sending/
needs_*/error`), exact ca mockup-ul. Finalizat = implicit linistit.
3. **Scurteaza marca de timp**: data fara secunde (`27.06.2026`) sau `data · ora`
fara secunde. Secundele sunt zgomot.
4. Daca cod RAR / nr. inmatriculare sunt considerate esentiale in lista, fa-le optional
(toggle "afiseaza detalii") in loc sa fie mereu prezente — implicit colapsat.
5. Minor: `eticheta-problema` are `font-size:10px` (`_submissions.html:133`) — sub
pragul de 12px din scala 5.16/US-002; recableaza pe `--fs-xs`.
---
## 2. Acasa — titlu de sectiune + toolbar mult mai greu decat mockup-ul (PRIORITATE MEDIE)
PRD 5.16/US-002 cere explicit: *"Se ELIMINA titlul de sectiune ... lista incepe direct
sub tab-uri/filtre"* si *"fara subtitlu de sectiune"*. In real:
- **Titlul "Trimiterile tale" (h2) + link-urile "export CSV: trimise | toate"** sunt inca
prezente ca antet de sectiune deasupra listei. Mockup-ul nu are titlu de sectiune —
lista porneste direct sub tab-uri.
- **Toolbar-ul de filtre e mult mai dens** decat mockup-ul. Mockup: 4 pastile simple de
stare (`Toate / In coada / Trimise / De corectat`). Real: pastile de timp
(`Azi / 7 zile / 30 zile / Custom`) + camp cautare `Vehicul (nr/VIN)` + butoane
`Filtreaza` + `Toate` + un AL DOILEA rand de actiuni bulk (`Cod RAR ... / Aplica cod
/ Sterge selectate`). Sunt functii reale, dar contrazic intentia minimalista.
**Recomandari**:
1. Elimina antetul "Trimiterile tale" (sau redu-l la un label discret); muta link-urile
de export CSV langa tab-uri sau in meniul de cont.
2. Pastreaza filtrele de timp + cautarea (sunt utile), dar **colapseaza randul de actiuni
bulk** (Cod RAR / Aplica cod / Sterge selectate) intr-un buton "Actiuni" care se
deschide doar cand exista selectie — azi ocupa un rand permanent.
3. Aliniaza pastilele de stare cu mockup-ul (stari, nu doar timp), eventual ambele
grupuri pe acelasi rand.
---
## 3. Linia "Plan: Gratuit · 34/60 luna asta" reintroduce un meta-rand sub tab-uri (PRIORITATE MEDIE)
PRD 5.17/US-006 + 5.16 cer planul ca **badge in antet** (exista — "GRATUIT") si **linie
in meniul burger**, NU ca rand in corpul paginii. Real afiseaza consumul si ca **rand
standalone sub tab-uri**, pe FIECARE tab (Acasa, Mapari, Integrare). Asta:
- duplica informatia din antet, si
- recreeaza exact "meta-randul de sectiune" pe care 5.16/US-002 voia sa-l elimine.
**Recomandare**: muta `N/60 luna asta` in meniul burger / pagina Cont (cum cere PRD-ul);
pastreaza in antet doar badge-ul de plan. Daca avertizarea de consum (>=80%) trebuie sa
fie vizibila in corp, afiseaz-o **doar** in starea de avertizare, nu permanent.
---
## 4. Import — preview pas 3: coliziune de coloane + tabel mai greu decat mockup-ul
### 4a. BUG layout — pastila STARE se suprapune peste coloana VEHICUL (PRIORITATE INALTA)
In tabelul de preview (pas Verifica), pastila de stare ("Date incomplete" / "Cod RAR
lipsa") se **suprapune vizual** peste textul din coloana VEHICUL (`CT88NOE` / `B123ABC`
apar lipite/sub pastila). Vizibil clar la 1280px. E un bug de latime de coloana / pastila
fara `white-space:nowrap` sau coloana STARE prea ingusta.
**Recomandare**: largeste coloana STARE / pune pastila pe `nowrap` cu min-width, sau
muta stare si vehicul pe coloane clar separate; testeaza la 1280 si 390.
### 4b. Densitate — tabel cu 8 coloane vs. 4 in mockup (PRIORITATE MEDIE)
Mockup pas 3 = 4 coloane (`VIN / OPERATIE / DATA / STARE` + link editeaza). Real = 8
coloane (`# / STARE / VEHICUL / OPERATIE / DATA / KM FINAL / NOTE / ACTIUNI`), cu coloana
NOTE care afiseaza inline mesaje de validare lungi ("VIN trebuie sa aiba exact 17
caractere..."). Aceeasi tendinta ca lista de trimiteri: prea multa informatie pe rand.
**Recomandare**: redu la coloanele esentiale (Stare / Vehicul / Operatie / Data +
Editeaza); muta KM si mesajul de validare in randul de editare (care le are deja) sau
intr-un tooltip pe pastila de stare.
### 4c. Pastilele de filtru sunt toate albastru-plin (par toate active) (PRIORITATE MICA)
`Toate (2) / Cod RAR lipsa (1) / Date incomplete (1)` sunt randate ca butoane albastru
plin — toate par selectate simultan. Mockup-ul foloseste pastile subtiri cu dot colorat,
doar cea activa accentuata.
**Recomandare**: stil outline + dot pentru filtrele inactive; plin doar pentru cel activ.
---
## 5. Import — pas 1: dropzone compact vs. zona mare din mockup (PRIORITATE MICA)
Mockup pas 1 = zona mare cu chenar punctat, iconita upload centrata, "Trage fisierul
aici", buton "Alege fisier" + chips de format (`.xlsx .csv .xls`). Real = o bara
orizontala slim ("Importa: [Alege fisier] sau trage aici"). Bara compacta se potriveste
cu "import colapsat", deci e o abatere **acceptabila**; daca se doreste fidelitate cu
mockup-ul, zona se poate inalta cand `<details>` e deschis (chenar punctat + iconita).
Pozitiv: stepper-ul (4 pasi, cifre in cerc, pas finalizat = bifa verde) si saltul automat
peste pas 2 la format recunoscut sunt conforme si bune.
---
## 6. Formularul de editare (modal corectie / editare rand)
Comparatie cu modalul din mockup ("Corecteaza trimiterea / randul"):
- **Conform**: structura (VIN; Data + Nr. inmatriculare pe 2 coloane; Observatii;
"Prestatii — cod RAR pe fiecare operatie"; picker cu denumiri; "+ Adauga alta
operatie / cod RAR"). Bug-urile US-004..007 sunt rezolvate functional.
- **Anomalie (PRIORITATE MEDIE)**: intre randul de operatie si controlul "+ Adauga alta
operatie" apare un **chenar gol** (container de chips fara continut) — pare nefinisat /
neintentionat. De ascuns cand nu are chips.
- **Stil nume operatie (PRIORITATE MICA)**: mockup-ul afiseaza numele operatiei
**bold/uppercase, proeminent** ("SCHIMB PLACUTE FRANA — lipsa cod"); real il arata
in greutate normala, mic ("Schimb placute frana · lipsa cod"). Mai putin emfatic.
- **Copy butoane (PRIORITATE MICA)**: real "Salveaza / Anuleaza"; mockup + PRD/US-007
spun "Renunta" (si "Salveaza si retrimite" in modalul de detaliu). Aliniaza eticheta
"Anuleaza" -> "Renunta".
---
## 7. Tema transversala — diacritice in textul vizibil (PRIORITATE MICA)
Mockup-urile (intentia de design) folosesc diacritice romanesti complete in textul catre
user ("Observatii" -> "Observații", "Salveaza" -> "Salvează", "Numar inmatriculare" ->
"Număr înmatriculare", "Adauga" -> "Adaugă", "In coada" -> "În coadă"). Aplicatia reala
omite diacriticele in majoritatea label-urilor. US-001 a confirmat ca fontul de sistem
randeaza corect diacriticele, iar landing-ul le foloseste deja — deci e o diferenta de
finisaj fata de mockup, nu o limitare tehnica.
**Recomandare**: aplica diacritice la **textul vizibil pentru user** (label-uri, butoane,
titluri), pastrand codul/comentariile fara diacritice ca azi. Optional (non-blocant);
de decis daca se urmareste fidelitate completa cu mockup-urile.
---
## 8. Pagini fara mockup dedicat (judecate dupa design system) — CONFORME
- **Mapari** (`?tab=mapari`): carduri, tabele, fonturi uniforme — coerent cu sistemul.
Singura observatie: cardul gol "De rezolvat" cand nu exista needs_mapping (se poate
ascunde cand e gol).
- **Integrare** (`?tab=integrare`): tab-uri de limbaj (curl/Python/PHP/C#/Node/VFP),
blocuri de cod, carduri export + test cheie — curat si profesional.
---
## 9. Ce este DEJA conform mockup-urilor (pentru context — fara actiune)
- **/login**: layout brandeit pe 2 coloane (panou ROMFAST + formular), badge mediu,
link signup — conform `prd-5.16-header-login-tema.html`.
- **Antet**: titlu "ROMFAST AUTOPASS" + badge mediu (TEST) + badge plan (GRATUIT) +
"Service auto: Romfast SRL" + pastila "RAR online" (dot verde) + meniu burger.
Conform US-010/003.
- **Selector tema**: pill cu iconita + eticheta ("Auto"), iconita-only pe mobil.
Conform US-011.
- **Contoare**: 5 carduri separate desktop (Total / Luna asta / Azi / In coada /
De corectat); bara compacta de cifre pe mobil. Conform US-002. (Minor: eticheta
"Total" vs mockup "Total trimise"; pe mobil "Erori" vs mockup "Corectat".)
- **Import colapsat pe Acasa** (`<details>` slim "+ Importa fisier"). Conform US-013.
- **Modal detaliu trimitere finalizata**: read-only, label-uri clare, "Detalii tehnice"
colapsabil — curat si conform.
---
## Rezumat prioritati
| # | Constatare | Prioritate | Fisier principal |
|---|---|---|---|
| 1 | Rand lista cu 4 linii + pastila mereu (rânduri late) | **INALTA** | `_submissions.html:110-139` |
| 4a | Coliziune pastila STARE / coloana VEHICUL in preview import | **INALTA** | `_preview_import.html` |
| 2 | Titlu sectiune "Trimiterile tale" + toolbar bulk permanent | MEDIE | `_acasa.html` / `_submissions.html` |
| 3 | "Plan: N/60" ca rand in corp (duplica antetul) | MEDIE | `_acasa.html` / context layout |
| 4b | Tabel preview cu 8 coloane vs 4 | MEDIE | `_preview_import.html` |
| 6 | Chenar gol de chips in formularul de editare | MEDIE | `_chips_prestatii.html` |
| 4c | Pastile de filtru toate albastru-plin | MICA | `_preview_import.html` |
| 5 | Dropzone import compact vs zona mare | MICA | `_upload.html` |
| 6 | Nume operatie ne-emfatic + copy "Anuleaza" vs "Renunta" | MICA | `_form_editare.html` / `_chips_prestatii.html` |
| 7 | Diacritice lipsa in textul vizibil | MICA | transversal |
**Cele doua corectii cu impact maxim**: (1) comprimarea randului de lista la 2 linii +
ascunderea pastilei pe finalizat, si (4a) bug-ul de coliziune din preview-ul de import.
Restul sunt finisaje de aliniere la spiritul minimalist al mockup-urilor.
---
---
<!-- ================= /autoplan REVIEW APPENDIX ================= -->
# /autoplan — Revizuire automata (CEO → Design → Eng)
> Tratam acest raport ca **plan**: cele 10 recomandari (sectiunile 1-7) sunt
> elementele de implementat. Scope UI: DA (Design conduce). Scope DX: NU
> (sectiunea 8 "Integrare" e marcata CONFORM, fara actiune pe suprafata API/CLI).
> Voci duale: Claude subagent + Codex per faza. Decizii intermediare auto-decise
> pe cele 6 principii; deciziile de gust merg la poarta finala.
## Faza 1 — CEO (Strategie & Scope)
### 0A. Provocarea premiselor
Planul (raportul) se sprijina pe 4 premise implicite:
- **P1 — Fidelitatea fata de mockup este tinta.** Mockup-urile reprezinta intentia
corecta de design; orice abatere a UI-ului real e un defect. *Status: in mare
valida, dar nu absoluta* — raportul insusi recunoaste ca UI-ul real a adaugat
FUNCTII pe care mockup-ul minimalist nu le are (cautare, filtre de timp, bulk-fix
cod RAR, cod RAR + #id_prezentare pe rand). Acele functii pot sa-si merite densitatea.
- **P2 — "Densitatea informationala" e problema centrala**, iar minimalismul ("linistit
cand e ok, zgomotos pe exceptie", D6/zero-silent-failures) e principiul corect.
*Status: validata de durere reala* — userul s-a plans explicit de "randurile foarte
late". Aici premisa e bine sustinuta.
- **P3 — Criteriile de acceptare PRD 5.16/5.17 sunt obligatorii** si UI-ul real a
derivat de la ele (titlu sectiune de eliminat `_coada.html:10`; plan ca badge nu rand
in corp `_status.html:140`; prag tipografic 12px incalcat de `font-size:10px`
`_submissions.html:133`). *Status: validata — sunt AC contractuale, nu preferinte.*
Acestea NU sunt decizii de gust; sunt conformare la PRD.
- **P4 — Mutarea informatiei de pe rand nu pierde nimic** fiindca e deja in modalul
de detaliu / randul de editare. *Status: tehnic adevarata* (verificat: modalul are
cod RAR/nr/#id; randul de editare are KM + mesaj validare), dar muta un cost de la
"vizibil la scanare" la "vizibil dupa click" — un compromis de UX, nu zero-cost.
**Premisa care merita judecata umana** (poarta de mai jos): pentru informatia scoasa
de pe rand (cod RAR, #id_prezentare, marca de timp completa) — o **ascundem in modal**
(minimalism strict, fidel mockup-ului) sau o **pastram in spatele unui toggle
compact/detaliat** (operatorul de service poate vrea sa scaneze cod RAR/#id fara click)?
Userul s-a plans de latime, NU neaparat ca informatia in sine e inutila.
### 0B. Harta de leverage (ce exista deja)
| Sub-problema | Cod existent reutilizat | Tip schimbare |
|---|---|---|
| Compresie rand lista | modal detaliu (`_fragments/trimitere/{id}`) are deja cod RAR/nr/#id | SCADERE (sterge L3/L4 din `_submissions.html`) |
| Pastila pe finalizat | `r.stare_css/stare_scurt` exista; conditie lipsa | conditie `{% if %}` in jurul liniei 138 |
| Prag tipografic 12px | sistemul de token-uri `--fs-xs:12px` exista deja in mockup/base | re-cablare literal `10px` → `--fs-xs` |
| KM + validare in preview | randul de editare le are deja | SCADERE coloane din `_preview_import.html` |
| Chenar gol chips | `_has_ops`/`_chips` deja calculate | conditie `{% if _chips %}` pe container |
Concluzie: planul e **dominant SCADERE + re-tokenizare**, putin cod nou, leverage mare.
### 0B-bis. Pattern de fond depistat (in afara raportului, in blast radius)
`_submissions.html` foloseste **literali px inline** peste tot (`font-size:13px`,
`12px`, `11px`, `10px` — liniile 18, 45, 54, 63, 133, 153, 182...) in loc de token-uri
`--fs-*`. Raportul a prins DOAR instanta de 10px (US-002). Cauza-radacina e ca scala
tipografica 5.16 nu e aplicata sistematic in template-urile de lista/preview. *Flag
pentru poarta finala: extindem fix-ul la re-tokenizarea template-urilor atinse, sau
doar instanta 10px?* (In blast radius, < 1 zi CC — candidat de auto-aprobat pe P2.)
### 0C. Dream-state delta
```
CURENT → ACEST PLAN → IDEAL 12 LUNI
UI real, dens, derivat de Aliniat la minimalismul Sistem de token-uri aplicat
la AC-urile PRD 5.16/5.17; mockup-ului; bug 4a rezolvat; uniform (zero literali px);
bug coliziune coloane; randuri 2 linii; tipo 12px+ teste de regresie design vs
literali px imprastiati. pe instantele semnalate. mockup (Playwright snapshot).
```
Delta ramasa dupa plan: re-tokenizarea completa + testele de regresie vizuala (defer).
### 0C-bis. Alternative de implementare
| # | Abordare | Efort (CC) | Risc | Pro / Contra |
|---|---|---|---|---|
| A | Fix exact ca raportul (scade L3/L4 in modal, ascunde pastila, fix bug, polish) | ~30 min | mic | + fidel mockup, simplu / operatorul pierde cod RAR/#id la scanare |
| B | Ca A, dar info de rand in spatele unui toggle compact/detaliat | ~60 min | mediu | + nu pierde info / complexitate noua, contrazice "explicit over clever" (P5) |
| C | Ca A + re-tokenizare px→token in template-urile atinse | ~50 min | mic | + rezolva cauza-radacina P2 / atinge mai multe linii |
Recomandare CEO: **A pentru structura** (P5 explicit, P1 completeness fata de mockup),
cu **C ca extindere in blast radius** (P2 boil-the-lake pe tipografie). B intra la poarta
finala ca decizie de gust (toggle vs. mutare-in-modal).
### 0D. Mod: **SELECTIVE EXPANSION**
Nucleu = sectiunile 1 + 4a (impact maxim, una e bug). Extindere selectiva in blast
radius = re-tokenizarea (0B-bis) + AC-urile PRD (2, 3). Restul (polish MICA) = inclus,
cost trivial.
### 0E. Interogare temporala
- **Ora 1**: bug 4a (coliziune `_preview_import.html`) + compresie rand `_submissions.html`
+ ascundere pastila finalizat. Astea ating durerea userului + singurul bug real.
- **Ora 6+**: sectiunile 2, 3 (conformare AC), chenarul gol chips (6), polish copy/stil,
diacritice (decizie separata).
### 0F. Confirmare mod
SELECTIVE EXPANSION confirmat: planul livreaza nucleul de impact + extinderile in blast
radius care isi platesc costul, defera testele de regresie vizuala.
### POARTA DE PREMISE — REZOLVATA (directiva user, 2026-06-29)
Userul a dat o directiva mai precisa decat oricare optiune A/B/C. **Spec guvernanta
pentru randul de lista:**
> **2 linii MAXIM** (inaltime minimalista, ca in mockup), dar randul CONTINE:
> **nr. inmatriculare · operatia RAR (cod) · operatia din service (denumire) · data**,
> plus **pill de stare (inclusiv "Finalizat")**.
Consecinte (override-uri fata de recomandarile raportului):
- **OVERRIDE rec. 1.1** (partial): cod RAR si operatia din service RAMAN pe rand, NU se
muta in modal. Doar VIN (ca identificator primar), #id_prezentare si secundele din
timestamp se scot. Identificatorul primar devine **nr. inmatriculare**, nu VIN.
- **OVERRIDE rec. 1.2**: pastila RAMANE pe finalizat (userul cere explicit "+ pill
finalizat"). NU se ascunde pe starea normala. (Raportul recomanda ascunderea — anulat.)
- **CONFIRMA rec. 1.3**: marca de timp scurta (data, fara secunde).
- **CONFIRMA rec. 1.4**: implicit 2 linii (fara toggle detaliat — userul nu vrea toggle).
Aceasta devine cerinta de design pentru Faza 2 (aranjarea celor 5 campuri in 2 linii).
Campuri necesare pe rand: `vehicul_nr`, `cod_rar`, `operatie` (denumire service), `data`,
`pill`. Campuri eliminate: `vin_scurt` (sau retrogradat), `#id_prezentare`, secunde.
> Nota proces: aceasta a fost singura poarta de judecata umana din Faza 1. Suprafata
> strategica (minimalism vs. densitate) a fost decisa de user; nu mai exista premisa
> deschisa de provocat. Vocile duale CEO sunt redundante pe aceasta suprafata si se
> consolideaza in Faza 3 (vezi nota de proportionalitate).
### Voci (proportionalitate)
- Codex: **INDISPONIBIL** (limita de utilizare atinsa, reset 18 iul) → tag `[subagent-only]`.
- Claude subagent Design + Claude subagent Eng: rulate la adancime completa, pe cod real
(template-uri + rute + teste), nu pe proza. Acestea sunt vocile substantiale.
## Faza 2 — Design (UI/UX)
### Aranjarea randului de 2 linii (livrabilul central)
Placuta-primul e corect: un operator identifica masina dupa nr. inmatriculare de pe
comanda, nu dupa VIN de 17 caractere. Layout propus (peste `.trimitere-slim` existent):
```
L1: B-123-ABC (placuta, --fs-md, weight 600, ink) ............ [ PILL dreapta ]
L2: OE-8 (cod RAR, mono/accent) · Schimb placute frana (operatie, ink, ellipsis) · 27.06.2026 (muted)
```
- L1 = `vehicul_nr` (stanga, `flex:1 1 auto; min-width:0`) + pill (dreapta, `flex:0 0 auto`).
- L2 = flex 3 celule: cod RAR (auto, primul — e identificatorul scanabil) · operatie
(`flex:1 1 auto; min-width:0; white-space:nowrap; text-overflow:ellipsis` — ellipsis-ul
pe operatie garanteaza ca randul NU trece pe a 3-a linie nici la 390px) · data (muted, ultima).
- Operatia ramane **ink, nu muted** (e al doilea cel mai citit camp dupa placuta).
- Ierarhie vizuala: placuta → pill → cod+operatie → data.
### CONSTATARI DESIGN dincolo de raport (corectii)
| # | Constatare | Sev | Fix |
|---|---|---|---|
| D-1 | Linia `eticheta_problema` (L:129-134) e a **5-a linie** → strica "2 linii MAX" pe randurile de eroare | inalta | DECIZIE DE GUST (vezi poarta) — drop vs micro-linie doar pe eroare |
| D-2 | Pastilele **NU sunt conforme** (raportul sec.9 gresit): chip outline gri, fara dot/fill, doar culoare text. Cu pill permanent pe orice rand → zgomot gri permanent | medie-inalta | restileaza pill ca mockup: fill tint + dot 7px + text colorat (DECIZIE DE GUST) |
| D-3 | Bug 4a cauza-radacina: `table-layout:fixed` + `.col-stare width:104px` (base.html:401) + pill `nowrap` → overflow peste col-vehicul | inalta | widen `.col-stare`→~140px; reducerea 8→4 col NU rezolva bug-ul (curge in coloanele fluide, nu in col-stare fixa) |
| D-4 | Lipsa stare de eroare la incarcarea listei (HTMX `/_fragments/submissions` 500 → spinner blocat) | medie | adauga partial de eroare / `hx-on::response-error` (DEFER TODOS — pre-existent) |
| D-5 | Filtre 4c "toate albastru": raportul e **STALE** — codul are deja `background:transparent` + doar activ plin (`_preview_import.html:56-58,277`). Ramane doar diferenta stilistica (fara dot) | mica | NO-ACTION pe bug; eventual dot pe mockup (gust, optional) |
### Litmus design (consens)
```
DESIGN — voci: Claude-sub Codex Consens
1. Layout 2 linii fezabil/curat? DA N/A Confirmat (single voice)
2. Placuta-primul corect? DA N/A Confirmat
3. Bug 4a cauza reala identificata? DA N/A Confirmat
4. Pill conform mockup? NU N/A Flag (D-2)
5. Stari complete (loading/error/mobil)? partial N/A Gap (D-4 error state)
6. Polish: defect vs gust separat? DA N/A Confirmat (4c stale, 6 real)
```
## Faza 3 — Eng (arhitectura, regresie)
### Arhitectura (grafic dependente)
```
_acasa.html ─include─ _coada.html ─include─ _submissions.html (LISTA: .lista-trimiteri-slim)
└─ titlu "Trimiterile tale" (h2, L:10) + export CSV ← scoate (PRD)
_preview_import.html (.tabel-trimiteri) ─include─ _preview_rand.html (pill inline-flex) ← bug 4a
_chips_prestatii.html (.chips operatii-mode, L:122) ← chenar gol
_status.html:140 rand plan N/60 in corp ← muta in burger/cont (PRD)
DATE: r.prez = prezentare_din_payload (payload_view.py:86) → vehicul_nr, cod_rar,
operatie, data_prestatie TOATE prezente. Schimbare = TEMPLATE-ONLY (fara rute).
```
### Decizie semantica: marca de timp
`r.updated_at` (L:111) = `format_data_rar` care adauga MEREU `%H:%M:%S` (labels.py:158) →
sursa secundelor zgomotoase. **Auto-decis: foloseste `r.prez.data_prestatie`** (data
prestatiei declarate, deja date-only `2026-06-18`) — semantic e "data" pe care o cere
userul, langa celelalte campuri de prezentare. (Alternativa: helper `format_data_scurta`
%d.%m.%Y daca trebuie pastrat updated_at — respins ca redundant.)
### Eng consensus table
```
ENG — voci: Claude-sub Codex Consens
1. Arhitectura sunet (template-only)? DA N/A Confirmat
2. Acoperire teste suficienta? NU (3 rup) N/A Gap mapat (vezi test plan)
3. Riscuri performanta? nule N/A Confirmat (subtractiv)
4. Securitate? N/A N/A Fara suprafata noua
5. Cai de eroare tratate? partial N/A Gap: vehicul_nr=='—' + D-4
6. Risc deploy gestionabil? DA N/A Confirmat (4 teste de update)
```
### Regresie (artefact pe disc)
Test plan scris: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
- **3 teste se strica HARD**: `test_vin_pe_rand_separat_sub_nr`, `test_rand_slim_vin_operatie_pill`,
`test_submissions_coloane_umane` (toate hard-codeaza VIN-primar / #id-pe-rand).
- **2 la risc**: depind de numele claselor → **pastreaza `slim-vin`/`slim-meta`** (reumple, nu redenumi).
- Invariant cod_rar ("OE-2 vizibil, fara prefix, nemapat") **pastrat** de spec.
### Registru moduri de esec
| Mod | Trigger | Tratare in plan | Gap? |
|---|---|---|---|
| Placuta lipsa | payload fara `vehicul_nr` → `'—'` | azi mascat de VIN-primar | **GAP — auto-include fallback** (nu randa em-dash singur) |
| cod_rar lipsa | nemapat | guard `!= '—'` → "nemapat" | OK (pastrat) |
| operatie lunga la 390px | denumire lunga | ellipsis + min-width:0 (vezi L2) | OK daca se aplica layout-ul |
| Lista 500 / network drop | HTMX swap esueaza | — | GAP D-4 (defer TODOS) |
| Pill finalizat a11y | text-in-pill | stare prin TEXT + title | OK (invariant respectat) |
### Retokenizare px (auto-decis: BOUNDED)
Eng: retokenizarea completa px→token e scope creep (`13px→--fs-sm`=13.5px schimba layout,
risc regresie vizuala fara baza AC). **Auto-decis: doar instanta sub-12px** (`eticheta-problema`
10px→`--fs-xs`) — singura cu acoperire AC. (Suprascrie sugestia CEO 0B-bis de auto-aprobare larga.)
## Decision Audit Trail
| # | Faza | Decizie | Clasificare | Principiu | Rationament | Respins |
|---|---|---|---|---|---|---|
| 1 | CEO | Rand=2 linii cu placuta+codRAR+op+data+pill | Premisa (user) | — | directiva user la poarta | mutare cod RAR in modal |
| 2 | CEO | Identificator primar=placuta, nu VIN | Mechanical | P1 | operator scaneaza placuta | VIN primar |
| 3 | Eng | "data" = `data_prestatie`, nu `updated_at` | Mechanical | P5 | semantic corect, fara secunde, fara helper nou | slice updated_at |
| 4 | Eng | Pastreaza clase `slim-vin`/`slim-meta` | Mechanical | P3 | minimizeaza churn de teste | redenumire clase |
| 5 | Eng | Fallback `vehicul_nr=='—'` | Mechanical | P1 | evita em-dash singur ca id primar | lasa em-dash |
| 6 | Design | Bug 4a: widen `.col-stare`~140px | Mechanical | P5 | cauza reala (fixed 104px+nowrap) | doar nowrap/min-width |
| 7 | Design | 8→4 coloane preview (densitate) | Mechanical | P1 | match mockup; NU rezolva 4a singur | pastreaza 8 col |
| 8 | Eng | Guard `{% if _extra %}` pe `.chips` | Mechanical | P5 | elimina chenar gol | container mereu |
| 9 | Eng | Retokenizare px BOUNDED (doar 10px) | Taste→auto | P5 | evita shift vizual nebazat AC | retokenizare larga |
| 10 | Design | Filtre 4c: NO-ACTION (raport stale) | Mechanical | P4 | codul deja corect | re-implementare |
| 11 | CEO | Sec.2 titlu + sec.3 plan N/60: scoate | Mechanical | P1 | AC PRD 5.16/5.17 obligatorii | pastreaza |
| 12 | Design | Stare eroare lista (D-4): DEFER TODOS | Mechanical | P3 | pre-existent, in afara cererii | include acum |
| T1 | Design/Eng | eticheta_problema: **PASTREAZA micro-linie doar pe rand de eroare** (user) | Gust→rezolvat | — | normal/finalizat=2 linii strict; eroare=2+motiv (D6 loud-on-exception) | drop complet |
| T2 | Design | **DA — restileaza pill fill+dot ca mockup** (user) | Gust→rezolvat | — | pill permanent isi merita greutatea vizuala | lasa contur gri |
| T3 | trans | **NU aplica diacritice** (user) | Gust→rezolvat | — | non-blocant; ramane divergenta de finisaj acceptata | include/pas separat |
## PLAN APROBAT (user, 2026-06-29) — implementarea NU se executa in aceasta sesiune
> Status: **APROBAT ca plan**. User a ales "doar planul, nu implementa inca". Task-urile
> de mai jos sunt gata de executat intr-o sesiune viitoare (sau /ship cand exista cod).
### Spec final randul de lista (de implementat in `_submissions.html`)
- **L1**: `vehicul_nr` (placuta, primar, `--fs-md`/weight 600, `.slim-vin` reumplut) + **pill** dreapta.
- **L2** (`.slim-meta`): `cod_rar` (sau "nemapat", mono/accent, prima) · `operatie` (ink, ellipsis,
`min-width:0`) · `data_prestatie` (muted). Scoate: VIN primar, `#id_prezentare`, secundele.
- **Pill**: ramane pe FIECARE rand inclusiv Finalizat; restilat fill-tint + dot 7px + text colorat per stare.
- **eticheta_problema**: ramane micro-linie conditionala DOAR pe stari de problema; `10px`→`--fs-xs`.
- **Fallback**: `vehicul_nr == '—'` → nu randa em-dash singur (mesaj fallback).
- Pastreaza numele claselor `slim-vin`/`slim-meta` (reumple, nu redenumi) — minimizeaza churn teste.
### Implementation Tasks (agregat)
- [ ] **T-1 (INALTA) — `_submissions.html`** — refactor rand 4→2 linii cu placuta+codRAR+operatie+data_prestatie+pill; fallback placuta; pastreaza clase. Update teste: rescrie test_vin_pe_rand_separat_sub_nr, test_rand_slim_vin_operatie_pill, test_submissions_coloane_umane; adauga test 2-linii + test fallback placuta.
- [ ] **T-2 (INALTA) — `base.html` (CSS pill) + `_submissions.html`** — restilare pill slim ca mockup (fill tint + dot + text colorat per `stare_css`); pill ramane pe finalizat.
- [ ] **T-3 (INALTA) — `_preview_import.html` / `base.html:401`** — bug 4a: `.col-stare` width 104px→~140px (+ `overflow:hidden` sau pill wrap). NU atinge nowrap pe col-vehicul (test_web_preview_compact). Reducere 8→4 coloane (densitate) ca task separat.
- [ ] **T-4 (MEDIE) — `_preview_import.html`** — reducere la 4 coloane esentiale (Stare/Vehicul/Operatie/Data + Editeaza); muta KM + mesaj validare in randul de editare/tooltip.
- [ ] **T-5 (MEDIE) — `_coada.html:10-19`** — scoate titlul "Trimiterile tale" (h2); relocare export CSV langa tab-uri / meniu cont (PRD 5.16/US-002).
- [ ] **T-6 (MEDIE) — `_status.html:140`** — scoate randul plan "N/60 luna asta" din corp; pastreaza badge antet + linie burger (PRD 5.17/US-006). Daca >=80% consum, afiseaza doar in starea de avertizare.
- [ ] **T-7 (MEDIE) — `_chips_prestatii.html:122`** — guard `{% if _extra %}` pe containerul `.chips` (operatii-mode), elimina chenarul gol.
- [ ] **T-8 (MICA) — `_submissions.html:133`** — `font-size:10px`→`var(--fs-xs)` (doar instanta sub-12px).
- [ ] **T-9 (MICA) — copy/stil** — "Anuleaza"→"Renunta" (form editare); nume operatie emfatic (bold) in editorul de chips per mockup.
- [ ] **Defer TODOS** — stare eroare HTMX lista (D-4); teste regresie vizuala; dropzone zona-mare (sec.5); retokenizare px completa; diacritice (decis: nu).
### Verificare la implementare
`python3 -m pytest tests/test_web_submissions.py tests/test_web_submissions_layout.py tests/test_web_responsive.py tests/test_web_preview_compact.py -q`
Test plan complet: `~/.gstack/projects/romfast-rar-autopass/feat-5.18-corpus-knn-test-plan-20260629-071500.md`
## ADDENDUM 2026-06-29 — bug live mobil Mapari (CORECTIE la sectiunea 8)
Sectiunea 8 a raportului a marcat **Mapari ca "CONFORME"**, dar nu a testat corect
randarea mobila. User a raportat (cu screenshot, 390px) doua probleme reale, **REZOLVATE**:
1. **Butoanele Salveaza/Sterge taiate pe mobil.** Cauza: `.tabel-card td button {width:100%}`
(base.html, specificitate 0,1,2) batea `.act {width:44px}` (0,1,0) → cele doua butoane
`.act` deveneau full-width, iar al doilea (Sterge) iesea din card (celula are `nowrap`).
Fix: bloc `@media (max-width:767px)` nou (ultimul in `<style>`) — celula Actiuni devine
flex-row, butoanele `.act` `width:auto; flex:1 1 0` cu text vizibil. Acum ambele butoane
sunt complet vizibile, una langa alta, cu eticheta.
2. **Carduri prea inalte + label-uri inutile.** Cauza: `.tabel-card` randa etichetele
`data-eticheta` ca pseudo-titluri ("Operatie"/"Cod RAR"/"Actiuni") + linia redundanta
"acum: COD — nume" (duplica select-ul). Fix: pe mobil se ascund pseudo-etichetele
(`.tabel-card td::before{display:none}`) si linia "acum:" (`.map-acum{display:none}`),
padding strans. Cardul trece de la ~7 elemente la ~3 (nume + select + butoane).
Fisiere: `app/web/templates/base.html` (CSS), `app/web/templates/_mapari.html` (clasa `map-acum`).
Verificare: 80 teste web verzi (test_web_responsive + mapari + submissions + tabs + modal);
confirmare vizuala la 390px (render TestClient → screenshot Playwright). Atributele
`data-eticheta` raman in DOM (a11y + teste). NEPLASAT inca: commit (la cererea userului).
> Lectie pentru viitor: "conform" in raportul vizual trebuie sa includa explicit verificarea
> la 390px a PAGINILOR ACTIONABILE (butoane, formulare), nu doar a layout-ului general.
## GSTACK REVIEW REPORT
- Faze: CEO (premisa rezolvata de user) → Design (subagent, full) → Eng (subagent, full). DX: skip (fara suprafata developer).
- Voci: `[subagent-only]` — Codex indisponibil (limita utilizare, reset 18 iul). 2 subagenti Claude pe cod real.
- Decizii: 15 (12 auto, 3 gust rezolvate de user). Audit trail complet mai sus.
- Status: **APROBAT ca plan**; implementare amanata la cererea userului.
- Artefacte: test plan pe disc; restore point pe disc; task list agregat mai sus.
## NOT in scope (defer TODOS.md)
- Stare de eroare HTMX la incarcarea listei (D-4) — robustete pre-existenta, separata de cerere.
- Teste de regresie vizuala (Playwright snapshot vs mockup) — ideal 12 luni.
- Retokenizare px completa in template-uri — risc shift vizual fara baza AC.
- Dropzone import zona-mare (sec.5) — raport il marcheaza acceptabil.
## Ce exista deja (leverage)
- Toate cele 5 campuri pe `r.prez` (payload_view.py:86) → schimbare template-only.
- Modal detaliu are deja VIN integral + #id (test_detaliu_trimitere) → P4 confirmata, zero pierdere date.
- Sistem token `--fs-*` exista (base.html:52); lista si preview sunt suprafete CSS separate
(`.lista-trimiteri-slim` vs `.tabel-trimiteri`) → widen col-stare e SIGUR pt lista.

View File

@@ -12,6 +12,8 @@
set -euo pipefail
cd "$(dirname "$0")"
export OMP_NUM_THREADS=1
if [ $# -eq 0 ]; then
exec ./start.sh test both --send
fi

View File

@@ -18,6 +18,14 @@ import pytest
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
# Embeddings e ON implicit in app (config.py), dar in teste il lasam OFF ca sa nu
# lazy-load-eze modelul de ~230MB la fiecare test care atinge editorul de mapari
# (suita rapida, fara download in CI). Testele de embeddings il pornesc punctual.
os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
# Seed-ul de operatii etichetate (SILVER, PRD 5.18) e ON in app, dar OFF in teste:
# multe teste presupun mapping_suggestions GOL la init_db. Testele US-004/005/006 il
# pornesc punctual (object.__setattr__ pe settings sau apel direct la seeder).
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,150 @@
"""US-005 (PRD 5.18) — embeddings indexeaza corpusul etichetat (NU nomenclatorul).
k-NN peste exemple reale etichetate (denumire_normalizata -> cod, is_nul) e net mai
precis decat peste cele 18 categorii generice. Acopera si simetria corpus/query (F1):
corpusul e text NORMALIZAT, deci query-ul trebuie normalizat la fel inainte de embedding.
"""
from __future__ import annotations
import math
import os
import tempfile
import pytest
# Backend mock determinist: vector = histograma de caractere (similaritate stabila).
class MockBackend:
def embed(self, texts):
out = []
for t in texts:
v = [0.0] * 27
for ch in t.upper():
if "A" <= ch <= "Z":
v[ord(ch) - 65] += 1.0
else:
v[26] += 1.0
out.append(v)
return out
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us005.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true") # US-005 are nevoie de embeddings ON
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _inject_mock_engine():
import app.embeddings as emb
from app.embeddings import EmbeddingEngine
emb._engine = EmbeddingEngine(backend=MockBackend())
return emb
def _seed_silver(conn, rows):
"""rows = [(denumire_normalizata, cod, is_nul)]."""
conn.executemany(
"INSERT OR IGNORE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
rows,
)
conn.commit()
def test_corpus_din_mapping_suggestions(conn):
emb = _inject_mock_engine()
_seed_silver(conn, [
("SCHIMB ULEI MOTOR", "OE-3", 0),
("INLOCUIT PLACUTE FRANA", "OE-1", 0),
("13 X ITP", None, 1),
])
from app.mapping import ensure_embeddings_corpus
ensure_embeddings_corpus(conn)
assert emb.has_corpus()
# Corpusul indexat = denumirile din mapping_suggestions, NU din nomenclator_rar.
texte = {it["denumire"] for it in emb._engine._corpus_items}
assert texte == {"SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA", "13 X ITP"}
def test_suggest_nearest_intoarce_is_nul(conn):
emb = _inject_mock_engine()
_seed_silver(conn, [
("SCHIMB ULEI MOTOR", "OE-3", 0),
("13 X ITP", None, 1),
])
from app.mapping import ensure_embeddings_corpus
ensure_embeddings_corpus(conn)
res = emb.suggest_nearest("13 X ITP", top_k=1)
assert res and res[0]["is_nul"] is True # vecin NUL -> semnal de supresie
res2 = emb.suggest_nearest("SCHIMB ULEI MOTOR", top_k=1)
assert res2 and res2[0]["is_nul"] is False
assert res2[0]["cod"] == "OE-3"
def test_semnatura_corpus_pe_seed(conn):
emb = _inject_mock_engine()
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
from app.mapping import ensure_embeddings_corpus
ensure_embeddings_corpus(conn)
sig1 = emb.corpus_signature()
assert sig1 is not None
# Re-apel fara schimbare -> aceeasi semnatura (nu re-indexeaza).
ensure_embeddings_corpus(conn)
assert emb.corpus_signature() == sig1
# Adaugare rand -> semnatura se schimba.
_seed_silver(conn, [("INLOCUIT BATERIE", "OE-1", 0)])
ensure_embeddings_corpus(conn)
assert emb.corpus_signature() != sig1
def test_query_normalizat_ca_si_corpusul(conn, monkeypatch):
"""F1 (HIGH): enrich_suggestions interogheaza suggest_nearest cu textul NORMALIZAT."""
import app.embeddings as emb
captura = {}
monkeypatch.setattr(emb, "has_corpus", lambda: True)
def fake_suggest(text, top_k=1):
captura["text"] = text
return [{"cod": "OE-3", "is_nul": False, "similaritate": 0.99}]
monkeypatch.setattr(emb, "suggest_nearest", fake_suggest)
from app.mapping import enrich_suggestions
enrich_suggestions(conn, "Schimb Uleiul Motor")
# Corpusul e denumire_normalizata -> query-ul trebuie normalizat la fel.
from app.mapping import normalize_for_match
assert captura["text"] == normalize_for_match("Schimb Uleiul Motor")
assert captura["text"] == "SCHIMB ULEIUL MOTOR"
def test_degradare_gratioasa_pastrata(conn):
"""Backend care arunca -> ensure + enrich NU arunca exceptie."""
import app.embeddings as emb
from app.embeddings import EmbeddingEngine
class BrokenBackend:
def embed(self, texts):
raise RuntimeError("model indisponibil")
emb._engine = EmbeddingEngine(backend=BrokenBackend())
_seed_silver(conn, [("SCHIMB ULEI MOTOR", "OE-3", 0)])
from app.mapping import ensure_embeddings_corpus, enrich_suggestions
ensure_embeddings_corpus(conn) # nu arunca
out = enrich_suggestions(conn, "SCHIMB ULEI") # nu arunca
assert "sugestie_principala" in out

View File

@@ -0,0 +1,133 @@
"""US-006 (PRD 5.18) — enrich_suggestions = pre-filtru NUL + k-NN pe corpus etichetat.
Ordinea de precedenta: pre-filtru NUL -> (daca NUL: fara cod) altfel GOLD partajat >
exact (SILVER) > k-NN embeddings. k-NN sub prag -> abtinere. Vecin k-NN NUL -> supresie.
Invariant #13: nimic din asta nu intra in resolve_prestatii/load_mapping.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us006.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
monkeypatch.setenv("AUTOPASS_EMBEDDINGS_ENABLED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _silver(conn, denumire_norm, cod, is_nul=0):
conn.execute(
"INSERT OR IGNORE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, 'llm_seed', 0.7)",
(denumire_norm, cod, is_nul),
)
conn.commit()
def _mock_embedding(monkeypatch, cod, sim, is_nul=False):
import app.embeddings as emb
monkeypatch.setattr(emb, "has_corpus", lambda: True)
monkeypatch.setattr(emb, "suggest_nearest",
lambda text, top_k=1: [{"cod": cod, "is_nul": is_nul, "similaritate": sim}])
def test_prefiltru_nul_supreseaza_inainte_de_knn(conn, monkeypatch):
# Embedding-ul AR sugera un cod, dar pre-filtrul NUL trebuie sa scurtcircuiteze.
chemat = {"da": False}
import app.embeddings as emb
monkeypatch.setattr(emb, "has_corpus", lambda: True)
def spion(text, top_k=1):
chemat["da"] = True
return [{"cod": "OE-1", "is_nul": False, "similaritate": 0.99}]
monkeypatch.setattr(emb, "suggest_nearest", spion)
from app.mapping import enrich_suggestions
out = enrich_suggestions(conn, "13 X ITP")
assert out["sugestie_principala"] is None # non-operatie -> fara cod
assert out["surse"]["nul"] is True
assert chemat["da"] is False # k-NN nici macar interogat
def test_precedenta_gold_exact_embedding(conn, monkeypatch):
from app.shared_store import record_human_validation
from app.mapping import enrich_suggestions, normalize_for_match
den = "OPERATIE DE TEST UNICA"
norm = normalize_for_match(den)
# Toate trei sursele dau coduri diferite.
record_human_validation(conn, den, "OE-1") # GOLD partajat
_silver(conn, norm, "OE-2") # SILVER exact
_mock_embedding(monkeypatch, "OE-3", 0.99) # embedding
conn.commit()
out = enrich_suggestions(conn, den)
assert out["sugestie_principala"] == {"cod_prestatie": "OE-1", "sursa": "gold_partajat"}
# Fara GOLD -> castiga SILVER.
conn.execute("DELETE FROM shared_mappings")
conn.commit()
out = enrich_suggestions(conn, den)
assert out["sugestie_principala"]["sursa"] == "silver"
assert out["sugestie_principala"]["cod_prestatie"] == "OE-2"
# Fara GOLD si fara SILVER -> castiga embedding.
conn.execute("DELETE FROM mapping_suggestions")
conn.commit()
out = enrich_suggestions(conn, den)
assert out["sugestie_principala"] == {"cod_prestatie": "OE-3", "sursa": "embedding"}
def test_prag_similaritate(conn, monkeypatch):
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE + 0.01)
out = enrich_suggestions(conn, "CEVA NEVAZUT")
assert out["surse"]["embedding"] == "OE-3"
def test_abtinere_sub_prag(conn, monkeypatch):
from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
_mock_embedding(monkeypatch, "OE-3", EMB_MIN_SIMILARITATE - 0.01)
out = enrich_suggestions(conn, "CEVA NEVAZUT")
assert out["surse"]["embedding"] is None # sub prag -> abtinere
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
out = enrich_suggestions(conn, "CEVA CARE SEAMANA CU GUNOI")
assert out["surse"]["embedding"] is None # NUL -> nu produce cod
assert out["surse"]["nul"] is True
assert out["sugestie_principala"] is None
def test_invariant_13_resolve_neatins(conn):
"""Regresie #13: SILVER populat NU produce auto-rezolvare in resolve_prestatii."""
from app.mapping import resolve_prestatii, normalize_for_match
_silver(conn, normalize_for_match("OPERATIE X"), "OE-1")
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OPERATIE X", "denumire": "OPERATIE X"}], mapping={}, valid_codes={"OE-1"}
)
assert resolved[0]["cod_prestatie"] is None # ramane nemapat, NU ia codul din SILVER
assert unmapped and unmapped[0]["cod_op_service"] == "OPERATIE X"

View File

@@ -0,0 +1,103 @@
"""US-002 (PRD 5.18) — etichetator offline multi-backend cu prompt procedural.
Toate testele ruleaza FARA retea reala (transport injectabil / inspectie body).
Acopera: prompt 3 pasi, envelope json_schema strict + enum, backend selectabil
prin env, scrub PII inainte de orice request, garda de truncare.
"""
from __future__ import annotations
# Numele pachetului `tools/mapare-llm` contine cratima -> nu e importabil ca modul.
# Incarcam fisierul direct prin importlib pe cale.
import importlib.util
import os
import sys
_PATH = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", "eticheteaza.py")
_spec = importlib.util.spec_from_file_location("eticheteaza", _PATH)
eticheteaza = importlib.util.module_from_spec(_spec)
sys.modules["eticheteaza"] = eticheteaza # necesar pt. @dataclass introspection
_spec.loader.exec_module(eticheteaza)
def test_construieste_prompt_3pasi():
msgs = eticheteaza.construieste_mesaje(["INLOCUIT PLACUTE FRANA"])
assert isinstance(msgs, list) and msgs[0]["role"] == "system"
sys = msgs[0]["content"].upper()
# Procedura in 3 pasi explicita.
assert "PAS 1" in sys and "PAS 2" in sys and "PAS 3" in sys
# Regula NUL + avarie grava doar la accident.
assert "NUL" in sys
assert "ACCIDENT" in sys
# Dezactivare thinking Qwen3 (token /no_think undeva in mesaje).
joined = " ".join(m["content"] for m in msgs)
assert "/no_think" in joined
# User message enumera operatiile.
assert "1." in msgs[1]["content"] and "INLOCUIT PLACUTE FRANA" in msgs[1]["content"]
def test_envelope_json_schema_strict_si_enum():
backend = eticheteaza.get_backend("lmstudio")
body = eticheteaza.construieste_body(["REVIZIE"], backend)
rf = body["response_format"]
# Envelope COMPLET, NU json_object.
assert rf["type"] == "json_schema"
js = rf["json_schema"]
assert js["strict"] is True
assert "name" in js
schema = js["schema"]
cod_schema = schema["properties"]["rez"]["items"]["properties"]["cod"]
# cod = enum peste cele 19 ALL_LABELS (18 coduri + NUL).
assert set(cod_schema["enum"]) == set(eticheteaza.ALL_LABELS)
assert len(eticheteaza.ALL_LABELS) == 19
assert "NUL" in eticheteaza.ALL_LABELS
# temperatura 0 (determinist) si strict items.
assert body["temperature"] == 0
assert schema["properties"]["rez"]["items"]["additionalProperties"] is False
def test_parseaza_raspuns_si_garda_truncare():
batch = ["A", "B", "C"]
# Raspuns complet, ordine amestecata, un cod invalid.
content = {"rez": [{"i": 2, "cod": "OE-1"}, {"i": 1, "cod": "NUL"}, {"i": 3, "cod": "INEXISTENT"}]}
codes = eticheteaza.parseaza_raspuns(content, len(batch))
assert codes == ["NUL", "OE-1", "?"] # cod invalid -> '?', NU ascuns
# Raspuns trunchiat: lipseste pozitia 3 -> '?' pe lipsa, nu eroare.
content_trunc = {"rez": [{"i": 1, "cod": "OE-1"}, {"i": 2, "cod": "OE-2"}]}
codes2 = eticheteaza.parseaza_raspuns(content_trunc, len(batch))
assert codes2 == ["OE-1", "OE-2", "?"]
assert len(codes2) == len(batch)
def test_backend_selectabil_env(monkeypatch):
# Default = lmstudio (backend aprobat v1, D4).
monkeypatch.delenv("ETICHETARE_BACKEND", raising=False)
assert eticheteaza.get_backend().name == "lmstudio"
# Selectie prin env.
monkeypatch.setenv("ETICHETARE_BACKEND", "groq")
assert eticheteaza.get_backend().name == "groq"
# Endpoint + model configurabile prin env.
monkeypatch.setenv("ETICHETARE_BACKEND", "lmstudio")
monkeypatch.setenv("ETICHETARE_ENDPOINT", "http://exemplu:1234/v1/chat/completions")
monkeypatch.setenv("ETICHETARE_MODEL", "qwen/qwen3-custom")
b = eticheteaza.get_backend()
assert b.url == "http://exemplu:1234/v1/chat/completions"
assert b.model == "qwen/qwen3-custom"
def test_scrub_pii_inainte_de_request(monkeypatch):
"""Nicio placuta/VIN nu ajunge la transport — scrub inainte de orice apel."""
capturat = {}
def fake_transport(url, headers, payload, timeout):
capturat["payload"] = payload
return {"choices": [{"message": {"content": '{"rez":[{"i":1,"cod":"OE-1"}]}'}}]}
backend = eticheteaza.get_backend("lmstudio")
codes, meta = eticheteaza.call(["VOPSIT USA B 123 ABC"], backend, transport=fake_transport)
assert codes == ["OE-1"]
body = capturat["payload"]
user_content = body["messages"][1]["content"]
assert "B 123 ABC" not in user_content
assert "[NR]" in user_content
assert meta["err"] is None

View File

@@ -0,0 +1,175 @@
"""US-003 (PRD 5.18) — generare seed etichetat in faze pe frecventa.
Pipeline dedup OBLIGATORIU inainte de orice apel LLM (D5):
brut -> normalize_for_match -> arunca chei vide -> dedup pe cheie (freq=suma NR)
-> reuse etichete existente (labels-groq + seed comis, conflict freq-max) -> de_etichetat.
Idempotenta cross-run (F2/F7): a doua rulare consuma seedul comis ca cache -> 0 apeluri LLM.
Toate testele FARA retea: `clasifica` e injectat (mock care inregistreaza ce primeste).
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
def _load(name: str):
path = os.path.join(os.path.dirname(__file__), "..", "tools", "mapare-llm", f"{name}.py")
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
gs = _load("genereaza_seed")
def _scrie_csv(path, randuri):
"""randuri = [(denumire, nr)]. Format CSV ca docs/operatii-service (`;`, header)."""
linii = ['" ";"DENOP";"NR"']
for i, (den, nr) in enumerate(randuri, 1):
linii.append(f'"{i}";"{den}";"{nr}"')
path.write_text("\n".join(linii) + "\n", encoding="utf-8")
def _mock_recorder():
"""Returneaza (clasifica, vazute) — clasifica raspunde OE-1 pe tot, inregistreaza inputul."""
vazute = []
def clasifica(batch):
vazute.append(list(batch))
return ["OE-1"] * len(batch)
return clasifica, vazute
# --------------------------------------------------------------------------- #
def test_dedup_normalizat(tmp_path):
f1 = tmp_path / "a.csv"
f2 = tmp_path / "b.csv"
_scrie_csv(f1, [("REVIZIE", 10), ("D/R BARA FATA", 3)])
_scrie_csv(f2, [(" revizie ", 5)]) # acelasi logic, case+spatii
corpus = gs.agrega_corpus([str(f1), str(f2)])
assert "REVIZIE" in corpus
assert corpus["REVIZIE"]["freq"] == 15 # 10 + 5, dedup pe cheie
assert len([k for k in corpus]) == 2 # REVIZIE + D/R BARA FATA
def test_skip_cheie_normalizata_vida(tmp_path):
f = tmp_path / "a.csv"
_scrie_csv(f, [(" ", 99), ("REVIZIE", 5)]) # cheie vida (doar spatii)
corpus = gs.agrega_corpus([str(f)])
assert "" not in corpus
assert list(corpus) == ["REVIZIE"]
def test_ordine_pe_frecventa(tmp_path):
f = tmp_path / "a.csv"
_scrie_csv(f, [("OP MICA", 5), ("OP MARE", 50), ("OP MEDIE", 20)])
seed = tmp_path / "seed.json"
clasifica, vazute = _mock_recorder()
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed),
etichetare_all=True, clasifica=clasifica, batch=32)
# Ordinea in care LLM-ul a vazut operatiile = desc pe frecventa.
primul_batch = vazute[0]
assert primul_batch[:3] == ["OP MARE", "OP MEDIE", "OP MICA"]
def test_reuse_in_spatiu_normalizat(tmp_path):
f = tmp_path / "a.csv"
_scrie_csv(f, [("Revizie", 10), ("SCHIMB ULEI", 5)])
labels = tmp_path / "labels.json"
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # cheiat brut, dar normalizeaza la fel
seed = tmp_path / "seed.json"
clasifica, vazute = _mock_recorder()
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed),
etichetare_all=True, clasifica=clasifica)
trimise = {d for b in vazute for d in b}
assert "Revizie" not in trimise and "REVIZIE" not in trimise # deja etichetat -> nu se trimite
seed_data = json.loads(seed.read_text(encoding="utf-8"))
rev = [e for e in seed_data if e["denumire_normalizata"] == "REVIZIE"][0]
assert rev["cod"] == "OE-3"
def test_reuse_conflict_determinist(tmp_path):
f = tmp_path / "a.csv"
# Doua variante raw ale aceleiasi chei, etichetate diferit; freq decide.
_scrie_csv(f, [("CURATAT CATALIZATOR", 100), ("curatat catalizator", 5)])
labels = tmp_path / "labels.json"
labels.write_text(json.dumps({
"CURATAT CATALIZATOR": "OE-1", # freq 100
"curatat catalizator": "OE-2", # freq 5
}), encoding="utf-8")
seed = tmp_path / "seed.json"
clasifica, _ = _mock_recorder()
gs.genereaza([str(f)], labels_path=str(labels), seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
seed_data = json.loads(seed.read_text(encoding="utf-8"))
cat = [e for e in seed_data if e["denumire_normalizata"] == "CURATAT CATALIZATOR"][0]
assert cat["cod"] == "OE-1" # freq-max castiga (100 > 5)
def test_zero_duplicate_trimis_la_llm(tmp_path):
f1 = tmp_path / "a.csv"
f2 = tmp_path / "b.csv"
_scrie_csv(f1, [("REVIZIE", 10), (" revizie ", 4), ("OP NOUA", 7), (" ", 3)])
_scrie_csv(f2, [("REVIZIE", 2), ("OP NOUA", 1)]) # cross-file duplicate
labels = tmp_path / "labels.json"
labels.write_text(json.dumps({"REVIZIE": "OE-3"}), encoding="utf-8") # REVIZIE deja etichetat
seed = tmp_path / "seed.json"
clasifica, vazute = _mock_recorder()
from app.mapping import normalize_for_match
gs.genereaza([str(f1), str(f2)], labels_path=str(labels), seed_path=str(seed),
etichetare_all=True, clasifica=clasifica)
trimise = [d for b in vazute for d in b]
chei = [normalize_for_match(d) for d in trimise]
assert len(chei) == len(set(chei)) # nicio cheie normalizata trimisa de doua ori
assert "" not in chei # nicio cheie vida
assert "REVIZIE" not in chei # nicio cheie deja etichetata
assert "OP NOUA" in chei # doar ce lipseste
def test_rerun_zero_apeluri_llm(tmp_path):
"""Criteriul real de idempotenta (F2/F7): a doua rulare = 0 apeluri LLM, seed identic."""
f = tmp_path / "a.csv"
_scrie_csv(f, [("OP UNU", 10), ("OP DOI", 5)])
seed = tmp_path / "seed.json"
clasifica1, vazute1 = _mock_recorder()
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica1)
assert sum(len(b) for b in vazute1) == 2 # prima rulare eticheteaza ambele
bytes1 = seed.read_bytes()
clasifica2, vazute2 = _mock_recorder()
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica2)
assert vazute2 == [] # a doua rulare: 0 apeluri LLM (seed = cache)
bytes2 = seed.read_bytes()
assert bytes1 == bytes2 # seed identic byte-cu-byte
def test_format_seed_valid(tmp_path):
f = tmp_path / "a.csv"
_scrie_csv(f, [("OP REALA", 10), ("13 X ITP", 5)])
seed = tmp_path / "seed.json"
def clasifica(batch):
# marcheaza ITP ca NUL, restul OE-1
return ["NUL" if "ITP" in d.upper() else "OE-1" for d in batch]
gs.genereaza([str(f)], labels_path=None, seed_path=str(seed), etichetare_all=True, clasifica=clasifica)
data = json.loads(seed.read_text(encoding="utf-8"))
chei = [e["denumire_normalizata"] for e in data]
assert len(chei) == len(set(chei)) # unice
assert all(e["denumire_normalizata"] for e in data) # non-vide
for e in data:
assert set(e) >= {"denumire", "denumire_normalizata", "cod", "is_nul", "source", "confidence"}
if e["is_nul"]:
assert e["cod"] is None # NUL -> cod NULL (oglindeste CHECK-ul DB)
else:
assert e["cod"]
nul = [e for e in data if e["is_nul"]][0]
assert "ITP" in nul["denumire_normalizata"]

View File

@@ -272,14 +272,18 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
get_settings.cache_clear()
monkeypatch.setattr(emb_mod, "_engine", EmbeddingEngine(backend=_FakeEmbedBackend()))
# Nomenclatorul (din fixtura conn) are OE-1..OE-4; adaug coduri cu denumiri keyword.
# Corpusul sursa = mapping_suggestions (SILVER) -- PRD 5.18 US-005.
# (Inainte era nomenclator_rar; migrat la mapping_suggestions ca k-NN sa
# opereze pe exemple reale etichetate, nu pe categorii generice RAR.)
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("UL-1", "Schimb ulei"),
"INSERT OR REPLACE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
("Schimb ulei", "UL-1", 0, "llm", 0.95),
)
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("FR-1", "Placute frana"),
"INSERT OR REPLACE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
("Placute frana", "FR-1", 0, "llm", 0.95),
)
conn.commit()

113
tests/test_operatii_seed.py Normal file
View File

@@ -0,0 +1,113 @@
"""US-004 (PRD 5.18) — seeder corpus etichetat in mapping_suggestions (SILVER).
INSERT OR IGNORE din artefactul comis -> SILVER nu mai e gol in productie.
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).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "us004.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield tmp
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _scrie_seed(tmp, items) -> str:
p = os.path.join(tmp, "operatii-etichetate.json")
with open(p, "w", encoding="utf-8") as fh:
json.dump(items, fh, ensure_ascii=False)
return p
SEED_OE = {"denumire": "SCHIMB ULEI MOTOR", "denumire_normalizata": "SCHIMB ULEI MOTOR",
"cod": "OE-3", "is_nul": False, "source": "llm_seed", "confidence": 0.7}
SEED_NUL = {"denumire": "13 X ITP", "denumire_normalizata": "13 X ITP",
"cod": None, "is_nul": True, "source": "llm_seed", "confidence": 0.7}
def test_seed_populeaza_mapping_suggestions(env, conn):
from app.operatii_seed import seed_operatii_etichetate
path = _scrie_seed(env, [SEED_OE])
n = seed_operatii_etichetate(conn, path)
conn.commit()
assert n == 1
row = conn.execute(
"SELECT cod_prestatie, source, confidence FROM mapping_suggestions "
"WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
).fetchone()
assert row["cod_prestatie"] == "OE-3"
assert row["source"] == "llm_seed"
assert abs(row["confidence"] - 0.7) < 1e-9
def test_is_nul_din_seed(env, conn):
from app.operatii_seed import seed_operatii_etichetate
path = _scrie_seed(env, [SEED_NUL])
seed_operatii_etichetate(conn, path)
conn.commit()
row = conn.execute(
"SELECT cod_prestatie, is_nul FROM mapping_suggestions WHERE denumire_normalizata = '13 X ITP'"
).fetchone()
assert row["is_nul"] == 1
assert row["cod_prestatie"] is None # respecta CHECK-ul (NUL -> cod NULL)
def test_insert_or_ignore_nu_clobber(env, conn):
from app.operatii_seed import seed_operatii_etichetate
# Un rand pre-existent (ex. embedding) pe aceeasi cheie, cu alt cod.
conn.execute(
"INSERT INTO mapping_suggestions (denumire_normalizata, cod_prestatie, is_nul, source, confidence) "
"VALUES ('SCHIMB ULEI MOTOR', 'OE-1', 0, 'embedding', 0.5)"
)
conn.commit()
path = _scrie_seed(env, [SEED_OE])
n = seed_operatii_etichetate(conn, path)
conn.commit()
assert n == 0 # INSERT OR IGNORE -> nu suprascrie
row = conn.execute(
"SELECT cod_prestatie, source FROM mapping_suggestions WHERE denumire_normalizata = 'SCHIMB ULEI MOTOR'"
).fetchone()
assert row["cod_prestatie"] == "OE-1" # randul existent ramane neatins
assert row["source"] == "embedding"
def test_idempotent_la_reinit(env, conn):
from app.operatii_seed import seed_operatii_etichetate
path = _scrie_seed(env, [SEED_OE, SEED_NUL])
n1 = seed_operatii_etichetate(conn, path)
conn.commit()
n2 = seed_operatii_etichetate(conn, path)
conn.commit()
assert n1 == 2
assert n2 == 0 # a doua rulare nu dubleaza
total = conn.execute("SELECT COUNT(*) AS n FROM mapping_suggestions").fetchone()["n"]
assert total == 2
def test_seed_inexistent_e_noop(env, conn):
from app.operatii_seed import seed_operatii_etichetate
n = seed_operatii_etichetate(conn, os.path.join(env, "nu-exista.json"))
assert n == 0

View File

@@ -0,0 +1,72 @@
"""US-001 (PRD 5.18) — pre-filtru determinist non-operatii (NUL).
Masuratoarea k-NN (memorie test-precizie-knn-embeddings) arata recall NUL doar 64%:
gunoiul evident (ITP, plata, discount, nr. inmatriculare, tractare) scapa ca OE-1.
Un pre-filtru determinist il marcheaza NUL INAINTE de k-NN.
Garantie non-negociabila (AC): ZERO fals-pozitiv pe operatii reale. Regulile
text/regex au fost calibrate pe `docs/operatii-service/*.csv` (vezi sesiunea de
implementare): triggerele ambigue (TRACTARE, NR INMATRICULARE/placuta) sunt
ECRANATE de un context de piesa/operatie (D/R, CARLIG, CAPAC, INLOCUIT...).
"""
from __future__ import annotations
from app.mapping import prefiltru_nul
def test_itp_e_nul():
assert prefiltru_nul("13 X ITP") is True
assert prefiltru_nul("11XITP") is True # glue fara spatii
assert prefiltru_nul("ITP") is True
assert prefiltru_nul("2 X ITP") is True
def test_plata_discount_nul():
assert prefiltru_nul("DISCOUNT FIDELITATE 10%") is True
assert prefiltru_nul("REDUCERE COMERCIALA") is True
assert prefiltru_nul("ACHITAT DE CONF.URBAN") is True
assert prefiltru_nul("PLATA AVANS") is True
assert prefiltru_nul("TAXA DE MEDIU") is True
def test_nr_inmatriculare_nul():
assert prefiltru_nul("NR INMATRICULARE") is True
assert prefiltru_nul("NUMAR INMATRICULARE") is True
assert prefiltru_nul("B 123 ABC") is True # pattern placuta standalone
assert prefiltru_nul("CT 44 MKY") is True
def test_tractare_serviciu_nul():
# Serviciul de tractare (rmorca) = non-operatie de service.
assert prefiltru_nul("TRACTARE CTA-SLOBOZIA") is True
assert prefiltru_nul("TRACTARE 100 KM") is True
def test_operatie_reala_nu_e_nul():
# Punctul critic: trigger ambiguu intr-un context de piesa reala -> NU e NUL.
assert prefiltru_nul("INLOCUIT PLACUTE FRANA") is False
assert prefiltru_nul("D/R CARLIG TRACTARE") is False # carlig = piesa, nu serviciu
assert prefiltru_nul("D/R CAPAC TRACTARE BARA SPATE") is False
assert prefiltru_nul("D/R NR INMATRICULARE") is False # suport placuta = piesa
assert prefiltru_nul("D/R ELECTROMOTOR CT 44 MKY") is False # placuta lipita la o operatie reala
def test_zero_fals_pozitiv_pe_set_operatii_reale():
"""AC: zero fals-pozitiv pe un set de 20 operatii reale (din docs/operatii-service)."""
reale = [
"REVIZIE", "SCHIMB ULEI MOTOR", "INLOCUIT PLACUTE FRANA FATA",
"D/R BARA FATA", "VOPSIT USA DR FATA", "INLOCUIT FILTRU AER",
"AERISIT INSTALATIE FRANA", "INLOCUIT AMORTIZOR SPATE", "ABSORBANT SOC BARA SPATE",
"INLOCUIT CUREA DISTRIBUTIE", "REGLAT FARURI", "INLOCUIT BUJII",
"REPARAT ARIPA FATA DR", "INLOCUIT DISCURI FRANA", "GRESAT PLANETARA",
"INLOCUIT RULMENT ROATA", "MONTAT ANVELOPE", "INLOCUIT BATERIE",
"DIAGNOZA COMPUTERIZATA", "INLOCUIT CONTACT PORNIRE",
]
for op in reale:
assert prefiltru_nul(op) is False, f"fals-pozitiv pe operatie reala: {op!r}"
def test_input_gol_nu_e_nul():
assert prefiltru_nul("") is False
assert prefiltru_nul(None) is False # type: ignore[arg-type]

View File

@@ -141,6 +141,21 @@ def test_seed_suggestions_nul_cu_cod_explicit_tot_nul(conn):
assert row["cod_prestatie"] is None # cod explicit ignorat cand is_nul
def test_seed_suggestions_cod_whitespace_devine_null(conn):
"""Rand non-NUL cu cod whitespace-only (' ') -> cod_prestatie NULL, NU '' (corectitudine)."""
from app.shared_store import seed_suggestions, lookup_suggestion
seed_suggestions(conn, [
{"denumire": "OPERATIE CU COD GOL", "cod_prestatie": " ", "source": "llm", "confidence": 0.5},
])
conn.commit()
row = lookup_suggestion(conn, "OPERATIE CU COD GOL")
assert row is not None
assert row["is_nul"] == 0 # nu e marcat NUL
assert row["cod_prestatie"] is None # whitespace -> NULL, nu '' (rand non-NUL fara cod gol)
def test_seed_suggestions_normalizare_diacritice(conn):
"""Lookup pe forma cu diacritice gaseste randul seedat fara diacritice (normalize_for_match)."""
from app.shared_store import seed_suggestions, lookup_suggestion

View File

@@ -0,0 +1,140 @@
"""TDD 5.18 US-007 — Badge sursa sugestie in editorul de mapare (_mapari.html).
Chip mic langa sugestia sistemului care arata DE UNDE vine codul propus:
- "confirmat" -> GOLD partajat (validat de om, shared_mappings)
- "similar" -> SILVER exact-match / k-NN embeddings (exemplu deja vazut)
- "non-operatie" -> pre-filtru NUL determinist (ITP/plata/discount...)
Toate suggestion-only (#13): badge-ul e doar indiciu vizual, nu schimba enqueue.
Render real prin GET /_fragments/mapari (fragmentul HTMX scoped pe cont).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
"""DB temporara cu schema, auth web dezactivata (mod dev -> cont id=1)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge_sursa_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def client(env):
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
def _seed_nomenclator(conn):
conn.executemany(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
[("OE-1", "REPARATIE"), ("OE-3", "REVIZIE PERIODICA")],
)
def _insert_needs_mapping(conn, *, op: str, denumire: str, key: str):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key) "
"VALUES (1, 'needs_mapping', ?, ?)",
(json.dumps({
"vin": "WVWZZZ1KZAW001111",
"prestatii": [{"cod_op_service": op, "denumire": denumire}],
}), key),
)
def test_badge_gold_confirmat(env, client):
"""O operatie cu match in GOLD partajat -> chip 'confirmat' in coloana Sugestii."""
from app.db import get_connection
from app.shared_store import record_human_validation
conn = get_connection()
try:
_seed_nomenclator(conn)
record_human_validation(conn, "Revizie anuala", "OE-3")
_insert_needs_mapping(conn, op="OP-REV", denumire="Revizie anuala", key="badge-gold-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--confirmat" in html
assert ">confirmat<" in html
def test_badge_similar_silver(env, client):
"""O operatie cu match in SILVER (mapping_suggestions) -> chip 'similar'."""
from app.db import get_connection
from app.shared_store import seed_suggestions
conn = get_connection()
try:
_seed_nomenclator(conn)
seed_suggestions(conn, [
{"denumire": "Reparatie motor", "cod_prestatie": "OE-1", "source": "llm", "confidence": 0.9},
])
_insert_needs_mapping(conn, op="OP-REP", denumire="Reparatie motor", key="badge-similar-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--similar" in html
assert ">similar<" in html
# NU trebuie sa fie marcat confirmat (sursa e SILVER, nu GOLD).
assert "sugg-sursa--confirmat" not in html
def test_badge_nul_non_operatie(env, client):
"""O operatie prinsa de pre-filtrul NUL (ITP) -> chip 'non-operatie', fara cod sugerat."""
from app.db import get_connection
conn = get_connection()
try:
_seed_nomenclator(conn)
_insert_needs_mapping(conn, op="OP-ITP", denumire="ITP CT 12 ABC", key="badge-nul-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
html = resp.text
assert "sugg-sursa--nul" in html
assert ">non-operatie<" in html
def test_fara_sursa_fara_badge(env, client):
"""O operatie fara nicio sursa (necunoscuta) NU primeste chip de sursa."""
from app.db import get_connection
conn = get_connection()
try:
_seed_nomenclator(conn)
_insert_needs_mapping(conn, op="OP-NISA", denumire="Operatie complet necunoscuta xyz", key="badge-none-1")
conn.commit()
finally:
conn.close()
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200, resp.text
assert "sugg-sursa" not in resp.text

View File

@@ -0,0 +1,185 @@
"""Teste keepalive RAR — login de proba periodic ca dashboard-ul sa nu afiseze
fals "RAR inaccesibil" doar din lipsa de trafic.
Comportament asteptat (_maybe_keepalive):
- login vechi/lipsa + creds durabile -> sondeaza (get_token apelat) si forteaza
login real (invalidate inainte);
- login proaspat (sub interval) -> NU sondeaza;
- interval=0 -> dezactivat;
- fara cont cu creds durabile -> nu sondeaza;
- gating: dupa o incercare, nu re-sondeaza in cadrul intervalului (nu hartui RAR).
"""
from __future__ import annotations
import os
import tempfile
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn, get_settings()
conn.close()
get_settings.cache_clear()
class _FakeSessions:
"""Imita AccountSessions: get_token reusit reimprospateaza heartbeat-ul (ca realul)."""
def __init__(self, conn, *, fail: bool = False):
self._conn = conn
self._fail = fail
self.invalidated: list[int] = []
self.tokens: list[int] = []
def invalidate(self, account_id: int) -> None:
self.invalidated.append(account_id)
def get_token(self, conn, account_id: int, creds) -> str | None:
self.tokens.append(account_id)
if self._fail:
raise RuntimeError("RAR jos")
from app.db import write_heartbeat
write_heartbeat(conn, rar_login_ok=True, detail=f"login proba (cont {account_id})")
return "tok"
def _set_last_login(conn, *, ago_s: float | None):
"""Seteaza last_rar_login_ok la now-ago_s (None = niciun login)."""
from app.db import write_heartbeat
write_heartbeat(conn, detail="poll") # asigura randul heartbeat
if ago_s is None:
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=NULL WHERE id=1")
else:
ts = (datetime.now(timezone.utc) - timedelta(seconds=ago_s)).isoformat()
conn.execute("UPDATE worker_heartbeat SET last_rar_login_ok=? WHERE id=1", (ts,))
conn.commit()
def _account_cu_creds(conn) -> int:
from app.accounts import create_account
from app.crypto import encrypt_creds
acct = create_account(conn, "Service Cu Creds", email="svc@example.com")
enc = encrypt_creds({"email": "svc@example.com", "password": "secret"})
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct))
conn.commit()
return acct
def test_login_vechi_sondeaza_si_reimprospateaza(env):
"""Login mai vechi decat intervalul + creds durabile -> proba reala, heartbeat reimprospatat."""
from app.worker.__main__ import _maybe_keepalive
from app.db import read_heartbeat
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
acct = _account_cu_creds(conn)
_set_last_login(conn, ago_s=100000) # > 24h
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == [acct] # a sondat contul cu creds
assert sessions.invalidated == [acct] # a fortat login real (nu token din cache)
last = read_heartbeat(conn)["last_rar_login_ok"]
age = (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds()
assert age < 60 # heartbeat reimprospatat de proba
def test_login_proaspat_nu_sondeaza(env):
"""Login sub interval -> niciun login de proba."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
_account_cu_creds(conn)
_set_last_login(conn, ago_s=3600) # 1h < 24h
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_interval_zero_dezactivat(env):
"""interval=0 -> keepalive dezactivat, nicio proba chiar cu login vechi."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 0
_account_cu_creds(conn)
_set_last_login(conn, ago_s=100000)
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_fara_creds_durabile_nu_sondeaza(env):
"""Niciun cont cu creds durabile + fara test-creds -> nimic de sondat."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
settings.worker_use_test_creds = False
_set_last_login(conn, ago_s=100000)
sessions = _FakeSessions(conn)
_maybe_keepalive(conn, settings, sessions, {"last_attempt": 0.0})
assert sessions.tokens == []
def test_target_sare_creds_nedecriptabile(env):
"""Cont cu creds criptate sub alta cheie (decrypt -> None) e sarit; alege contul valid.
Reproduce bug-ul real: start.sh both genereaza o cheie efemera noua la fiecare
pornire, deci creds-urile durabile vechi nu se mai decripteaza.
"""
from app.worker.__main__ import _keepalive_target
from app.accounts import create_account
from app.crypto import encrypt_creds
conn, settings = env
settings.worker_use_test_creds = False
# Cont cu creds GUNOI (nedecriptabile sub cheia curenta), id mai mic.
bad = create_account(conn, "Cont Cheie Veche", email="old@example.com")
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", ("gAAAAA-token-invalid", bad))
# Cont cu creds valide, id mai mare.
good = create_account(conn, "Cont Valid", email="good@example.com")
enc = encrypt_creds({"email": "good@example.com", "password": "pw"})
conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, good))
conn.commit()
acct_id, creds = _keepalive_target(conn, settings)
assert acct_id == good # a sarit contul nedecriptabil
assert creds and creds["email"] == "good@example.com"
def test_gating_nu_hartuieste_pe_esec(env):
"""Pe esec (RAR jos) login-ul ramane vechi; a doua trecere imediata NU re-sondeaza."""
from app.worker.__main__ import _maybe_keepalive
conn, settings = env
settings.worker_rar_keepalive_interval_s = 86400
_account_cu_creds(conn)
_set_last_login(conn, ago_s=100000)
state = {"last_attempt": 0.0}
sessions = _FakeSessions(conn, fail=True)
_maybe_keepalive(conn, settings, sessions, state) # incearca, esueaza
_maybe_keepalive(conn, settings, sessions, state) # gating: nu re-incearca
assert sessions.tokens == [sessions.invalidated[0]] # o singura proba
assert len(sessions.tokens) == 1

View File

@@ -0,0 +1,258 @@
"""Etichetator offline operatii service -> coduri RAR (US-002, PRD 5.18).
Backend implicit = **LM Studio local** (Qwen3-4B, GPU RX 6600M via Tailscale),
backend-ul APROBAT pentru bootstrap-ul v1 (decizia D4). Groq / OpenRouter raman
fallback-uri interschimbabile, dar NU sunt calea aprobata pentru v1.
Particularitati care justifica un tool NOU (nu reuse de `or_common.call`):
- LM Studio RESPINGE `response_format: json_object` (eroare 400). Cere envelope
`json_schema` STRICT complet: {"type":"json_schema","json_schema":{...,"strict":true}}.
- `cod` e ENUM peste cele 19 etichete (18 coduri RAR + NUL) -> modelul nu poate
inventa coduri; orice abatere e prinsa de garda de truncare ('?').
- Qwen3 emite `<think>...` daca nu dezactivam thinking-ul -> umfla tokeni/latenta
sub structured output strict. Punem `/no_think` in promptul de sistem.
Setari conservatoare OBLIGATORII pe GPU-box (a facut shutdown sub sarcina 2026-06-29,
probabil termic/alimentare): in LM Studio incarca modelul cu `n_parallel=1`,
`n_ctx=4096`, batch 32-40, monitorizeaza temperatura. NU mari batch/context fara
headroom termic. Vezi memorie `lmstudio-gpu-etichetare`.
Reutilizeaza din `or_common`: scrub-ul PII (F3) si lista de coduri.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
# --- Coduri + scrub PII: sursa de adevar = or_common (acelasi nomenclator de etichete) ---
import importlib.util as _ilu
_OR_PATH = os.path.join(os.path.dirname(__file__), "or_common.py")
_spec = _ilu.spec_from_file_location("or_common", _OR_PATH)
or_common = _ilu.module_from_spec(_spec)
sys.modules.setdefault("or_common", or_common)
_spec.loader.exec_module(or_common)
scrub = or_common.scrub # VIN/placuta -> [VIN]/[NR]
# Cele 19 etichete (18 coduri RAR + NUL), extrase din CODURI (sursa unica or_common).
ALL_LABELS: list[str] = [c.split("=")[0].strip() for c in or_common.CODURI.replace(", ", ",").split(",")]
assert "NUL" in ALL_LABELS and len(ALL_LABELS) == 19, ALL_LABELS
_VALID = set(ALL_LABELS)
# --------------------------------------------------------------------------- #
# Prompt procedural in 3 pasi (versionat) #
# --------------------------------------------------------------------------- #
PROMPT_VERSION = "3pasi-v1"
_CODURI_LISTA = or_common.CODURI
SYS = (
"Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul "
"din aceste coduri:\n" + _CODURI_LISTA + "\n\n"
"Urmeaza PROCEDURA in 3 pasi, in ordine:\n"
"PAS 1 (non-operatie -> NUL): daca textul NU e o operatie tehnica de service "
"(ITP, plata/achitat, discount/reducere, taxa, nr inmatriculare/placuta, manopera "
"generica, sau DOAR un nume de piesa fara actiune) -> cod = NUL. Opreste-te.\n"
"PAS 2 (avarie din ACCIDENT -> avarie grava): foloseste codurile de avarie grava DOAR "
"pentru daune in urma unui accident, pe sistemul avariat:\n"
" caroserie/structura rezistenta -> OE-C; sasiu -> OE-S; directie -> OE-D; "
"franare -> OE-F; sistem de retinere/airbag -> OE-R; ADAS (asistenta condus) -> OE-A.\n"
" Reparatiile curente, de uzura (NU dintr-un accident) NU sunt avarii grave -> mergi la PAS 3.\n"
"PAS 3 (operatie obisnuita): \n"
" inlocuire / D-R / reparare / vopsire / retus piese -> OE-1 (REPARATIE);\n"
" schimb ulei motor + filtre -> OE-3 (REVIZIE PERIODICA);\n"
" aerisit / gresat / completat nivele -> OE-2 (INTRETINERE);\n"
" reglare functionala (geometrie directie, faruri, ralanti) -> OE-4;\n"
" actualizare/programare software -> OE-7; schimb sezonier anvelope -> OE-8;\n"
" istoric/reparatie/inlocuire odometru -> OE-I / R-ODO / I-ODO; tahograf -> AITLV.\n\n"
"Raspunde DOAR cu JSON conform schemei. /no_think"
)
def construieste_mesaje(batch: list[str]) -> list[dict]:
"""Mesajele chat (system procedural + user enumerat). Scrub PII pe fiecare item."""
user = "\n".join(f"{i + 1}. {scrub(o)}" for i, o in enumerate(batch))
return [
{"role": "system", "content": SYS},
{"role": "user", "content": user},
]
# --------------------------------------------------------------------------- #
# Schema json_schema strict (envelope complet — LM Studio respinge json_object) #
# --------------------------------------------------------------------------- #
def _response_format() -> dict:
return {
"type": "json_schema",
"json_schema": {
"name": "etichete_operatii",
"strict": True,
"schema": {
"type": "object",
"properties": {
"rez": {
"type": "array",
"items": {
"type": "object",
"properties": {
"i": {"type": "integer"},
"cod": {"type": "string", "enum": ALL_LABELS},
},
"required": ["i", "cod"],
"additionalProperties": False,
},
}
},
"required": ["rez"],
"additionalProperties": False,
},
},
}
# --------------------------------------------------------------------------- #
# Backend-uri (LM Studio default; Groq/OpenRouter fallback) #
# --------------------------------------------------------------------------- #
@dataclass
class Backend:
name: str
url: str
model: str
api_key: str | None = None
# Endpoint LM Studio implicit = GPU-box pe Tailscale (memorie lmstudio-gpu-etichetare).
_DEFAULT_LMSTUDIO_URL = "http://100.64.151.22:1234/v1/chat/completions"
_BACKENDS = {
"lmstudio": {"url": _DEFAULT_LMSTUDIO_URL, "model": "qwen/qwen3-4b", "key_env": None},
"groq": {"url": "https://api.groq.com/openai/v1/chat/completions",
"model": "llama-3.3-70b-versatile", "key_env": "GROQ_KEY"},
"openrouter": {"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "qwen/qwen3-4b:free", "key_env": "OPENROUTER_KEY"},
}
def get_backend(name: str | None = None) -> Backend:
"""Construieste backend-ul din env. Default = lmstudio (D4).
Override-uri: ETICHETARE_BACKEND, ETICHETARE_ENDPOINT, ETICHETARE_MODEL.
Cheia API (Groq/OpenRouter) se citeste din env-ul indicat de backend; LM Studio
local nu cere cheie.
"""
name = (name or os.environ.get("ETICHETARE_BACKEND") or "lmstudio").strip().lower()
if name not in _BACKENDS:
raise ValueError(f"backend necunoscut: {name} (alege din {list(_BACKENDS)})")
cfg = _BACKENDS[name]
url = os.environ.get("ETICHETARE_ENDPOINT") or cfg["url"]
model = os.environ.get("ETICHETARE_MODEL") or cfg["model"]
api_key = os.environ.get(cfg["key_env"]) if cfg["key_env"] else None
return Backend(name=name, url=url, model=model, api_key=api_key)
def construieste_body(batch: list[str], backend: Backend) -> dict:
"""Corpul request-ului OpenAI-compatibil cu envelope json_schema strict."""
return {
"model": backend.model,
"messages": construieste_mesaje(batch),
"temperature": 0,
"response_format": _response_format(),
}
# --------------------------------------------------------------------------- #
# Parsare + garda de truncare #
# --------------------------------------------------------------------------- #
def parseaza_raspuns(content: dict, n: int) -> list[str]:
"""Mapeaza raspunsul {"rez":[{i,cod}]} la o lista paralela cu batch-ul (len n).
Garda de truncare/validare (F8): pozitiile lipsa SAU codurile in afara enum-ului
devin '?', NU sunt ascunse tacit. Apelantul logheaza cate '?' au ramas.
"""
by_i: dict[int, str] = {}
for x in content.get("rez") or []:
try:
idx = int(x["i"])
except (KeyError, TypeError, ValueError):
continue
cod = str(x.get("cod") or "").strip().upper()
by_i[idx] = cod if cod in _VALID else "?"
return [by_i.get(i + 1, "?") for i in range(n)]
# --------------------------------------------------------------------------- #
# Transport (injectabil in teste) #
# --------------------------------------------------------------------------- #
def _urllib_transport(url: str, headers: dict, payload: dict, timeout: int) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def call(
batch: list[str],
backend: Backend,
*,
timeout: int = 180,
max_attempts: int = 5,
transport=None,
) -> tuple[list[str], dict]:
"""Un apel pe un batch. Intoarce (codes, meta).
codes: lista paralela cu batch; '?' pe pozitiile fara raspuns valid (garda F8).
meta: {ms, err, missing} — `missing` = cate '?' au ramas (truncare/cod invalid).
transport: callable(url, headers, payload, timeout) -> dict raspuns OpenAI
(injectabil in teste; default urllib).
"""
transport = transport or _urllib_transport
body = construieste_body(batch, backend)
headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
if backend.api_key:
headers["Authorization"] = f"Bearer {backend.api_key}"
t0 = time.time()
for attempt in range(max_attempts):
try:
resp = transport(backend.url, headers, body, timeout)
content = json.loads(resp["choices"][0]["message"]["content"])
codes = parseaza_raspuns(content, len(batch))
missing = codes.count("?")
return codes, {"ms": int((time.time() - t0) * 1000), "err": None, "missing": missing}
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503):
wait = float(e.headers.get("retry-after", 0)) or min(2 ** attempt, 30)
time.sleep(wait)
continue
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": f"HTTP {e.code}", "missing": len(batch)}
except Exception as e: # noqa: BLE001 — degradare gratioasa, batch-ul devine '?'
if attempt < max_attempts - 1:
time.sleep(min(2 ** attempt, 20))
continue
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": type(e).__name__, "missing": len(batch)}
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": "max_attempts", "missing": len(batch)}
if __name__ == "__main__":
# Sanity-check manual: 1 batch mic pe backend-ul configurat (default lmstudio).
import sys
probe = sys.argv[1:] or ["13 X ITP", "INLOCUIT PLACUTE FRANA FATA", "SCHIMB ULEI MOTOR SI FILTRE"]
b = get_backend()
print(f"backend={b.name} url={b.url} model={b.model}")
codes, meta = call(probe, b)
for op, c in zip(probe, codes):
print(f" {c:6} {op}")
print("meta:", meta)

View File

@@ -0,0 +1,346 @@
"""Generare seed etichetat operatie->cod (US-003, PRD 5.18).
Produce artefactul `app/data/operatii-etichetate.json` (comis in repo), consumat de
seeder (US-004) si de corpusul embeddings (US-005). NU cheama LLM la runtime — o
singura data, offline, pe LM Studio (backend implicit, D4).
Pipeline dedup OBLIGATORIU, in ordine, INAINTE de orice apel LLM (D5):
1. Agrega cele N CSV-uri -> freq pe denumire RAW (NR ne-numeric -> skip rand, F9).
2. `cheie = normalize_for_match(denumire)` (ACEEASI functie ca DB/k-NN, NU strip exact).
Arunca randurile cu `cheie == ""` inainte de dedup (coliziune pe slot UNIQUE gol, F6).
3. Dedup pe cheie: un reprezentant per cheie, `freq = suma NR`.
4. Harta `cheie -> cod` din TOATE etichetele existente: `labels-groq-partial.json` (cheiat
brut) + seedul comis anterior (cheiat normalizat). Conflict (acelasi cheie, coduri diferite
pe variante raw) -> castiga codul cu freq-max, tie-break pe cod sortat (F3).
5. `de_etichetat = corpus(in prag) - harta`. Sortat desc pe freq = SINGURUL input la LLM.
Idempotenta cross-run (F2/F7): seedul comis = cache de etichete -> re-run = 0 apeluri LLM.
"""
from __future__ import annotations
import argparse
import csv
import glob
import importlib.util
import json
import os
import sys
from collections import Counter, defaultdict
# Functia de normalizare = sursa unica de adevar (consistenta cu DB/k-NN).
_APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _APP_ROOT not in sys.path:
sys.path.insert(0, _APP_ROOT)
from app.mapping import normalize_for_match # noqa: E402
def _load_eticheteaza():
path = os.path.join(os.path.dirname(__file__), "eticheteaza.py")
spec = importlib.util.spec_from_file_location("eticheteaza", path)
mod = importlib.util.module_from_spec(spec)
sys.modules.setdefault("eticheteaza", mod)
spec.loader.exec_module(mod)
return mod
# Cai implicite (relative la repo).
DEFAULT_CSV_GLOB = os.path.join(_APP_ROOT, "docs", "operatii-service", "*.csv")
DEFAULT_LABELS = os.path.join(_APP_ROOT, "tools", "mapare-llm", "labels-groq-partial.json")
DEFAULT_SEED = os.path.join(_APP_ROOT, "app", "data", "operatii-etichetate.json")
NUL_LABEL = "NUL"
DEFAULT_CONFIDENCE = 0.7
DEFAULT_SOURCE = "llm_seed"
# --------------------------------------------------------------------------- #
# Pasul 1-3: corpus agregat pe cheie normalizata #
# --------------------------------------------------------------------------- #
def _freq_raw(csv_paths: list[str]) -> Counter:
"""Counter denumire_raw -> suma NR. NR ne-numeric -> skip rand (F9), nu zero-weight."""
freq: Counter = Counter()
for f in csv_paths:
with open(f, encoding="utf-8", errors="replace") as fh:
for r in list(csv.reader(fh, delimiter=";"))[1:]:
if len(r) <= 2:
continue
den = r[1].strip()
if not den:
continue
nr_raw = (r[2] or "").strip()
try:
nr = int(nr_raw)
except ValueError:
continue # F9: skip rand cu NR ne-numeric
freq[den] += nr
return freq
def _corpus_din_freq(freq_raw: Counter) -> dict[str, dict]:
"""{cheie_normalizata -> {denumire, freq}}. Arunca cheile vide (F6).
`denumire` = varianta raw cu freq individual maxim (tie-break: raw sortat asc),
folosita ca text trimis la LLM si stocata in seed.
"""
grup: dict[str, list[tuple[str, int]]] = defaultdict(list)
for raw, n in freq_raw.items():
cheie = normalize_for_match(raw)
if not cheie:
continue # F6
grup[cheie].append((raw, n))
corpus: dict[str, dict] = {}
for cheie, variante in grup.items():
freq = sum(n for _, n in variante)
# reprezentant determinist: freq max, tie-break raw sortat.
denumire = sorted(variante, key=lambda rn: (-rn[1], rn[0]))[0][0]
corpus[cheie] = {"denumire": denumire, "freq": freq}
return corpus
def agrega_corpus(csv_paths: list[str]) -> dict[str, dict]:
"""{cheie_normalizata -> {denumire, freq}} din CSV-uri (pasii 1-3)."""
return _corpus_din_freq(_freq_raw(csv_paths))
# --------------------------------------------------------------------------- #
# Pasul 4: harta cheie -> cod din etichetele existente (reuse + conflict) #
# --------------------------------------------------------------------------- #
def _incarca_seed(seed_path: str | None) -> list[dict]:
if not seed_path or not os.path.exists(seed_path):
return []
try:
with open(seed_path, encoding="utf-8") as fh:
return json.loads(fh.read())
except (ValueError, OSError):
return []
def construieste_harta_etichete(
freq_raw: Counter,
corpus: dict[str, dict],
labels_path: str | None,
seed_existent: list[dict],
) -> dict[str, str]:
"""Harta cheie_normalizata -> eticheta (cod RAR sau 'NUL'), reuse in spatiu normalizat.
Voturi ponderate pe freq; conflict pe acelasi cheie -> freq-max, tie-break cod sortat (F3).
"""
votes: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
# labels-groq-partial.json: cheiat pe text BRUT.
if labels_path and os.path.exists(labels_path):
with open(labels_path, encoding="utf-8") as fh:
labels = json.loads(fh.read())
for raw, cod in labels.items():
cheie = normalize_for_match(raw)
if not cheie:
continue
cod = str(cod or "").strip().upper()
if not cod:
continue
votes[cheie][cod] += freq_raw.get(raw, 0)
# seed comis anterior: cheiat normalizat (cache cross-run).
for e in seed_existent:
cheie = e.get("denumire_normalizata")
if not cheie:
continue
eticheta = NUL_LABEL if e.get("is_nul") else str(e.get("cod") or "").strip().upper()
if not eticheta:
continue
votes[cheie][eticheta] += corpus.get(cheie, {}).get("freq", 0)
harta: dict[str, str] = {}
for cheie, codmap in votes.items():
# freq desc, apoi cod asc -> determinist.
harta[cheie] = sorted(codmap.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
return harta
# --------------------------------------------------------------------------- #
# Pasul 5: selectie de_etichetat (prag de volum) + orchestrare #
# --------------------------------------------------------------------------- #
def selecteaza_de_etichetat(
corpus: dict[str, dict],
harta: dict[str, str],
*,
target_volum: float,
etichetare_all: bool,
) -> list[str]:
"""Cheile ne-etichetate, sortate desc pe freq, in interiorul pragului de volum."""
ordered = sorted(corpus, key=lambda k: (-corpus[k]["freq"], k))
if etichetare_all:
in_prag = ordered
else:
total = sum(c["freq"] for c in corpus.values()) or 1
in_prag = []
cum = 0
for k in ordered:
in_prag.append(k)
cum += corpus[k]["freq"]
if cum / total >= target_volum:
break
return [k for k in in_prag if k not in harta]
def genereaza(
csv_paths: list[str],
*,
labels_path: str | None = DEFAULT_LABELS,
seed_path: str = DEFAULT_SEED,
target_volum: float = 0.9,
etichetare_all: bool = False,
clasifica=None,
batch: int = 32,
confidence: float = DEFAULT_CONFIDENCE,
source: str = DEFAULT_SOURCE,
progres=None,
checkpoint_every: int = 1,
pauza: float = 0.0,
) -> dict:
"""Genereaza/actualizeaza seedul. Intoarce statistici. Scrie `seed_path`.
`clasifica(batch_denumiri) -> list[cod]` e injectabil (teste); default = LM Studio.
`progres(mesaj)` e un callback optional de logare.
Checkpointing (`checkpoint_every` batch-uri): seedul se scrie pe disc periodic in
timpul rularii, NU doar la final. Esential pe GPU-box-ul instabil (shutdown termic
sub sarcina, memorie lmstudio-gpu-etichetare): un crash la batch-ul 80/104 pastreaza
progresul, iar re-run-ul continua din cache (idempotenta cross-run). 0 = doar la final.
"""
freq_raw = _freq_raw(csv_paths)
corpus = _corpus_din_freq(freq_raw)
seed_existent = _incarca_seed(seed_path)
harta = construieste_harta_etichete(freq_raw, corpus, labels_path, seed_existent)
de_etichetat = selecteaza_de_etichetat(
corpus, harta, target_volum=target_volum, etichetare_all=etichetare_all
)
reused = len(harta)
brute = int(sum(freq_raw.values()))
if progres:
progres(f"{len(freq_raw)} randuri brute distincte -> {len(corpus)} dupa normalizare "
f"-> {len(de_etichetat)} trimise la LLM (deja: {len(harta)})")
clasif = clasifica
if clasif is None:
et = _load_eticheteaza()
backend = et.get_backend()
if progres:
progres(f"backend={backend.name} url={backend.url} model={backend.model}")
def clasif(batch_denumiri):
return et.call(batch_denumiri, backend)[0]
apeluri = 0
valide = _valid_labels()
nr_batch = (len(de_etichetat) + batch - 1) // batch
for k in range(0, len(de_etichetat), batch):
chunk = de_etichetat[k:k + batch]
denumiri = [corpus[c]["denumire"] for c in chunk]
codes = clasif(denumiri)
apeluri += 1
for cheie, cod in zip(chunk, codes):
cod = str(cod or "").strip().upper()
if cod in valide: # '?' / cod invalid -> ramane ne-etichetat (retry la urmatorul run)
harta[cheie] = cod
if progres:
progres(f" batch {apeluri}/{nr_batch} "
f"-> total etichetat {sum(1 for c in harta if c in corpus)}")
# Checkpoint periodic: protejeaza progresul pe GPU-box instabil.
if checkpoint_every and apeluri % checkpoint_every == 0:
_scrie_seed(seed_path, _construieste_seed(corpus, harta, confidence=confidence, source=source))
# Pauza intre batch-uri: ragaz termic pentru GPU-box (shutdown sub sarcina sustinuta).
if pauza and k + batch < len(de_etichetat):
import time as _t
_t.sleep(pauza)
seed = _construieste_seed(corpus, harta, confidence=confidence, source=source)
_scrie_seed(seed_path, seed)
return {
"brute": brute,
"distincte": len(corpus),
"deja_etichetate": reused,
"de_etichetat": len(de_etichetat),
"apeluri_llm": apeluri,
"seed": len(seed),
}
def _valid_labels() -> set[str]:
et = _load_eticheteaza()
return set(et.ALL_LABELS)
def _construieste_seed(corpus, harta, *, confidence, source) -> list[dict]:
"""Seed ordonat determinist (pe cheie) -> byte-stabil intre rulari."""
out = []
for cheie in sorted(harta):
if cheie not in corpus:
continue # eticheta fara corespondent in corpusul curent
eticheta = harta[cheie]
is_nul = eticheta == NUL_LABEL
out.append({
"denumire": corpus[cheie]["denumire"],
"denumire_normalizata": cheie,
"cod": None if is_nul else eticheta,
"is_nul": is_nul,
"source": source,
"confidence": confidence,
})
return out
def _scrie_seed(seed_path: str, seed: list[dict]) -> None:
os.makedirs(os.path.dirname(os.path.abspath(seed_path)), exist_ok=True)
with open(seed_path, "w", encoding="utf-8") as fh:
json.dump(seed, fh, ensure_ascii=False, indent=2)
fh.write("\n")
# --------------------------------------------------------------------------- #
# CLI #
# --------------------------------------------------------------------------- #
def main(argv=None):
ap = argparse.ArgumentParser(description="Genereaza seed etichetat operatie->cod (LM Studio).")
ap.add_argument("--target-volum", type=float, default=0.9,
help="prag de acoperire pe volum (default 0.9 = D1)")
ap.add_argument("--all", action="store_true", help="eticheteaza tot corpusul, ignora pragul")
ap.add_argument("--batch", type=int, default=32, help="dimensiune batch (conservator: 32-40)")
ap.add_argument("--pauza", type=float, default=1.5,
help="secunde de pauza intre batch-uri (ragaz termic GPU); 0 = fara")
ap.add_argument("--checkpoint-every", type=int, default=1,
help="scrie seedul la fiecare N batch-uri (1 = dupa fiecare, crash-safe)")
ap.add_argument("--confidence", type=float, default=DEFAULT_CONFIDENCE)
ap.add_argument("--csv-glob", default=DEFAULT_CSV_GLOB)
ap.add_argument("--labels", default=DEFAULT_LABELS)
ap.add_argument("--seed", default=DEFAULT_SEED)
args = ap.parse_args(argv)
csv_paths = sorted(glob.glob(args.csv_glob))
if not csv_paths:
ap.error(f"niciun CSV gasit la {args.csv_glob}")
stats = genereaza(
csv_paths,
labels_path=args.labels,
seed_path=args.seed,
target_volum=args.target_volum,
etichetare_all=args.all,
batch=args.batch,
pauza=args.pauza,
checkpoint_every=args.checkpoint_every,
confidence=args.confidence,
progres=lambda m: print(m, flush=True),
)
print("GATA:", json.dumps(stats, ensure_ascii=False))
if __name__ == "__main__":
main()