feat: enrichment Hub API, feature flags UI/gating, NCM refresher, Agent enrichment worker with tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-25 18:47:35 -03:00
co-authored by Claude Sonnet 5
parent 27f992d9e0
commit 5deebea14e
22 changed files with 1118 additions and 23 deletions
+140
View File
@@ -0,0 +1,140 @@
"""Agent-facing product enrichment routes (authenticated as the Agent; the Agent never talks to external providers)."""
from __future__ import annotations
import time
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from ..blocked_products import upsert_blocker
from ..config import settings
from ..models import Installation, ProductEnrichment, ProductFieldAudit
from .providers import build_providers
from .quota import ProviderGateway
from .resolver import FIELDS, resolve
PROVIDERS_OVERRIDE = None # tests inject fake providers here (never the network)
PENDING_STATES = {"PENDING_PROVIDER_QUOTA", "PENDING_PROVIDERS_OFFLINE", "PENDING_TIME_BUDGET"}
REASONS = { # human PT-BR reasons shown through the existing blockers (category ENRICHMENT); never internal jargon
"INVALID_GTIN": "Código de barras (GTIN) inválido: o produto não foi consultado em fontes externas.",
"AMBIGUOUS": "Descrição genérica demais para identificar o produto com segurança; informe o código de barras.",
"NOT_FOUND": "Produto não encontrado nas fontes consultadas; preencha os campos fiscais manualmente.",
"CONFLICT": "As fontes divergem sobre a classificação deste produto; nenhuma alteração foi feita.",
"SINGLE_WEAK_SOURCE": "Apenas uma fonte identificou o produto; a classificação foi apenas sugerida.",
"PENDING_PROVIDER_QUOTA": "Limite diário das fontes de produtos atingido; a consulta será repetida automaticamente.",
"PENDING_PROVIDERS_OFFLINE": "Fontes de produtos indisponíveis no momento; a consulta será repetida automaticamente.",
}
def _aware(dt):
return dt.replace(tzinfo=timezone.utc) if dt is not None and dt.tzinfo is None else dt
def features_of(installation) -> dict:
return {"corrector_enabled": bool(installation.corrector_enabled), "enrichment_mode": installation.enrichment_mode, "features_configured": bool(installation.features_configured)}
def router(principal, providers_factory=None):
routes = APIRouter(prefix="/products")
holder = {}
def providers():
if PROVIDERS_OVERRIDE is not None:
return PROVIDERS_OVERRIDE
if providers_factory is not None:
return providers_factory()
if "p" not in holder:
holder["p"] = build_providers(settings)
return holder["p"]
@routes.post("/resolve")
def resolve_product(payload: dict, identity=Depends(principal)):
session, agent = identity
try:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is None:
raise HTTPException(409, "installation not registered")
features = features_of(installation)
if not features["features_configured"] or installation.enrichment_mode == "DISABLED":
return {"state": "FEATURE_DISABLED", "features": features, "fields": {}}
code = str(payload.get("product_code") or "")[:64]
if not code:
raise HTTPException(422, "product_code required")
description, current = str(payload.get("description") or "")[:255], payload.get("current") if isinstance(payload.get("current"), dict) else {}
started = time.monotonic()
result = resolve(session, ProviderGateway(session, providers()), gtin_raw=payload.get("gtin"), description=description, current={k: current.get(k) for k in FIELDS},
client_label=f"installation:{installation.id}", budget_s=settings.enrichment_time_budget_seconds)
latency = int((time.monotonic() - started) * 1000)
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == code))
if row is None:
row = ProductEnrichment(tenant_id=agent.tenant_id, installation_id=installation.id, product_code=code)
session.add(row)
for field in (row.human_overridden or {}): # a human decision (even deleting the value) is final for this product
if field in result["fields"]:
result["fields"][field] = {"status": "HUMAN_OVERRIDDEN", "value": None, "rule": None, "kb_status": None, "evidence": [], "reason": "valor alterado por uma pessoa; nunca mais preenchido automaticamente"}
pending = result["state"] in PENDING_STATES
row.gtin, row.description, row.latency_ms = result.get("gtin"), description, latency
row.attempts = (row.attempts or 0) + 1
row.status = "PENDING" if pending else "RESOLVED"
row.decisions = {"state": result["state"], "fields": {k: {kk: vv for kk, vv in v.items() if kk != "evidence"} for k, v in result["fields"].items()}}
row.next_attempt_at = _aware(result["retry_at"]) if pending else None
if pending and row.next_attempt_at is None:
row.next_attempt_at = datetime.now(timezone.utc).replace(microsecond=0)
session.commit()
reason = REASONS.get(result["state"])
if reason:
upsert_blocker(session, agent.tenant_id, {"company": installation.hostname or "", "product_code": code, "gtin": result.get("gtin"), "description": description, "blocker_type": "ENRICHMENT_" + result["state"],
"field": "ENRICHMENT", "reason_code": result["state"], "evidence_summary": {"message": reason}, "input_fingerprint": f"{result.get('gtin')}|{description}"}, installation_id=installation.id)
result.update({"features": features, "latency_ms": latency, "mode": installation.enrichment_mode})
result["retry_at"] = result["retry_at"].isoformat() if result["retry_at"] else None
return result
finally:
session.close()
@routes.post("/enrichment/applied")
def applied(payload: dict, identity=Depends(principal)):
"""The Agent reports what it wrote / refused. The Hub re-checks that the feature is still ENABLED and audits every field."""
session, agent = identity
try:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is None:
raise HTTPException(409, "installation not registered")
code = str(payload.get("product_code") or "")[:64]
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == code))
if row is None:
raise HTTPException(404, "unknown product enrichment")
versions = payload.get("dataset_versions") if isinstance(payload.get("dataset_versions"), dict) else {}
applied_fields, overridden = dict(row.fields_applied or {}), dict(row.human_overridden or {})
for item in (payload.get("fields") or [])[:32]:
if not isinstance(item, dict) or item.get("field") not in FIELDS:
continue
outcome = str(item.get("outcome") or "APPLIED")[:24]
session.add(ProductFieldAudit(tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, product_code=code, gtin=row.gtin, description_snapshot=row.description, field=item["field"],
old_value=(str(item["old"])[:64] if item.get("old") is not None else None), new_value=(str(item["new"])[:64] if item.get("new") is not None else None), rule=str(item.get("rule") or "")[:64] or None,
kb_status=str(item.get("kb_status") or "")[:16] or None, evidence=item.get("evidence") if isinstance(item.get("evidence"), list) else [], dataset_versions=versions,
authorization_origin=f"enrichment_mode:{installation.enrichment_mode}", job_id=payload.get("job_id") if isinstance(payload.get("job_id"), int) else None, outcome=outcome,
reason=str(item.get("reason") or "")[:96] or None))
if outcome == "APPLIED":
applied_fields[item["field"]] = item.get("new")
elif outcome == "HUMAN_OVERRIDDEN":
overridden[item["field"]] = True
row.fields_applied, row.human_overridden = applied_fields, overridden
row.status = "APPLIED" if applied_fields and not overridden else ("PARTIAL" if applied_fields else row.status if row.status != "PENDING" else "RESOLVED")
row.updated_at = datetime.now(timezone.utc)
session.commit()
return {"ok": True, "status": row.status, "mode": installation.enrichment_mode}
finally:
session.close()
@routes.get("/enrichment/authorization")
def authorization(identity=Depends(principal)):
"""Fresh central authorization read by the Agent right before EVERY field write."""
session, agent = identity
try:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
return features_of(installation) if installation is not None else {"corrector_enabled": False, "enrichment_mode": "DISABLED", "features_configured": False}
finally:
session.close()
return routes