feat(mapare-llm): pivot PRD 5.14 + tooling etichetare OpenRouter

PRD 5.14 rescris cu pivotul arhitectural: LLM doar etichetator OFFLINE,
runtime = clasificator local fara API (fuzzy + embeddings), baza de
cunostinte GOLD partajata cross-account (validarea unui service ajuta
toate). Decizia 8 (corpus per-cont) SUPERSEDED.

Tooling nou OpenRouter (free, familia NVIDIA Nemotron): or_common.py
(client + corpus pe frecventa, cheie din .env) + or_modeltest.py
(comparatie modele, acord ensemble vs Groq). Masurat: super-120b +
nano-9b fiabile, 3/3 unanim pe 87% volum; ultra-550b aruncat.

Corpus real (4 CSV service, coloana NR=frecventa) + etichete Groq
bootstrap incluse ca date de masurare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-28 14:10:10 +00:00
parent 4caf055c53
commit 9031f81908
13 changed files with 21817 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import json, urllib.request, math, time, csv, glob, random, re, unicodedata
IP="10.0.20.161"; MODEL="nomic-embed-text"; N=140
random.seed(42)
def embed(t):
req=urllib.request.Request(f"http://{IP}:11434/api/embeddings",
data=json.dumps({"model":MODEL,"prompt":t,"keep_alive":"30m"}).encode(),
headers={"Content-Type":"application/json"})
with urllib.request.urlopen(req,timeout=120) as r: return json.load(r)["embedding"]
def cos(a,b): return sum(x*y for x,y in zip(a,b))/(math.sqrt(sum(x*x for x in a))*math.sqrt(sum(y*y for y in b)))
ANCORE={
"OE-1":"REPARATIE: inlocuit sau reparat piesa defecta: placute frana, kit ambreaj, kit distributie, amortizoare, rulment, toba esapament, alternator, pompa, radiator, demontat montat piesa, vopsit usa aripa, reparat caroserie, vulcanizare, sudura",
"OE-2":"INTRETINERE curenta: aerisit frane, gresat, curatat, verificat si completat nivele lichide, intretinere periodica minora",
"OE-3":"REVIZIE PERIODICA la kilometri: schimb ulei motor si filtru ulei, filtru aer polen combustibil, revizie 15000 30000 60000 km",
"OE-4":"REGLARE FUNCTIONALA: reglaj faruri, geometrie directie, supape, calibrare senzori, adaptare actuator, fara inlocuire piese",
"OE-5":"MODIFICARE CONSTRUCTIVA: montare carlig remorcare, GPL, transformare omologata",
"OE-6":"RECONSTRUCTIE vehicul avariat sever dupa dauna totala",
"OE-7":"ACTUALIZARE SOFTWARE: update software calculator, programare ECU, codare module, flash",
"OE-8":"INLOCUIRE SEZONIERA A ANVELOPELOR: montat anvelope iarna vara, schimb sezonier cauciucuri",
"OE-D":"AVARIE GRAVA sistem directie in urma unui accident",
"OE-F":"AVARIE GRAVA sistem franare in urma unui accident",
"OE-C":"AVARIE GRAVA structura caroserie in urma unui accident",
"OE-S":"AVARIE GRAVA structura sasiu in urma unui accident",
"OE-R":"AVARIE GRAVA sistem retinere airbag centuri in urma unui accident",
"OE-A":"AVARIE GRAVA sistem ADAS asistenta condus in urma unui accident",
"OE-I":"ISTORIC INDICATIE ODOMETRU vehicule inmatriculate anterior in alte tari",
"AITLV":"INREGISTRARE ATELIER inspectie tahografe limitatoare viteza",
"R-ODO":"REPARATIE ODOMETRU kilometraj ceas bord",
"I-ODO":"INLOCUIRE ODOMETRU kilometraj ceas bord"}
# heuristica de referinta (doar pe cazuri clare) pentru un semnal de acord
def norm(s): return ''.join(c for c in unicodedata.normalize('NFD',s.upper()) if unicodedata.category(c)!='Mn')
def heur(op):
s=norm(op)
if re.search(r'\bITP\b|ACHITAT|CONF\.|FACTUR|MANOPERA|DEPLAS|^[A-Z]{1,2} ?\d{2,3} ?[A-Z]{3}$',s): return "NUL"
if 'ANVELOP' in s or 'CAUCIUC' in s or 'JANT' in s: return "OE-8"
if 'SOFTWARE' in s or 'CODARE' in s or 'PROGRAMARE' in s or 'UPDATE' in s: return "OE-7"
if 'REVIZIE' in s or ('ULEI' in s and 'MOTOR' in s) or 'FILTRU ULEI' in s: return "OE-3"
if 'REGLAJ' in s or 'REGLARE' in s or 'GEOMETRIE' in s or 'CALIBRARE' in s: return "OE-4"
if 'AERISIT' in s or 'GRESAT' in s or 'CURATAT' in s: return "OE-2"
if s.startswith('INLOCUIT') or s.startswith('INLOC') or 'REPARAT' in s or s.startswith('D/R') or 'VOPSIT' in s or 'SCHIMBAT' in s or 'MONTAT' in s:
return "OE-1"
return None # necunoscut -> nu evaluam
# incarca toate operatiile distincte
ops=set()
for f in sorted(glob.glob("/workspace/autopass/docs/operatii-service/*.csv")):
for r in list(csv.reader(open(f,encoding="utf-8",errors="replace"),delimiter=";"))[1:]:
if len(r)>1 and r[1].strip(): ops.add(r[1].strip())
ops=sorted(ops); sample=random.sample(ops,N)
print(f"{len(ops)} operatii distincte; esantion random {N}\n",flush=True)
t0=time.time()
anc=[(c,embed(d)) for c,d in ANCORE.items()]
print(f"ancore embed: {time.time()-t0:.0f}s",flush=True)
THR=0.55 # prag de incredere
res=[]
for op in sample:
e=embed(op)
rang=sorted(((cos(e,v),c) for c,v in anc),reverse=True)
sim,cod=rang[0]; sim2=rang[1][0]
res.append((op,cod,sim,sim-sim2))
# statistici
from collections import Counter
dist=Counter(c for _,c,_,_ in res)
acc=sum(1 for _,_,s,_ in res if s>=THR)
amb=sum(1 for _,_,_,m in res if m<0.03)
# acord cu heuristica pe subsetul clar
ev=[(op,cod) for (op,cod,_,_) in res if heur(op) is not None]
agree=sum(1 for op,cod in ev if heur(op)==cod)
print(f"\n--- {N} operatii in {time.time()-t0:.0f}s ({(time.time()-t0)/N:.1f}s/op) ---")
print("Distributie coduri:", dict(dist.most_common()))
print(f"Peste prag {THR}: {acc}/{N} ({100*acc//N}%) | Ambigue(marja<0.03): {amb}")
print(f"Acord cu heuristica pe {len(ev)} cazuri clare: {agree}/{len(ev)} ({100*agree//max(len(ev),1)}%)")
print("\nDEZACORDURI fata de heuristica (de inspectat):")
for op,cod in ev:
if heur(op)!=cod: print(f" {op:<40} embed={cod:<6} heur={heur(op)}")
print("\nESANTION (primele 45):")
for op,cod,sim,m in res[:45]:
flag="LOW" if sim<THR else ("AMB" if m<0.03 else "")
print(f" {op:<42}{cod:<6}{sim:.3f} {flag}")

View File

@@ -0,0 +1,81 @@
"""Evalueaza un clasificator DETERMINIST (fara AI la runtime) construit din
etichetele Groq. Split 90/10: 'antrenam' pe 90% (lookup exact + fuzzy NN +
Naive Bayes pe tokeni), testam pe 10% nevazute. Masuram acoperire + acuratete
per strat si global, fata de etichetele Groq (referinta)."""
import json, re, unicodedata, random, math, time, os
from collections import defaultdict, Counter
from rapidfuzz import process, fuzz
OUT="/tmp/claude-1000/-workspace-autopass/4177677c-7995-4fab-bbd5-16735cb335e3/scratchpad/labels.json"
random.seed(7)
def norm(s):
s=''.join(c for c in unicodedata.normalize('NFD',s.upper()) if unicodedata.category(c)!='Mn')
s=re.sub(r'[^A-Z0-9/ ]',' ',s); s=re.sub(r'\s+',' ',s).strip()
return s
def toks(s): return [t for t in norm(s).split() if len(t)>1]
labels=json.load(open(OUT))
items=list(labels.items())
random.shuffle(items)
cut=int(len(items)*0.9)
train=items[:cut]; test=items[cut:]
print(f"{len(items)} etichetate | train {len(train)} | test {len(test)}")
# --- strat 1: lookup exact normalizat ---
exact={}
for op,c in train: exact[norm(op)]=c # ultima castiga (rar conflicte)
# --- strat 2: fuzzy NN (rapidfuzz) ---
train_norm=[norm(op) for op,_ in train]
train_code=[c for _,c in train]
norm2code={}
for n,c in zip(train_norm,train_code): norm2code.setdefault(n,c)
choices=list(norm2code.keys())
FUZZ_THR=88
# --- strat 3: Naive Bayes pe tokeni (invatat din etichete) ---
classes=Counter(c for _,c in train)
prior={c:math.log(n/len(train)) for c,n in classes.items()}
tok_cnt=defaultdict(lambda: defaultdict(int)); tok_tot=defaultdict(int)
vocab=set()
for op,c in train:
for t in toks(op): tok_cnt[c][t]+=1; tok_tot[c]+=1; vocab.add(t)
V=len(vocab)
def nb(op):
best=None; bests=-1e18
for c in classes:
s=prior[c]
for t in toks(op):
s+=math.log((tok_cnt[c][t]+1)/(tok_tot[c]+V))
if s>bests: bests=s; best=c
return best
MAJ=classes.most_common(1)[0][0]
def predict(op):
n=norm(op)
if n in exact: return exact[n],"exact"
m=process.extractOne(n,choices,scorer=fuzz.WRatio)
if m and m[1]>=FUZZ_THR: return norm2code[m[0]],"fuzzy"
if toks(op): return nb(op),"nb"
return MAJ,"default"
t0=time.time()
layer=Counter(); ok=Counter(); tot=Counter()
mis=[]
for op,truth in test:
pred,lyr=predict(op)
layer[lyr]+=1; tot[lyr]+=1
if pred==truth: ok[lyr]+=1
elif len(mis)<25: mis.append((op,pred,truth,lyr))
dt=time.time()-t0
TOTAL=len(test); OKALL=sum(ok.values())
print(f"\nPredictie {TOTAL} cazuri in {dt:.2f}s ({1000*dt/TOTAL:.2f} ms/op) - FARA AI")
print(f"\nACURATETE GLOBALA (vs Groq): {OKALL}/{TOTAL} = {100*OKALL/TOTAL:.1f}%")
print(f"\n{'strat':<8}{'aparitii':>9}{'corecte':>9}{'acuratete':>11}{'acoperire':>11}")
for lyr in ["exact","fuzzy","nb","default"]:
if tot[lyr]:
print(f"{lyr:<8}{tot[lyr]:>9}{ok[lyr]:>9}{100*ok[lyr]/tot[lyr]:>10.1f}%{100*tot[lyr]/TOTAL:>10.1f}%")
print("\nExemple gresite (pred != Groq):")
for op,p,t,l in mis[:20]:
print(f" [{l}] {op:<42} pred={p:<6} groq={t}")

View File

@@ -0,0 +1,86 @@
import json, urllib.request, urllib.error, time, os, csv, glob, random, re
from collections import Counter
KEY=os.environ["GROQ_KEY"]; N=150; BATCH=30
MODELS=["llama-3.3-70b-versatile","openai/gpt-oss-120b","qwen/qwen3-32b"]
OUT="/tmp/claude-1000/-workspace-autopass/4177677c-7995-4fab-bbd5-16735cb335e3/scratchpad/f7_result.json"
random.seed(123)
CODURI=("OE-1=REPARATIE, OE-2=INTRETINERE, OE-3=REVIZIE PERIODICA, OE-4=REGLARE FUNCTIONALA, "
"OE-5=MODIFICARE CONSTRUCTIVA, OE-6=RECONSTRUCTIE, OE-7=ACTUALIZARE SOFTWARE, "
"OE-8=INLOCUIRE SEZONIERA ANVELOPE, OE-D=AVARIE GRAVA DIRECTIE, OE-F=AVARIE GRAVA FRANARE, "
"OE-C=AVARIE GRAVA CAROSERIE, OE-S=AVARIE GRAVA SASIU, OE-R=AVARIE GRAVA RETINERE/AIRBAG, "
"OE-A=AVARIE GRAVA ADAS, OE-I=ISTORIC ODOMETRU, AITLV=ATELIER TAHOGRAFE, "
"R-ODO=REPARATIE ODOMETRU, I-ODO=INLOCUIRE ODOMETRU, NUL=NU e operatie de service")
SYS=("Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul din coduri:\n"+CODURI+
"\nReguli: AVARIILE GRAVE (OE-D/F/C/S/R/A) DOAR pentru daune in urma unui accident, NU reparatii curente. "
"Vopsire/revopsire/retus = REPARATIE (OE-1). Inlocuire/D-R/reparare piese = REPARATIE (OE-1). "
"Schimb ulei motor + filtre = REVIZIE (OE-3). Aerisit/gresat/completat nivele = INTRETINERE (OE-2). "
"Text care nu e operatie efectiva (ITP, plata, discount, manopera generica, nr inmatriculare, doar nume piesa) -> NUL. "
"Raspunde DOAR JSON {\"rez\":[{\"i\":<numar>,\"cod\":\"...\"}]}.")
# F3: scrub PII (nr inmatriculare) inainte de trimitere
PLATE=re.compile(r'\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b')
VIN=re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b')
def scrub(s): return VIN.sub('[VIN]',PLATE.sub('[NR]',s))
def call(model,batch):
msgs=[{"role":"system","content":SYS},
{"role":"user","content":"\n".join(f"{i+1}. {scrub(o)}" for i,o in enumerate(batch))}]
body={"model":model,"messages":msgs,"temperature":0,"response_format":{"type":"json_object"}}
data=json.dumps(body).encode()
for attempt in range(8):
req=urllib.request.Request("https://api.groq.com/openai/v1/chat/completions",
data=data,headers={"Authorization":f"Bearer {KEY}","Content-Type":"application/json","User-Agent":"Mozilla/5.0"})
try:
with urllib.request.urlopen(req,timeout=180) as r: d=json.load(r)
out=json.loads(d["choices"][0]["message"]["content"])["rez"]
m={x["i"]:x["cod"] for x in out}
return [m.get(i+1,"?") for i in range(len(batch))]
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
raise
except Exception:
time.sleep(min(2**attempt,20)); continue
return ["?"]*len(batch)
# corpus + esantion random
ops=set()
for f in sorted(glob.glob("/workspace/autopass/docs/operatii-service/*.csv")):
for r in list(csv.reader(open(f,encoding="utf-8",errors="replace"),delimiter=";"))[1:]:
if len(r)>1 and r[1].strip(): ops.add(r[1].strip())
sample=random.sample(sorted(ops),N)
print(f"esantion random {N} din {len(ops)} distincte",flush=True)
votes={m:[] for m in MODELS}
t0=time.time()
for m in MODELS:
res=[]
nb=(N+BATCH-1)//BATCH
for bi,k in enumerate(range(0,N,BATCH)):
res+=call(m,sample[k:k+BATCH])
print(f" {m} batch {bi+1}/{nb} ({time.time()-t0:.0f}s)",flush=True)
time.sleep(6) # pacing sub TPM 12000
votes[m]=res
print(f" {m}: GATA ({time.time()-t0:.0f}s)",flush=True)
rows=[]
for i,op in enumerate(sample):
vs=[votes[m][i] for m in MODELS]
c=Counter(vs); top,cnt=c.most_common(1)[0]
level=3 if cnt==3 else (2 if cnt==2 else 1)
rows.append({"op":op,"votes":vs,"cod":top,"agree":level})
json.dump(rows,open(OUT,"w"),ensure_ascii=False,indent=1)
a3=[r for r in rows if r["agree"]==3]; a2=[r for r in rows if r["agree"]==2]; a1=[r for r in rows if r["agree"]==1]
print(f"\n=== F7 ENSEMBLE ({N} ops, {time.time()-t0:.0f}s) ===")
print(f"ACORD 3/3 (candidat auto-send): {len(a3)} ({100*len(a3)//N}%)")
print(f"ACORD 2/3: {len(a2)} ({100*len(a2)//N}%)")
print(f"DEZACORD total (1+1+1): {len(a1)} ({100*len(a1)//N}%)")
n3=sum(1 for r in a3 if r['cod']=='NUL')
print(f" din 3/3: {n3} sunt NUL (gunoi), {len(a3)-n3} coduri reale")
print(f"\nrezultat complet salvat: {OUT}")
print("\n--- ACORD 2/3 si DEZACORD (astea ar merge la needs_mapping) ---")
for r in a2+a1:
print(f" {r['op']:<42} {r['cod']:<6} voturi={r['votes']}")

View File

@@ -0,0 +1,61 @@
import json, urllib.request, urllib.error, time, os, csv, glob, re
from collections import Counter
KEY=os.environ["GROQ_KEY"]; MODEL="llama-3.3-70b-versatile"; BATCH=40
OUT="/tmp/claude-1000/-workspace-autopass/4177677c-7995-4fab-bbd5-16735cb335e3/scratchpad/labels.json"
CODURI=("OE-1=REPARATIE, OE-2=INTRETINERE, OE-3=REVIZIE PERIODICA, OE-4=REGLARE FUNCTIONALA, "
"OE-5=MODIFICARE CONSTRUCTIVA, OE-6=RECONSTRUCTIE, OE-7=ACTUALIZARE SOFTWARE, "
"OE-8=INLOCUIRE SEZONIERA ANVELOPE, OE-D=AVARIE GRAVA DIRECTIE, OE-F=AVARIE GRAVA FRANARE, "
"OE-C=AVARIE GRAVA CAROSERIE, OE-S=AVARIE GRAVA SASIU, OE-R=AVARIE GRAVA RETINERE/AIRBAG, "
"OE-A=AVARIE GRAVA ADAS, OE-I=ISTORIC ODOMETRU, AITLV=ATELIER TAHOGRAFE, "
"R-ODO=REPARATIE ODOMETRU, I-ODO=INLOCUIRE ODOMETRU, NUL=NU e operatie de service")
SYS=("Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul din coduri:\n"+CODURI+
"\nReguli: AVARIILE GRAVE DOAR pentru daune in urma unui accident, NU reparatii curente. "
"Vopsire/revopsire/retus = REPARATIE (OE-1). Inlocuire/D-R/reparare piese = REPARATIE (OE-1). "
"Schimb ulei motor + filtre = REVIZIE (OE-3). Aerisit/gresat/completat nivele = INTRETINERE (OE-2). "
"Text care nu e operatie efectiva (ITP, plata, discount, manopera generica, nr inmatriculare, doar nume piesa) -> NUL. "
"Raspunde DOAR JSON {\"rez\":[{\"i\":<numar>,\"cod\":\"...\"}]}.")
PLATE=re.compile(r'\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b'); VIN=re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b')
def scrub(s): return VIN.sub('[VIN]',PLATE.sub('[NR]',s))
def classify(batch):
msgs=[{"role":"system","content":SYS},{"role":"user","content":"\n".join(f"{i+1}. {scrub(o)}" for i,o in enumerate(batch))}]
body={"model":MODEL,"messages":msgs,"temperature":0,"response_format":{"type":"json_object"}}
data=json.dumps(body).encode()
for attempt in range(8):
req=urllib.request.Request("https://api.groq.com/openai/v1/chat/completions",data=data,
headers={"Authorization":f"Bearer {KEY}","Content-Type":"application/json","User-Agent":"Mozilla/5.0"})
try:
with urllib.request.urlopen(req,timeout=180) as r: d=json.load(r)
m={x["i"]:x["cod"] for x in json.loads(d["choices"][0]["message"]["content"])["rez"]}
return [m.get(i+1,"?") for i in range(len(batch))]
except urllib.error.HTTPError as e:
if e.code in (429,500,502,503):
time.sleep(float(e.headers.get("retry-after",0)) or min(2**attempt,30)); continue
raise
except Exception:
time.sleep(min(2**attempt,20)); continue
return ["?"]*len(batch)
# presence in CSV-uri
files=sorted(glob.glob("/workspace/autopass/docs/operatii-service/*.csv"))
presence=Counter()
for f in files:
seen=set()
for r in list(csv.reader(open(f,encoding="utf-8",errors="replace"),delimiter=";"))[1:]:
if len(r)>1 and r[1].strip(): seen.add(r[1].strip())
for op in seen: presence[op]+=1
labels=json.load(open(OUT))
todo=sorted([op for op,c in presence.items() if c>=2 and op not in labels])
print(f"comune (>=2 service) de etichetat: {len(todo)} (peste {len(labels)} deja)",flush=True)
t0=time.time(); nb=(len(todo)+BATCH-1)//BATCH
for bi,k in enumerate(range(0,len(todo),BATCH)):
b=todo[k:k+BATCH]
for o,c in zip(b,classify(b)): labels[o]=c
json.dump(labels,open(OUT,"w"),ensure_ascii=False)
print(f" batch {bi+1}/{nb} -> total {len(labels)} ({time.time()-t0:.0f}s)",flush=True)
time.sleep(4)
print(f"GATA comune: {len(labels)} etichete totale ({time.time()-t0:.0f}s)",flush=True)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
"""Comun pentru etichetarea operatii->coduri RAR prin OpenRouter (modele FREE).
Difera de tooling-ul Groq (label_common.py / f7_ensemble.py) prin:
- endpoint OpenRouter (OpenAI-compatibil), cheie din .env (nu env exportat);
- corpus ordonat pe FRECVENTA (suma NR per denumire distincta), nu alfabetic.
Refoloseste IDENTIC: cele 18 coduri RAR, promptul de sistem si scrub-ul PII (F3).
"""
import json, urllib.request, urllib.error, time, os, csv, glob, re
from collections import Counter
# --- cheie din .env (gitignored). Tool-urile Groq citeau os.environ; aici din fisier,
# fiindca shell-ul non-interactiv nu pastreaza export-urile intre apeluri. ---
def _load_key():
if os.environ.get("OPENROUTER_KEY"):
return os.environ["OPENROUTER_KEY"]
env = os.path.join(os.path.dirname(__file__), "..", "..", ".env")
for line in open(env, encoding="utf-8", errors="replace"):
line = line.strip()
if line.startswith("OPENROUTER_KEY="):
return line.split("=", 1)[1].strip()
raise RuntimeError("OPENROUTER_KEY lipseste din .env si din mediu")
KEY = _load_key()
URL = "https://openrouter.ai/api/v1/chat/completions"
# --- nomenclator + prompt: identic cu label_common.py / f7_ensemble.py (sursa de adevar) ---
CODURI = ("OE-1=REPARATIE, OE-2=INTRETINERE, OE-3=REVIZIE PERIODICA, OE-4=REGLARE FUNCTIONALA, "
"OE-5=MODIFICARE CONSTRUCTIVA, OE-6=RECONSTRUCTIE, OE-7=ACTUALIZARE SOFTWARE, "
"OE-8=INLOCUIRE SEZONIERA ANVELOPE, OE-D=AVARIE GRAVA DIRECTIE, OE-F=AVARIE GRAVA FRANARE, "
"OE-C=AVARIE GRAVA CAROSERIE, OE-S=AVARIE GRAVA SASIU, OE-R=AVARIE GRAVA RETINERE/AIRBAG, "
"OE-A=AVARIE GRAVA ADAS, OE-I=ISTORIC ODOMETRU, AITLV=ATELIER TAHOGRAFE, "
"R-ODO=REPARATIE ODOMETRU, I-ODO=INLOCUIRE ODOMETRU, NUL=NU e operatie de service")
SYS = ("Esti expert RAR AUTOPASS. Clasifici fiecare operatie de service-auto in EXACT unul din coduri:\n" + CODURI +
"\nReguli: AVARIILE GRAVE (OE-D/F/C/S/R/A) DOAR pentru daune in urma unui accident, NU reparatii curente. "
"Vopsire/revopsire/retus = REPARATIE (OE-1). Inlocuire/D-R/reparare piese = REPARATIE (OE-1). "
"Schimb ulei motor + filtre = REVIZIE (OE-3). Aerisit/gresat/completat nivele = INTRETINERE (OE-2). "
"Text care nu e operatie efectiva (ITP, plata, discount, manopera generica, nr inmatriculare, doar nume piesa) -> NUL. "
"Raspunde DOAR JSON {\"rez\":[{\"i\":<numar>,\"cod\":\"...\"}]}.")
# --- F3: scrub PII inainte de a trimite la LLM ---
PLATE = re.compile(r'\b[A-Z]{1,2}\s?\d{2,3}\s?[A-Z]{3}\b')
VIN = re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b')
def scrub(s): return VIN.sub('[VIN]', PLATE.sub('[NR]', s))
VALID = {c.split("=")[0] for c in CODURI.replace(", ", ",").split(",")}
def call(model, batch, timeout=180, max_attempts=6):
"""Un apel OpenRouter pe un batch. Intoarce (coduri, meta) unde meta are latenta si erori.
coduri: lista paralela cu batch; "?" pe pozitiile fara raspuns / parse-fail.
"""
msgs = [{"role": "system", "content": SYS},
{"role": "user", "content": "\n".join(f"{i+1}. {scrub(o)}" for i, o in enumerate(batch))}]
body = {"model": model, "messages": msgs, "temperature": 0,
"response_format": {"type": "json_object"}}
data = json.dumps(body).encode()
t0 = time.time()
for attempt in range(max_attempts):
req = urllib.request.Request(URL, data=data, headers={
"Authorization": f"Bearer {KEY}", "Content-Type": "application/json",
"User-Agent": "Mozilla/5.0", # WAF: Python-urllib -> 403
"HTTP-Referer": "https://gitea.romfast.ro/romfast/autopass",
"X-Title": "autopass-mapare-llm"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.load(r)
content = d["choices"][0]["message"]["content"]
out = json.loads(content)["rez"]
m = {x["i"]: x["cod"] for x in out}
codes = [m.get(i + 1, "?") for i in range(len(batch))]
return codes, {"ms": int((time.time() - t0) * 1000), "err": None}
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}"}
except Exception as e:
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__}
return ["?"] * len(batch), {"ms": int((time.time() - t0) * 1000), "err": "max_attempts"}
def corpus_by_freq():
"""Toate denumirile distincte, cu frecventa = suma NR pe toate CSV-urile, desc.
Intoarce lista de (denop, nr_total). NR = de cate ori apare denumirea in prezentari.
"""
freq = Counter()
for f in sorted(glob.glob(os.path.join(os.path.dirname(__file__), "..", "..",
"docs", "operatii-service", "*.csv"))):
for r in list(csv.reader(open(f, encoding="utf-8", errors="replace"), delimiter=";"))[1:]:
if len(r) > 2 and r[1].strip():
try:
freq[r[1].strip()] += int(r[2].strip() or 0)
except ValueError:
freq[r[1].strip()] += 0
return freq.most_common()
if __name__ == "__main__":
c = corpus_by_freq()
tot = sum(n for _, n in c)
print(f"distincte: {len(c)} volum total (suma NR): {tot}")
for cut in (100, 500, 1000):
cov = sum(n for _, n in c[:cut])
print(f" top {cut}: {100*cov/tot:.1f}% din volum")
print("--- top 15 dupa frecventa ---")
for op, n in c[:15]:
print(f" {n:>6} {op}")

View File

@@ -0,0 +1,137 @@
"""Compara modele FREE OpenRouter la clasificarea operatii->coduri RAR (RO).
Ruleaza fiecare model candidat pe top-N denumiri DUPA FRECVENTA (cele care conteaza
la volum), si raporteaza per model:
- latenta (ms/batch), rata de eroare/parse-fail (cate "?"),
- cate NUL detecteaza (gunoi), distributia codurilor,
- acord cu etichetele Groq existente (labels-groq-partial.json) ca referinta silver,
- acord pereche intre modele + vot majoritar (candidat treapta auto-send).
Salveaza voturile brute in modeltest-result.json pentru adjudicare de catre om.
Rulare: python3 tools/mapare-llm/or_modeltest.py [N] [model1 model2 ...]
N = cate denumiri (top dupa frecventa). Default 120.
modelN = override lista de modele. Default = set curat din modele free live.
"""
import sys, os, json, time
from collections import Counter
sys.path.insert(0, os.path.dirname(__file__))
import or_common as oc
HERE = os.path.dirname(__file__)
GROQ_LABELS = os.path.join(HERE, "labels-groq-partial.json")
OUT = os.path.join(HERE, "modeltest-result.json")
BATCH = 40 # batch mare = mai putine cereri (cap free tier ~50/zi fara credit)
PACE = 4.0 # sec intre batch-uri (free tier OpenRouter ~20 req/min)
# Set FIABIL pe free tier (probat live 2026-06-28): doar familia NVIDIA Nemotron
# routeaza fara 429/404. llama/qwen/gemma/gpt-oss/hermes = rate-limited sau provider
# blocat. CAVEAT F7: aceeasi familie -> acordul supraestimeaza increderea; scale
# diferite (9B/120B/550B) dau totusi divergenta pe cazuri grele.
DEFAULT_MODELS = [
"nvidia/nemotron-3-super-120b-a12b:free", # 120B, rapid (~3s)
"nvidia/nemotron-nano-9b-v2:free", # 9B, scala mica
"nvidia/nemotron-3-ultra-550b-a55b:free", # 550B, lent (~36s) dar capabil
]
def run_model(model, sample):
codes, total_ms, errs = [], 0, []
nb = (len(sample) + BATCH - 1) // BATCH
for bi, k in enumerate(range(0, len(sample), BATCH)):
batch = sample[k:k + BATCH]
c, meta = oc.call(model, batch)
codes += c
total_ms += meta["ms"]
if meta["err"]:
errs.append(meta["err"])
print(f" {model:<45} batch {bi+1}/{nb} {meta['ms']}ms err={meta['err']}", flush=True)
time.sleep(PACE)
return codes, total_ms, errs
def main():
args = sys.argv[1:]
n = 120
models = DEFAULT_MODELS
if args and args[0].isdigit():
n = int(args[0]); args = args[1:]
if args:
models = args
corpus = oc.corpus_by_freq()
sample = [op for op, _ in corpus[:n]]
freq = {op: nr for op, nr in corpus[:n]}
vol_total = sum(freq.values())
print(f"esantion: top {n} dupa frecventa = {vol_total} volum "
f"({100*vol_total/sum(nr for _,nr in corpus):.1f}% din total)\n", flush=True)
groq = {}
if os.path.exists(GROQ_LABELS):
groq = json.load(open(GROQ_LABELS, encoding="utf-8"))
results = {}
t0 = time.time()
for m in models:
print(f"=== {m} ===", flush=True)
codes, total_ms, errs = run_model(m, sample)
results[m] = {"codes": codes, "total_ms": total_ms, "errs": errs}
print(f" -> {total_ms/1000:.0f}s total\n", flush=True)
# vot majoritar + nivel de acord, ponderat pe frecventa
rows = []
for i, op in enumerate(sample):
votes = {m: results[m]["codes"][i] for m in models}
valid = [v for v in votes.values() if v not in ("?",)]
c = Counter(valid)
top, cnt = (c.most_common(1)[0] if c else ("?", 0))
rows.append({"op": op, "nr": freq[op], "votes": votes,
"maj": top, "agree": cnt, "n_models": len(models),
"groq": groq.get(op)})
json.dump({"models": models, "n": n, "rows": rows}, open(OUT, "w"),
ensure_ascii=False, indent=1)
# --- raport per model ---
print("=" * 78)
print(f"RAPORT ({n} ops, {time.time()-t0:.0f}s, ponderare pe frecventa NR)")
print("=" * 78)
print(f"{'model':<46} {'ms/op':>6} {'?fail':>6} {'NUL':>5} {'~Groq':>7}")
for m in models:
codes = results[m]["codes"]
fails = sum(1 for x in codes if x == "?")
nul = sum(1 for x in codes if x == "NUL")
# acord vs Groq pe overlap (ponderat pe frecventa)
ov_w = ov_match = 0
for i, op in enumerate(sample):
g = groq.get(op)
if g and codes[i] != "?":
ov_w += freq[op]
if codes[i] == g:
ov_match += freq[op]
agr = f"{100*ov_match/ov_w:.0f}%" if ov_w else "n/a"
msop = results[m]["total_ms"] / max(1, len(codes))
print(f"{m:<46} {msop:>6.0f} {fails:>6} {nul:>5} {agr:>7}")
# --- acord ensemble ponderat pe frecventa ---
print("\n--- ACORD ENSEMBLE (ponderat pe volum) ---")
nm = len(models)
for lvl in range(nm, 0, -1):
w = sum(r["nr"] for r in rows if r["agree"] == lvl)
cnt = sum(1 for r in rows if r["agree"] == lvl)
tag = " <- candidat auto-send" if lvl == nm else ""
print(f" acord {lvl}/{nm}: {cnt:>4} ops, {100*w/vol_total:.0f}% volum{tag}")
unan = [r for r in rows if r["agree"] == nm]
nul_un = sum(1 for r in unan if r["maj"] == "NUL")
print(f" din unanim {nm}/{nm}: {nul_un} NUL (gunoi), {len(unan)-nul_un} coduri reale")
print(f"\nbrut salvat: {OUT}")
print("--- esantion dezacord (volum mare, de adjudecat de om) ---")
disp = sorted([r for r in rows if r["agree"] < nm], key=lambda r: -r["nr"])[:20]
for r in disp:
vs = "/".join(sorted(set(r["votes"].values())))
print(f" {r['nr']:>5} {r['op']:<40} maj={r['maj']:<6} ({vs})")
if __name__ == "__main__":
main()