145 lines
9.6 KiB
Python
145 lines
9.6 KiB
Python
"""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 .placeholders import is_placeholder
|
|
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
|
|
for name, decision in result["fields"].items(): # explain in the audit trail that a technical placeholder was treated as "not classified yet"
|
|
if is_placeholder(name, current.get(name)):
|
|
decision["technical_placeholder"] = str(current.get(name))
|
|
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
|