feat: visible per-product enrichment status, flagged re-evaluation (panel/Reprocessar/Sincronizar/KB decision), operator NCM decision, deterministic name-only match, stale-write guard; 1.3.5

This commit is contained in:
gti
2026-09-26 14:37:37 -03:00
parent ab65ddd679
commit 1333ac1bc4
34 changed files with 804 additions and 42 deletions
+32 -4
View File
@@ -29,6 +29,7 @@ READ_FIELDS = ("CODIGO", "PRODUTO", "GTIN", "CODBARRA") + ALLOWED_FIELDS #
POLL_SECONDS = 2.0
SAFETY_DIFF_SECONDS = 60.0
OVERRIDE_CHECK_SECONDS = 20.0
PENDING_POLL_SECONDS = 10.0 # cheap GET: products the Hub flagged for a new evaluation
WATCH_WINDOW_SECONDS = 120.0 # a fresh product is re-read for a while: the operator may add the EAN seconds after the first save
RETRY_BACKOFF = (30, 60, 120, 300, 600, 1800)
@@ -170,7 +171,7 @@ def read_rows(conn, codes) -> dict[str, dict]:
return out
def write_field(conn, code: str, field: str, value) -> dict:
def write_field(conn, code: str, field: str, value, observed=None, guard_observed: bool = False) -> dict:
"""PRODUCT_ENRICHMENT_WRITE_V1: one field, one short transaction, only if the field is still empty. Returns {outcome, reason?}."""
if field not in ALLOWED_FIELDS:
raise ValueError(f"field {field!r} is outside the PRODUCT_ENRICHMENT_WRITE_V1 allowlist")
@@ -188,6 +189,9 @@ def write_field(conn, code: str, field: str, value) -> dict:
conn.rollback()
return {"outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"}
raw = rows[0][0]
if guard_observed and _text(raw) != _text(observed): # STALE_CONFLICT: the value changed between the Hub decision and now (e.g. a person typed something) -> zero write
conn.rollback()
return {"outcome": "ABORTED", "reason": "STALE_CONFLICT"}
if not writable_now(field, raw):
conn.rollback()
return {"outcome": "ABORTED", "reason": "VALUE_ALREADY_SET_NEVER_OVERWRITTEN"}
@@ -235,6 +239,7 @@ class EnrichmentWorker:
self._last_override = 0.0
self._auth_cache = (None, 0.0)
self._count_ms = 0.0
self._last_pending = 0.0
self._wconn = None
self.metrics = {"detected": 0, "resolved": 0, "fields_written": 0, "detect_latency_ms": []}
@@ -372,7 +377,7 @@ class EnrichmentWorker:
if mode == "DRY_RUN":
reports.append({**base, "outcome": "DRY_RUN", "reason": "DRY_RUN_NO_WRITE"})
continue
outcome = self._write_safely(code, field, value)
outcome = self._write_safely(code, field, value, observed=current.get(field))
reports.append({**base, "outcome": outcome["outcome"], "reason": outcome.get("reason")})
if outcome["outcome"] == "APPLIED":
written[field] = value
@@ -391,14 +396,14 @@ class EnrichmentWorker:
except Exception: # noqa: BLE001
logger.warning("reprocess request for %s failed", code)
def _write_safely(self, code, field, value) -> dict:
def _write_safely(self, code, field, value, observed=None) -> dict:
"""Per field: fresh central authorization (fail closed) + guarded typed UPDATE. write_field re-reads the field INSIDE its own transaction and refuses when anything is there."""
if not self._fresh_enabled():
return {"outcome": "ABORTED", "reason": "AUTHORIZATION_CHANGED"}
try:
if self._wconn is None:
self._wconn = self._open(self.write_conn)
return write_field(self._wconn, code, field, value)
return write_field(self._wconn, code, field, value, observed=observed, guard_observed=True)
except Exception as error: # noqa: BLE001 - one field failing never breaks the others or the SGV
self._close_write()
return {"outcome": "FAILED", "reason": type(error).__name__}
@@ -411,6 +416,26 @@ class EnrichmentWorker:
except Exception: # noqa: BLE001
pass
def reevaluate_flagged(self) -> list[str]:
"""'Reavaliar' in the panel, Reprocessar/Sincronizar and KB decisions: re-run the normal resolve for tracked, not-yet-enriched products (KB/cache first, the Hub decides about providers)."""
try:
codes = self.hub.pending_enrichment()
except Exception: # noqa: BLE001 - Hub offline: try again next cycle
return []
done = []
for code in codes[:25]:
item = self.store.get(code)
if item is None:
self.store.enqueue(code, self.clock())
item = self.store.get(code)
try:
self.process(item)
except Exception as error: # noqa: BLE001
self._close_write()
logger.warning("re-evaluation of %s failed: %s", code, type(error).__name__)
done.append(code)
return done
# ---- human override watch --------------------------------------------------------------------------------------------------------------------------
def check_overrides(self):
tracked = self.store.tracked_written()
@@ -468,6 +493,9 @@ class EnrichmentWorker:
if row and f"{pick_gtin(row)}|{_text(row['PRODUTO'])}" != item.get("snapshot"):
self.process(item)
done.append(item["code"])
if self.clock() - self._last_pending >= PENDING_POLL_SECONDS:
self._last_pending = self.clock()
done += self.reevaluate_flagged()
if self.clock() - self._last_override >= OVERRIDE_CHECK_SECONDS:
self._last_override = self.clock()
self.check_overrides()