diff --git a/README.md b/README.md index 2d8f0b7..b45e19f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,24 @@ pe semnatura coloanelor, per cont) → **preview** (fiecare rand: `ok` / `needs_ Coloane recunoscute (cu sinonime): `VIN`, `Nr inmatriculare`, `Data prestatie`, `Odometru final`, `Odometru initial`, `Operatie`, `Observatii`. Fiecare cont poate avea mai multe formate memorate. +## Mapari si sugestii de cod RAR + +O operatie fara cod RAR valid (din import sau din API) opreste randul in **Lipsa cod** +(`needs_mapping`) — nimic nu pleaca la RAR pana nu confirma un om codul. Sistemul propune +insa singur un cod ("sugestia principala"), calculat pe loc din denumirea operatiei: + +- **Cand:** de fiecare data cand un rand ajunge in `needs_mapping` — sugestia apare imediat + in raspunsul API (`motiv` + `nemapate[].sugestie`), in tab-ul **Mapari** (precompletata + in select) si in `GET /v1/mapari/pending`. +- **Cum (in ordinea increderii):** mapari confirmate de oameni in alte conturi (GOLD partajat) → + clasificari LLM pre-calculate (SILVER) → similaritate semantica (embeddings k-NN) → + potrivire fuzzy pe numele din nomenclatorul RAR. Prima sursa care raspunde castiga; + textele care nu sunt operatii (ITP, taxe, discounturi) nu primesc sugestie. +- **Sugestia NU se aplica singura** — declararea la RAR e definitiva, deci prima asociere + operatie→cod cere confirmarea ta (tab Mapari sau `POST /v1/mapari`). Din acel moment maparea + e memorata: aparitiile viitoare se rezolva automat, iar **toate** trimiterile deja blocate + pe acel cod se deblocheaza pe loc si intra in coada. + ## API v1 (curl) Dev: fara cheie → cont id=1. Productie (`AUTOPASS_REQUIRE_API_KEY=true`): header `X-API-Key: rfak_...`. diff --git a/app/api/v1/router.py b/app/api/v1/router.py index a8be7e1..6109b4d 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -33,16 +33,19 @@ from ...mapping import ( classify_prezentare, load_excluded_ops, load_mapping_meta, + load_nomenclator, load_nomenclator_codes, load_text_rules, pending_unmapped, reresolve_account, save_mapping, + sugestie_pentru_op, ) from ...models import ( PrezentareRequest, PrezentariResponse, SubmissionResult, + SubmissionResultMinim, ValidarePrezentariRequest, ValidareResponse, ValidareResult, @@ -89,6 +92,16 @@ def _erori_nemapate(unmapped: list[dict]) -> list[dict]: ] +def _ataseaza_sugestii(conn, cl: dict, nomenclator: list[dict]) -> None: + """Adauga `sugestie` ({cod_prestatie, nume_prestatie, sursa} | None) pe fiecare + cod nemapat din clasificare. Suggestion-only: informeaza raspunsul API, nu + schimba statusul si nu se aplica automat.""" + for u in cl.get("unmapped") or []: + u["sugestie"] = sugestie_pentru_op( + conn, u.get("denumire") or u.get("cod_op_service"), nomenclator + ) + + # Mesaj uman pentru un rand tinut manual (queued AND held=1). # Reutilizeaza vocabularul existent AUTO_SEND_OPRIT (errors.py) — nu inventam al treilea # vocabular "auto_send". Onestitate: raspunsul NU trebuie sa arate un "queued" curat fals. @@ -117,8 +130,16 @@ def _motiv_clasificare(cl: dict) -> str | None: (e.get("problema") or e.get("message") or "") for e in cl["errors"] ).strip("; ") or "Date incomplete (respinse de RAR)." if cl["unmapped"]: - coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"]) - return f"Coduri fara mapare RAR: {coduri}" + parti = [] + for u in cl["unmapped"]: + op = u.get("cod_op_service") or "" + s = u.get("sugestie") + if s and s.get("cod_prestatie"): + nume = f" {s['nume_prestatie']}" if s.get("nume_prestatie") else "" + parti.append(f"{op} (sugestie: {s['cod_prestatie']}{nume})") + else: + parti.append(op) + return f"Coduri fara mapare RAR: {', '.join(parti)}" return None @@ -185,6 +206,8 @@ def create_prezentari( # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). valid_codes = load_nomenclator_codes(conn) or None + # Nomenclator complet (cod+nume) pentru sugestiile atasate codurilor nemapate. + nomenclator = load_nomenclator(conn) # Reguli text incarcate o data per cerere (seam partajat cu dry-run). text_rules = load_text_rules(conn, acct) excluded_ops = load_excluded_ops(conn, acct) @@ -288,6 +311,7 @@ def create_prezentari( # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. if existing["status"] == "error": cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops) + _ataseaza_sugestii(conn, cl, nomenclator) if cl["blocked_error"]: # on_unmapped_error=True: nu reactivam; randul ramane 'error'. results.append(_rezultat_respins(existing["id"], cl, rar_env=env)) @@ -340,6 +364,7 @@ def create_prezentari( # Helper pur partajat cu dry-run: reproduce EXACT clasificarea # (canonicalize + mapare op->cod + validare; fara gate auto_send). cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops) + _ataseaza_sugestii(conn, cl, nomenclator) if cl["blocked_error"]: # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). results.append(_rezultat_respins(None, cl, rar_env=env)) @@ -356,6 +381,14 @@ def create_prezentari( # pe queued tinut manual (held=1) expune held=true + motiv. results.append(_rezultat_enqueue(sub_id, cl, held=held_val, rar_env=env)) + # Corelare raspuns<->cerere: fiecare prezentare produce EXACT un rezultat, + # in ordine, deci pozitia din lista e si indexul cererii. Ecoul vehiculului + # foloseste valorile normalizate de Pydantic (upper/strip). + for i, (rez, prez) in enumerate(zip(results, req.prezentari)): + rez.index = i + rez.vin = prez.vin + rez.nr_inmatriculare = prez.nr_inmatriculare + # Audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). dist: dict[str, int] = {} @@ -376,7 +409,21 @@ def create_prezentari( ) finally: conn.close() - return PrezentariResponse(results=results) + # Forma implicita = minim (6 chei fixe per rezultat); formatul complet + # (erori/nemapate/deduped/held/...) doar la cerere explicita. + if req.raspuns == "complet": + return PrezentariResponse(results=list(results)) + return PrezentariResponse(results=[ + SubmissionResultMinim( + index=r.index if r.index is not None else 0, + submission_id=r.submission_id, + vehicul=r.nr_inmatriculare or "", + status=r.status, + motiv=r.motiv, + id_prezentare=r.id_prezentare, + ) + for r in results + ]) @router.post("/prezentari/valideaza", response_model=ValidareResponse) @@ -397,6 +444,7 @@ def valideaza_prezentari( mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None + nomenclator = load_nomenclator(conn) # Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text. text_rules = load_text_rules(conn, acct) excluded_ops = load_excluded_ops(conn, acct) @@ -433,6 +481,7 @@ def valideaza_prezentari( for i, prez in enumerate(req.prezentari): content = prez.model_dump() res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules, excluded_ops) + _ataseaza_sugestii(conn, res, nomenclator) if res["blocked_error"]: res = {**res, "status": "error"} # Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT diff --git a/app/mapping.py b/app/mapping.py index ec579ac..4942967 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -981,6 +981,40 @@ def enrich_suggestions( return {"sugestie_principala": sugestie_principala, "surse": surse} +# Prag fuzzy sub care nu propunem nimic (evita sugestii de zgomot in raspunsul API). +FUZZY_SUGESTIE_MIN_SCORE = 60.0 + + +def sugestie_pentru_op(conn, denumire: str | None, nomenclator: list[dict]) -> dict | None: + """Sugestia principala de cod RAR pentru o operatie nemapata. + + Aceeasi precedenta ca editorul de mapari (enrich_suggestions: GOLD partajat > + SILVER > embeddings), cu fallback fuzzy pe nomenclator peste prag. SUGGESTION-ONLY: + nu se aplica automat, doar se afiseaza (raspuns API / editor). + + Returneaza {cod_prestatie, nume_prestatie, sursa} sau None (inclusiv pe + pre-filtrul NUL: non-operatiile nu primesc sugestie de cod). + """ + if not denumire: + return None + nume = {r.get("cod_prestatie"): r.get("nume_prestatie") for r in nomenclator} + enriched = enrich_suggestions(conn, denumire) + sp = enriched.get("sugestie_principala") + if sp and sp.get("cod_prestatie"): + cod = str(sp["cod_prestatie"]) + return {"cod_prestatie": cod, "nume_prestatie": nume.get(cod), "sursa": sp.get("sursa")} + if enriched.get("surse", {}).get("nul"): + return None + fuzzy = suggest_codes(denumire, nomenclator, limit=1) + if fuzzy and float(fuzzy[0].get("score") or 0) >= FUZZY_SUGESTIE_MIN_SCORE: + return { + "cod_prestatie": fuzzy[0]["cod_prestatie"], + "nume_prestatie": fuzzy[0].get("nume_prestatie"), + "sursa": "fuzzy", + } + return None + + def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None: """Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text. diff --git a/app/models.py b/app/models.py index b2b28ce..2bb5d97 100644 --- a/app/models.py +++ b/app/models.py @@ -97,11 +97,37 @@ class PrezentareRequest(BaseModel): on_unmapped_error: bool | None = None # Mediul RAR tinta: 'test' | 'prod'. Absent -> default-ul contului (REQ-DEFAULT). rar_env: Literal["test", "prod"] | None = None + # Forma raspunsului: 'minim' (implicit) = 6 campuri fixe per rezultat + # (index, submission_id, vehicul, status, motiv, id_prezentare); + # 'complet' = SubmissionResult intreg (erori/nemapate/deduped/held/...). + raspuns: Literal["minim", "complet"] | None = None + + +class SubmissionResultMinim(BaseModel): + """Rezultat compact per prezentare — forma implicita a raspunsului. + + Chei FIXE (mereu prezente), usor de parsat de clienti simpli (ex. VFP): + `motiv` e null cand randul e curat in coada; `id_prezentare` e non-null + doar pe dedup al unui rand deja trimis la RAR. + """ + + index: int + submission_id: int | None = None + vehicul: str + status: str + motiv: str | None = None + id_prezentare: int | None = None class SubmissionResult(BaseModel): # submission_id e None cand cererea a fost RESPINSA fara enqueue (on_unmapped_error=True). submission_id: int | None = None + # Corelare cu cererea (campuri aditive): pozitia 0-based in `prezentari` + # + ecoul vehiculului trimis, ca integratorul sa lege fiecare rezultat + # de randul lui fara sa se bazeze doar pe ordine. + index: int | None = None + vin: str | None = None + nr_inmatriculare: str | None = None status: str id_prezentare: int | None = None deduped: bool = False # True daca idempotency a intors un submission existent @@ -127,7 +153,9 @@ class SubmissionResult(BaseModel): class PrezentariResponse(BaseModel): - results: list[SubmissionResult] + # Uniune per element: instantele sunt deja modelul potrivit (smart union + # pydantic v2 pastreaza tipul exact), deci serializarea nu amesteca formele. + results: list[SubmissionResult | SubmissionResultMinim] class ValidarePrezentariRequest(BaseModel): diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 638ebbc..4c1fa50 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -112,6 +112,7 @@ celulele raman semnale compacte (bulina, litera). #}