feat(api): raspuns minim implicit + corelare cerere-raspuns + sugestie cod in raspuns; coloana # in lista trimiteri

- POST /v1/prezentari: camp optional 'raspuns' (minim implicit / complet);
  forma minima = 6 chei fixe (index, submission_id, vehicul, status, motiv,
  id_prezentare), formatul detaliat ramane la cerere
- corelare rezultat<->cerere: index 0-based + ecou vin/nr_inmatriculare
- sugestia principala de cod RAR pe nemapate (GOLD partajat > SILVER >
  embeddings > fuzzy peste prag), in motiv si in nemapate[].sugestie
  (si pe dry-run /valideaza); suggestion-only, nu se aplica automat
- lista Trimiteri: coloana # cu id-ul trimiterii (desktop + mobil)
- README: sectiune 'Mapari si sugestii de cod RAR'; contract actualizat

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-09 08:32:55 +00:00
parent c2beede084
commit 3ba35abed2
16 changed files with 239 additions and 20 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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):

View File

@@ -112,6 +112,7 @@
celulele raman semnale compacte (bulina, litera). #}
<li class="trimiteri-head" aria-hidden="true">
<span></span>
<span>#</span>
<span>Vehicul</span>
<span>Cod RAR</span>
<span>Operatie</span>
@@ -144,6 +145,10 @@
{% endif %}
</div>
{# Numarul trimiterii (id din baza) — aceeasi valoare ca `submission_id`
din raspunsul API si ca titlul modalului de detaliu/editare. #}
<span class="c-id">#{{ r.id }}</span>
{# Vehicul: placuta (identificator primar); fallback VIN scurt / 'fara numar'
(niciodata em-dash izolat). #}
{% if r.prez.vehicul_nr and r.prez.vehicul_nr != '—' %}

View File

@@ -758,7 +758,7 @@
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
.trimiteri-head, .trimitere-slim {
display:grid; align-items:center; column-gap:10px; row-gap:2px;
grid-template-columns:22px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
grid-template-columns:22px 42px minmax(90px,150px) minmax(64px,auto) minmax(0,1fr) auto 46px 44px 84px; }
.trimiteri-head { padding:2px 16px 8px; border-bottom:1px solid var(--line);
font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
color:var(--muted); }
@@ -769,6 +769,10 @@
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.trimitere-slim .c-check { display:flex; align-items:center; }
/* Numarul trimiterii: id-ul din baza, corelabil cu submission_id din API. */
.trimitere-slim .c-id { font-family:var(--font-mono); font-size:var(--fs-xs);
color:var(--muted); white-space:nowrap;
font-variant-numeric:tabular-nums; }
.slim-vin { font-family:var(--font-mono); font-size:var(--fs-sm); font-weight:600; color:var(--ink);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
/* Pe desktop copiii .c-meta devin celule de grid (cod / operatie / data). */
@@ -800,7 +804,7 @@
.env-l-test { color:var(--muted); border:1px solid var(--line); }
.trimitere-slim .c-act { justify-self:end; }
/* Sub-linia de eroare: text brut, mono, un singur rand cu ellipsis (title = complet). */
.trimitere-slim .c-err { grid-column:2 / -1; min-width:0; font-family:var(--font-mono);
.trimitere-slim .c-err { grid-column:3 / -1; min-width:0; font-family:var(--font-mono);
font-size:var(--fs-xs); color:var(--err); white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; }
/* .camp-slim — varianta compacta camp formular: label --fs-sm muted deasupra, input --fs-md, fundal --card2.
@@ -850,12 +854,13 @@
@media (max-width:767px) {
.trimiteri-head { display:none; }
.trimitere-slim { padding:12px 14px; column-gap:8px;
grid-template-columns:22px minmax(0,1fr) auto auto auto;
grid-template-columns:22px auto minmax(0,1fr) auto auto auto;
grid-template-areas:
"check veh act env status"
"check meta act env status"
"err err err err err"; }
"check id veh act env status"
"check meta meta act env status"
"err err err err err err"; }
.trimitere-slim .c-check { grid-area:check; }
.trimitere-slim .c-id { grid-area:id; align-self:center; }
.trimitere-slim .slim-vin { grid-area:veh; font-size:var(--fs-md); }
.trimitere-slim .c-meta { grid-area:meta; display:flex; align-items:baseline;
gap:6px; min-width:0; margin-top:3px; }