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:
85
tools/mapare-llm/bigtest.py
Normal file
85
tools/mapare-llm/bigtest.py
Normal 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}")
|
||||
81
tools/mapare-llm/eval_det.py
Normal file
81
tools/mapare-llm/eval_det.py
Normal 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}")
|
||||
86
tools/mapare-llm/f7_ensemble.py
Normal file
86
tools/mapare-llm/f7_ensemble.py
Normal 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']}")
|
||||
61
tools/mapare-llm/label_common.py
Normal file
61
tools/mapare-llm/label_common.py
Normal 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)
|
||||
1
tools/mapare-llm/labels-groq-partial.json
Normal file
1
tools/mapare-llm/labels-groq-partial.json
Normal file
File diff suppressed because one or more lines are too long
1570
tools/mapare-llm/modeltest-result.json
Normal file
1570
tools/mapare-llm/modeltest-result.json
Normal file
File diff suppressed because it is too large
Load Diff
112
tools/mapare-llm/or_common.py
Normal file
112
tools/mapare-llm/or_common.py
Normal 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}")
|
||||
137
tools/mapare-llm/or_modeltest.py
Normal file
137
tools/mapare-llm/or_modeltest.py
Normal 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()
|
||||
Reference in New Issue
Block a user