Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
@@ -0,0 +1,2 @@
"""Product enrichment (new product -> GTIN/name -> fiscal profile -> safe fill). Deterministic only: no AI, no LLM, no embeddings, no paid providers.
Everything lives inside the existing Hub/FastAPI/PostgreSQL; providers are hidden behind the Hub (Agents never call them)."""
+193
View File
@@ -0,0 +1,193 @@
"""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 ..field_contract import WRITE_FIELDS
from ..models import Installation, ProductEnrichment, ProductFieldAudit
from . import status as enrichment_status, textnorm
from .placeholders import is_placeholder
from .providers import build_providers
from .quota import ProviderGateway
from .resolver import FIELDS, resolve
CLASSIFICATION_CONTEXT_FIELDS = ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO") # read-only evidence for field_contract.infer_role; never written
CURRENT_FIELDS = tuple(dict.fromkeys(FIELDS + tuple(WRITE_FIELDS) + CLASSIFICATION_CONTEXT_FIELDS)) # everything resolve() may read from the Agent's `current`: NCM/CEST/IPI + every fiscal-completion field + role evidence
def sanitize_product_evidence(raw) -> dict | None:
"""Bounds the Agent's targeted, single-product evidence (firebird_reader.read_product_evidence) before it reaches field_contract: shape-only
validation (never trusts an authenticated Agent to send something huge or malformed for a single-product payload)."""
if not isinstance(raw, dict):
return None
out = {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
for key in ("recipes", "composed"):
if isinstance(raw.get(key), list):
out[key] = {str(v) for v in raw[key][:8]}
if isinstance(raw.get("composed_ratio"), dict):
for code, counts in list(raw["composed_ratio"].items())[:8]:
if isinstance(counts, dict) and isinstance(counts.get("composed"), int) and isinstance(counts.get("other"), int):
out["composed_ratio"][str(code)] = {"composed": max(0, counts["composed"]), "other": max(0, counts["other"])}
if isinstance(raw.get("purchases"), dict):
for code, docs in list(raw["purchases"].items())[:8]:
if isinstance(docs, list):
out["purchases"][str(code)] = [d for d in docs[:32] if isinstance(d, dict)]
for key in ("stock_entry", "stock_in"):
if isinstance(raw.get(key), dict):
out[key] = {str(k): bool(v) for k, v in list(raw[key].items())[:8]}
return out
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 CURRENT_FIELDS},
client_label=f"installation:{installation.id}", budget_s=settings.enrichment_time_budget_seconds,
product_code=code, installation=installation, tenant_id=agent.tenant_id, product_evidence=sanitize_product_evidence(payload.get("ev")))
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.ui_status, row.reason_code = enrichment_status.classify(result, has_gtin=bool(result.get("gtin")), specific_name=textnorm.is_specific(description))
row.last_attempt_at, row.reevaluate = datetime.now(timezone.utc), False
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
ui, why = enrichment_status.after_write([i for i in (payload.get("fields") or []) if isinstance(i, dict)], installation.enrichment_mode)
if ui:
row.ui_status, row.reason_code = ui, why
row.reevaluate = False
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/pending")
def pending_reevaluation(identity=Depends(principal)):
"""Tracked products flagged for a new evaluation (panel button, REPROCESS/SYNC, KB decision). The Agent polls this cheaply and re-runs resolve for each code."""
session, agent = identity
try:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is None or not installation.features_configured or installation.enrichment_mode == "DISABLED":
return {"codes": []}
codes = list(session.scalars(select(ProductEnrichment.product_code).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.reevaluate.is_(True)).order_by(ProductEnrichment.id).limit(25)))
return {"codes": codes}
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
+110
View File
@@ -0,0 +1,110 @@
"""Gradual, quota-safe verification of locally CORROBORATED GTINs by an NCM-bearing free provider (OSCBR). Never spends the operational reserve.
Rules: only CORROBORATED single-NCM GTINs (never VERIFIED_KB, never CONFLICT, never an invalid GTIN); each GTIN is checked once per `RECHECK_DAYS` (next_checked_at kept in the KB evidence, plus the
provider cache); a provider answer is EVIDENCE only - the promotion/conflict decision is kb.evaluate's (local strong evidence + independent provider NCM + vigente + zero conflict).
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from ..models import KbKnowledge, KbProduct
from . import kb
from .gtin import normalize_gtin
RECHECK_DAYS = 30
def _now():
return datetime.now(timezone.utc)
def select_sample(session, is_official, limit: int, provider: str = "oscbr") -> list[dict]:
"""Priority: multi-client, more documents, more years; current-vigente NCM; zero conflict; not checked recently."""
ids = list(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.status == kb.CORROBORATED).distinct()))
by_product: dict[int, list] = {}
for start in range(0, len(ids), 500): # every NCM row of those products: a second (even weaker) value already disqualifies the GTIN
for r in session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.kb_product_id.in_(ids[start:start + 500]))):
by_product.setdefault(r.kb_product_id, []).append(r)
now = _now().isoformat()
picks = []
for pid, items in by_product.items():
valid = [i for i in items if is_official(i.value)]
if len(valid) != 1 or valid[0].status != kb.CORROBORATED:
continue # conflicts / no vigente NCM / already verified are not asked
row = valid[0]
info = (row.evidence or {}).get("info") or {}
if info.get(f"{provider}_next_checked_at", "") > now:
continue
prod = session.get(KbProduct, pid)
if not prod or normalize_gtin(prod.gtin[1:] if prod.gtin and prod.gtin.startswith("0") else prod.gtin)["status"] != "VALID_GTIN":
continue
summary = kb._lineage_summary([row])
if any(f.startswith("PROVIDER:" + provider) for f in ((row.evidence or {}).get("families") or {})):
continue
picks.append({"gtin_key": prod.gtin, "ncm": row.value, "clients": len(summary["clients"]), "docs": summary["docs"], "years": len(summary["years"]), "product_id": pid})
picks.sort(key=lambda p: (-p["clients"], -p["docs"], -p["years"], p["gtin_key"]))
return picks[:limit]
def apply_outcome(session, gtin_key: str, provider: str, outcome, is_official) -> dict:
"""Turns one gateway Outcome into KB evidence. Returns the per-GTIN measurement record."""
product = kb.find_by_gtin(session, gtin_key)
rec = {"gtin": gtin_key[-13:] if gtin_key.startswith("0") else gtin_key, "state": outcome.state, "reason": outcome.reason, "found": False, "ncm": None, "cest": None, "description": None, "brand": None,
"local_ncm": None, "official_valid": None, "kb_status": None, "external_conflict": False}
local_rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm"))) if product else []
rec["local_ncm"] = local_rows[0].value if local_rows else None
stamp = (_now() + timedelta(days=RECHECK_DAYS)).isoformat()
if outcome.state in ("FOUND", "CACHE_HIT") and outcome.product:
p = outcome.product
rec.update({"found": True, "ncm": p.ncm, "cest": p.cest, "description": p.title, "brand": p.brand})
if p.ncm:
rec["official_valid"] = bool(is_official(p.ncm))
kb.add_evidence(session, product, "ncm", p.ncm, f"provider:{provider}"[:32], family=f"PROVIDER:{provider}", info={"raw_hash": p.raw_hash})
if p.cest:
kb.add_evidence(session, product, "cest", p.cest, f"provider:{provider}"[:32], family=f"PROVIDER:{provider}", info={"raw_hash": p.raw_hash})
if p.brand and not product.brand:
product.brand = p.brand
if p.title:
kb.add_alias(session, product, p.title.upper()[:200])
ev = kb.evaluate(session, product, "ncm", is_official)
rec["kb_status"] = ev["status"]
rec["external_conflict"] = bool(ev["status"] == kb.CONFLICT and rec["ncm"])
if outcome.state in ("FOUND", "CACHE_HIT", "NOT_FOUND"): # remember the check so it is not repeated (skipped/errors are retried later)
for r in session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm")):
r.evidence = {**(r.evidence or {}), "info": {**((r.evidence or {}).get("info") or {}), f"{provider}_next_checked_at": stamp}}
return rec
def provider_query(key: str) -> str:
"""Canonical form sent to providers: GTIN-8 stays 8 digits, UPC-12/EAN-13 as 13 digits (same rule as the resolver), GTIN-14 with an indicator digit stays 14."""
if len(key) == 14 and key.startswith("000000"):
return key[-8:]
if len(key) == 14 and key.startswith("0"):
return key[1:]
return key
def run(session, gateway, provider, *, limit: int, reserve_percent: int, is_official, dry_run: bool = False, wait_rate_window: bool = True) -> dict:
"""One backfill pass. dry_run only lists the sample (no request)."""
sample = select_sample(session, is_official, limit, provider.name)
report = {"provider": provider.name, "sample_size": len(sample), "requests": 0, "records": [], "stopped": None, "dry_run": dry_run}
if dry_run:
report["records"] = [{k: v for k, v in s.items() if k != "product_id"} for s in sample]
return report
for item in sample:
query = provider_query(item["gtin_key"])
outcome = gateway.lookup(provider, query, "BACKFILL", reserve_percent)
while outcome.state == "SKIPPED" and outcome.reason == "RATE_WINDOW" and wait_rate_window: # per-minute governor: wait for the window, never exceed it
time.sleep(max(1, min(65, int((outcome.retry_at - _now()).total_seconds()) + 1)) if outcome.retry_at else 12)
outcome = gateway.lookup(provider, query, "BACKFILL", reserve_percent)
if outcome.state == "SKIPPED":
report["stopped"] = outcome.reason # quota / reserve / per-minute window: stop, never insist
break
if not outcome.from_cache:
report["requests"] += 1
report["records"].append(apply_outcome(session, item["gtin_key"], provider.name, outcome, is_official))
session.commit()
return report
@@ -0,0 +1,143 @@
"""Post-NCM fiscal completion: wires the EXISTING, previously-dormant `central.app.field_contract.resolve_product` (the real field-by-field fiscal
contract engine - CFOP, CST, CSOSN, PIS/COFINS, CST_IPI, SITUACAO_TRIBUTARIA, IAT, IPPT, ALIQUOTA...) into the live Product Enrichment resolve call.
No second fiscal engine. Everything here only ASSEMBLES the same inputs `resolve_product` already expects (`company`, `refs`, `ev`, `ctx`) from data
that already exists on the Hub, and maps its per-field CHANGE/ALREADY/NOT_APPLICABLE/MANUAL output onto the enrichment's own AUTO_FILL_SAFE vocabulary
so the SAME typed-write Agent pipeline can apply it. `company` comes straight from `Installation.company_uf/company_crt` (auto-discovered from the
Agent's own heartbeat - zero guessing). `ctx` comes straight from `FiscalContextEntry` (the SAME table the existing blocker-resolution workflow
already writes to when an operator answers a question) - so a missing key surfaces as an ordinary blocker the operator resolves the usual way; no
new configuration UI is created. `ev` (purchase/recipe/stock evidence) is not available from a single ad-hoc product resolve; those fields fall back
to MANUAL exactly as `field_contract` already does when evidence is absent - never a guess.
"""
from __future__ import annotations
from sqlalchemy import select
from ..classification import normalize_code
from ..datasets.service import DatasetService
from ..models import Dataset, FiscalContextEntry
from .. import field_contract as fc
# Fields this module MAY auto-fill. Deliberately excludes CLASSIFICACAO_FISCAL/CEST/IPI (owned by the enrichment resolver itself),
# COD_ORIGEM_MERCADORIA (no evidence source exists per the contract - never inferred from CST), EX_TIPI (interacts with the IPI EX choice,
# left for a dedicated pass) and CCLASSTRIB (RTC's own domain, untouched).
FISCAL_COMPLETION_FIELDS = ("SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "CST_IPI",
"COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS",
"INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT")
# Value shape per field (validated BOTH here, before proposing a write, and again by the Agent right before the typed write).
DIGIT_FIELDS = {"CFOP": 4, "CFOP_ENTRADA": 4, "CST": 3, "CST_ENTRADA": 3, "CSOSN": 3, "CST_IPI": 3, "COD_ENTRADA_PIS": 2, "COD_SAIDA_PIS": 2,
"COD_ENTRADA_COFINS": 2, "COD_SAIDA_COFINS": 2}
NUMERIC_FIELDS = ("ALIQUOTA",)
DECIMAL_TEXT_FIELDS = ("ALIQ_PIS", "ALIQ_COFINS")
ENUM_FIELDS = {"SITUACAO_TRIBUTARIA": {"T", "I", "N", "S"}, "IAT": {"A", "T"}, "IPPT": {"P", "T"}, "PISCOFINS": {"N", "S"},
"INCIDENCIA_PISCOFINS": {"NENHUMA", "MONOFASICA", "SUBST.TRIBUT."}}
def is_writable_shape(field: str, value) -> bool:
text = "" if value is None else str(value).strip()
if not text:
return False
if field in DIGIT_FIELDS:
digits = "".join(c for c in text if c.isdigit())
return digits == text and len(digits) in (DIGIT_FIELDS[field], DIGIT_FIELDS[field] + 1) # CST/CSOSN family sometimes carries an origin digit prefix
if field in NUMERIC_FIELDS or field in DECIMAL_TEXT_FIELDS:
try:
float(text.replace(",", "."))
return True
except ValueError:
return False
if field in ENUM_FIELDS:
return text.upper() in ENUM_FIELDS[field]
return False
def build_company(installation) -> dict:
"""Auto-discovered, zero guessing: the Agent already reports these at every heartbeat (company_contribuinte_ipi from C000004.CONTRIBUINTE_IPI,
the same row/query as company_crt - see company_context.py on the Agent). Omits "contribuinte_ipi" entirely when the Agent hasn't reported it
yet (older Agent, or the field just never resolved): resolve_fiscal_fields treats that as genuinely unknown, never as a proven "N"."""
company = {"uf": (installation.company_uf or "").upper() or None, "crt": installation.company_crt or None}
if installation.company_contribuinte_ipi in ("S", "N"):
company["contribuinte_ipi"] = installation.company_contribuinte_ipi
return company
def build_refs(session, *, uf: str | None, cfop_candidates: set[str]) -> dict:
ds = DatasetService(session)
refs = {key: {r.code for r in ds.fiscal_codes(domain)} for key, domain in
(("csosn", "CSOSN"), ("cst_icms", "CST_ICMS"), ("cst_pis", "CST_PIS"), ("cst_cofins", "CST_COFINS"), ("origem", "ORIGEM_MERCADORIA"))}
refs["cfop_valid"] = {c for c in cfop_candidates if ds.cfop_lookup(code=c)}
refs["fcp_uf"] = {}
if uf:
fcp = ds.fcp_for_uf(uf)
if fcp:
refs["fcp_uf"][uf] = {"aliq_1": fcp.aliq_1, "observacao": fcp.observacao}
refs["cbs_rate_2026"] = (ds.rtc_cbs_rate(2026).rate if ds.rtc_cbs_rate(2026) else None)
refs["cclasstrib_count"] = session.scalar(select(Dataset.record_count).where(Dataset.kind == "RTC_CCLASSTRIB", Dataset.status == "ACTIVE")) or 0
return refs
def build_context(session, tenant_id: int, company_key: str) -> fc.Context:
"""SAME table the existing blocker-resolution workflow (`central/app/blocker_resolution.py`) already writes operator answers into."""
rows = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key)).all()
return fc.Context([{"scope": r.scope, "scope_key": r.scope_key, "key": r.key, "value": r.value} for r in rows])
def resolve_fiscal_fields(session, *, product_code: str, description: str, current: dict, ncm: str, ncm_official: bool, installation, tenant_id: int,
extra_columns: dict | None = None, decision: dict | None = None, ev: dict | None = None) -> dict:
"""Returns {field: {status, value, rule, reason}} for FISCAL_COMPLETION_FIELDS, in the enrichment's own vocabulary (AUTO_FILL_SAFE / ALREADY_SET /
NOT_APPLICABLE / NEEDS_REVIEW / INSUFFICIENT_EVIDENCE). Never called for a product whose NCM is not yet an official, vigente code.
`decision` is the SAME dict `classification.Classifier.classify(...)` already returns for CEST/IPI (resolver.py's `_fiscal_decisions`) - reused
as-is (its `classification.ipi_candidates`/`cest_candidates`/`ba_st_mva` keys are exactly what `resolve_product` reads), never recomputed here."""
if not ncm_official:
return {f: {"status": "INSUFFICIENT_EVIDENCE", "value": None, "rule": None, "reason": "depende de um NCM oficial vigente"} for f in FISCAL_COMPLETION_FIELDS}
ncm = normalize_code(ncm)
p = {"CODIGO": product_code, "PRODUTO": description, "CODGRUPO": None, "TIPO": None, "PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": ncm}
p.update({k: v for k, v in (extra_columns or {}).items() if k in ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO")})
if str(p.get("PROD_COZINHA") or "").strip().isdigit(): # the Agent sends `current` as JSON strings (e.g. "1"); infer_role compares the real Firebird SMALLINT (== 1)
p["PROD_COZINHA"] = int(p["PROD_COZINHA"])
for f in fc.WRITE_FIELDS:
p.setdefault(f, current.get(f))
company = build_company(installation)
company_key = (installation.company_code or installation.company_name or installation.customer_code or "").strip()
ctx = build_context(session, tenant_id, company_key)
if "contribuinte_ipi" not in company: # ERP fact (heartbeat) always wins; the operator answer is only a fallback for an Agent that hasn't reported it yet
contribuinte_ipi, _ = ctx.get("contribuinte_ipi")
if contribuinte_ipi in ("S", "N"):
company["contribuinte_ipi"] = contribuinte_ipi
cfop_candidates = {str(current.get(f)).strip() for f in ("CFOP", "CFOP_ENTRADA") if current.get(f)}
refs = build_refs(session, uf=company.get("uf"), cfop_candidates=cfop_candidates)
# Agent-provided, product_code-targeted evidence (recipes/composed/purchases/stock - see firebird_reader.read_product_evidence): a real,
# indexed per-product query, never a full-table scan or a guess. Falls back to "no evidence" only when the Agent hasn't sent it yet.
ev = ev if isinstance(ev, dict) else {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
d = decision or {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ba_st_mva_status": None, "classification": {"cest_candidates": [], "ipi_candidates": [], "ba_st_mva": {}}}
raw = fc.resolve_product(p, d, company, refs, ev, ctx)
out = {}
for field in FISCAL_COMPLETION_FIELDS:
r = raw.get(field) or {}
state = r.get("state")
if state == fc.ALREADY:
out[field] = {"status": "ALREADY_SET" if not _blank(current.get(field)) else "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
elif state == fc.NA and field == "CST_IPI" and "contribuinte_ipi" not in company:
# field_contract's own NA branch for CST_IPI fires whenever company.get("contribuinte_ipi") != "S" - which is also true when the fact is
# simply UNKNOWN. Never let an unknown fact read as a proven "not an IPI taxpayer": surface it as a missing answer instead.
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": "contribuinte de IPI não confirmado", "missing_key": "contribuinte_ipi", "missing_scope": "COMPANY"}
elif state == fc.NA:
out[field] = {"status": "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
elif state == fc.CHANGE and _blank(current.get(field)) and is_writable_shape(field, r.get("proposed")):
out[field] = {"status": "AUTO_FILL_SAFE", "value": str(r.get("proposed")), "rule": r.get("reason"), "reason": None}
elif state == fc.CHANGE:
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": r.get("reason"), "reason": "valor proposto não seguro para escrita automática" if not _blank(current.get(field)) else "formato do valor proposto não confere"}
else: # MANUAL
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": r.get("reason"), "missing_key": r.get("input_key"), "missing_scope": r.get("input_scope")}
return out
def _blank(v) -> bool:
return v is None or str(v).strip() == ""
def coverage_status(field_statuses: dict) -> str:
"""COMPLETE when every field is filled/already-correct/not-applicable; SAFE_BLOCKED when at least one relevant field still needs a decision."""
return "SAFE_BLOCKED" if any(v["status"] in ("NEEDS_REVIEW", "INSUFFICIENT_EVIDENCE") for v in field_statuses.values()) else "COMPLETE"
+31
View File
@@ -0,0 +1,31 @@
"""Deterministic GTIN normalization and validation (GTIN-8, UPC/GTIN-12, EAN/GTIN-13, GTIN-14)."""
import re
VALID, INVALID, MISSING, RESTRICTED = "VALID_GTIN", "INVALID_GTIN", "MISSING_GTIN", "RESTRICTED_GTIN"
def check_digit_ok(digits: str) -> bool:
body, check = digits[:-1], int(digits[-1])
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
return (10 - total % 10) % 10 == check
def normalize_gtin(raw) -> dict:
"""Returns {status, gtin (digits as typed), key (zero-padded to 14: the KB identity), reason}. Never calls anything external.
RESTRICTED: EAN prefixes 02, 04 and 20-29 are store-internal / restricted circulation (not a global product identity), so no provider is asked."""
text = "" if raw is None else str(raw).strip()
if not text or text.upper() in ("SEM GTIN", "SEM EAN", "0", "NULL"):
return {"status": MISSING, "gtin": None, "key": None, "reason": "EMPTY"}
digits = re.sub(r"\D", "", text)
if not digits or len(digits) not in (8, 12, 13, 14):
return {"status": INVALID, "gtin": digits or None, "key": None, "reason": "BAD_LENGTH"}
if len(set(digits)) == 1:
return {"status": INVALID, "gtin": digits, "key": None, "reason": "REPEATED_DIGITS"}
if not check_digit_ok(digits):
return {"status": INVALID, "gtin": digits, "key": None, "reason": "BAD_CHECK_DIGIT"}
key = digits.zfill(14)
prefix = key[1:3] if len(digits) == 14 else key[1:3] # key = 0 + EAN-13 layout for 12/13; GTIN-14 keeps its indicator digit in key[0]
ean13 = key[1:]
if ean13[:2] in ("02", "04") or "20" <= ean13[:2] <= "29":
return {"status": RESTRICTED, "gtin": digits, "key": key, "reason": "STORE_INTERNAL_PREFIX"}
return {"status": VALID, "gtin": digits, "key": key, "reason": None}
+196
View File
@@ -0,0 +1,196 @@
"""Historical ERP evidence (product master + NFC-e/NF-e items) -> central KB evidence. Local data only: NO provider is contacted here.
Evidence model
- One 'lineage' per client per (GTIN, value): master-data flag, fiscal-document count and documents per year. Master data and the fiscal documents of the SAME ERP are one family (CLIENT_DATA):
the documents were issued from that very master, so they are never counted as two independent sources.
- CLIENT_DATA alone can reach CORROBORATED (>= 20 documents over >= 2 years, or >= 2 distinct clients) but never VERIFIED_KB: that needs an independent NCM-bearing source (kb.evaluate).
- NCM validity is judged against the ACTIVE official dataset today: CURRENT_VALID / HISTORICAL_EXPIRED / HISTORICAL_INVALID; a different NCM at least as recent -> HISTORICAL_CHANGED; two current values -> CONFLICT.
- Company-specific tax data (CRT, CFOP, CSOSN, ...) is never read nor stored; only GTIN, descriptions/aliases, NCM, CEST and the source tenant label.
Idempotent: a client's lineage for a (GTIN, value) is REPLACED on every import, never accumulated.
"""
from __future__ import annotations
import re
from collections import defaultdict
from sqlalchemy import select
from ..models import KbKnowledge, KbProduct
from . import kb, textnorm
from .gtin import normalize_gtin
def _digits(v) -> str:
return re.sub(r"\D", "", str(v or ""))
def build_observations(stagings: list[dict]) -> dict:
"""stagings: miner output per client. Returns {"gtins": {key: {...}}, "stats": {...}}."""
gtins: dict[str, dict] = {}
stats = defaultdict(int)
for st in stagings:
client = st["client"]
product_gtin: dict[str, set] = defaultdict(set)
master_gtin: dict[str, str] = {}
master_desc: dict[str, str] = {}
for m in st["master"]:
stats["master_rows"] += 1
master_desc[m["code"]] = m["desc"]
for raw in (m["gtin"], m["codbarra"]):
g = normalize_gtin(raw)
if g["status"] == "VALID_GTIN":
master_gtin.setdefault(m["code"], g["key"])
break
# product<->EAN links seen in documents/movements are used ONLY when the master has none, and only when unambiguous
seen: dict[str, set] = defaultdict(set)
for row in st.get("nfe", []):
g = normalize_gtin(row["codbarra"])
if g["status"] == "VALID_GTIN":
seen[row["code"]].add(g["key"])
for row in st.get("barcodes", []):
g = normalize_gtin(row["codbarra"])
if g["status"] == "VALID_GTIN":
seen[row["code"]].add(g["key"])
link: dict[str, str] = dict(master_gtin)
for code, keys in seen.items():
if code in link:
continue
if len(keys) == 1:
link[code] = next(iter(keys))
stats["links_from_documents"] += 1
else:
stats["ambiguous_product_links_skipped"] += 1
def slot(key):
return gtins.setdefault(key, {"aliases": set(), "ncm": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "cest": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "clients": set()})
for m in st["master"]:
key = link.get(m["code"])
if not key:
continue
s = slot(key)
s["clients"].add(client)
if m["desc"]:
s["aliases"].add(textnorm.normalized(m["desc"]))
n = _digits(m["ncm"])
if len(n) == 8:
s["ncm"][n][client]["master"] = 1
c = _digits(m["cest"])
if len(c) == 7:
s["cest"][c][client]["master"] = 1
for row in st.get("nfce", []):
key = link.get(row["code"])
stats["nfce_groups"] += 1
if not key:
stats["nfce_groups_without_gtin"] += 1
continue
s = slot(key)
n = _digits(row["ncm"])
if len(n) == 8:
lin = s["ncm"][n][client]
lin["docs"] += row["n"]
if row["year"]:
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
stats["nfce_documents"] += row["n"]
c = _digits(row["cest"])
if len(c) == 7:
lin = s["cest"][c][client]
lin["docs"] += row["n"]
if row["year"]:
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
for row in st.get("nfe", []):
key = link.get(row["code"])
if not key:
continue
s = slot(key)
if row["desc"]:
s["aliases"].add(textnorm.normalized(row["desc"]))
n = _digits(row["ncm"])
if len(n) == 8:
s["ncm"][n][client]["docs"] += row["n"]
stats["nfe_documents"] += row["n"]
stats["gtins"] = len(gtins)
return {"gtins": gtins, "stats": dict(stats)}
def apply(session, observations: dict, is_official, *, dry_run: bool = True, chunk: int = 500) -> dict:
"""Bulk, idempotent import into kb_products / kb_knowledge. dry_run=True computes every status and rolls back."""
keys = sorted(observations["gtins"])
report = {"GTINS_FOUND": len(keys), "OBSERVATIONS": 0, "by_status": defaultdict(int), "CONFLICTS": 0, "WOULD_VERIFY": 0, "with_current_valid_ncm": 0, "stable_across_years": 0, "changed_over_time": 0,
"with_historical_ncm": 0, "multi_client": 0}
nested = session.begin_nested()
try:
for start in range(0, len(keys), chunk):
part = keys[start:start + chunk]
existing = {p.gtin: p for p in session.scalars(select(KbProduct).where(KbProduct.gtin.in_(part)))}
for key in part:
obs = observations["gtins"][key]
product = existing.get(key)
if product is None:
alias = next(iter(sorted(obs["aliases"])), "")
product = kb.upsert_product(session, key, alias)
existing[key] = product
for a in sorted(obs["aliases"])[:20]:
kb.add_alias(session, product, a)
fp = textnorm.fingerprint(a)
if fp:
kb.add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=kb.OBSERVED)
for field, values in (("ncm", obs["ncm"]), ("cest", obs["cest"])):
for value, per_client in values.items():
for client, lin in per_client.items():
kb.add_evidence(session, product, field, value, "client:history", family=kb.CLIENT_FAMILY, client_label=client, lineage=None)
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == field, KbKnowledge.value == value))
ev = dict(row.evidence or {})
lineages = dict(ev.get("lineages") or {})
lineages[client] = {"master": lin["master"], "docs": lin["docs"], "years": dict(lin["years"])} # REPLACE: idempotent
row.evidence = {**ev, "lineages": lineages}
report["OBSERVATIONS"] += 1
session.flush()
for key in part:
product = existing[key]
ev = kb.evaluate(session, product, "ncm", is_official)
report["by_status"][ev["status"]] += 1
if ev["status"] == kb.CONFLICT:
report["CONFLICTS"] += 1
if ev["status"] == kb.VERIFIED_KB:
report["WOULD_VERIFY"] += 1
if ev["value"]:
report["with_current_valid_ncm"] += 1
if observations["gtins"][key]["ncm"]:
report["with_historical_ncm"] += 1
if len(observations["gtins"][key]["clients"]) >= 2:
report["multi_client"] += 1
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm")))
states = {(r.evidence.get("info") or {}).get("historical_state") for r in rows}
if "HISTORICAL_STABLE_CURRENT" in states and ev["value"]:
years = set()
for r in rows:
for lin in (r.evidence.get("lineages") or {}).values():
years |= set(lin.get("years") or {})
if len(years) >= 2:
report["stable_across_years"] += 1
if "HISTORICAL_CHANGED" in states or ("HISTORICAL_EXPIRED" in states and ev["value"]):
report["changed_over_time"] += 1
if dry_run:
nested.rollback()
else:
nested.commit()
session.commit()
except Exception:
nested.rollback()
raise
report["by_status"] = dict(report["by_status"])
return report
def official_sets(session):
"""Precomputed 'vigente today' membership: one query instead of one per value."""
from datetime import date
from ..models import Dataset, NcmEntry
from .resolver import _parse_br
today = date.today()
current = set()
for row in session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")):
start, end = _parse_br(row.valid_from), _parse_br(row.valid_to)
if (start is None or start <= today) and (end is None or end >= today):
current.add(row.code)
return current
+284
View File
@@ -0,0 +1,284 @@
"""Central product Knowledge Base access (same PostgreSQL, existing kb_* tables) with per-field status.
Trust model (docs/product_enrichment.md):
- A provider value or a client observation is EVIDENCE. A field becomes VERIFIED_KB only when the official dataset confirms it AND at least two INDEPENDENT evidence
families agree with zero conflict (distinct providers each count once; client observations count once and only with >= 2 distinct clients).
- Human edits never enter the KB (HUMAN_OVERRIDDEN is per-installation protection, not knowledge). Company-specific fiscal decisions (CSOSN, CFOP, ...) never live here.
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import func
from ..models import KbKnowledge, KbProduct, KbPromotionAudit
from . import textnorm
UNVERIFIED, OBSERVED, CORROBORATED, EXTERNAL_CORROBORATED, VERIFIED_KB, CONFLICT, STALE = "UNVERIFIED", "OBSERVED", "CORROBORATED", "EXTERNAL_CORROBORATED", "VERIFIED_KB", "CONFLICT", "STALE"
EVIDENCE_ONLY = OBSERVED # legacy name
CLIENT_FAMILY = "CLIENT_DATA" # every client-derived lineage (master data + fiscal documents of the same ERP are NOT independent of each other)
RULE_VERSION = "kb-promotion-v2"
STRONG_DOCS, STRONG_YEARS = 20, 2
def _now():
return datetime.now(timezone.utc)
def gtin_fingerprint(key: str) -> str:
return hashlib.sha256(f"gtin:{key}".encode()).hexdigest()
def find_by_gtin(session, key: str | None) -> KbProduct | None:
return session.scalar(select(KbProduct).where(KbProduct.gtin == key)) if key else None
NEUTRAL_EXTRAS = frozenset({"REFRIGERANTE", "REFRIG", "REFR", "BEBIDA", "PRODUTO", "PROD"}) # category nouns only: ZERO / DIET / a flavour / another size are NEVER neutral
def _tokset(text) -> frozenset:
return frozenset(t for t in textnorm.tokens(text) if t not in textnorm.MARKETING)
def alias_match(query: frozenset, alias: frozenset) -> bool:
"""Deterministic, conservative: same token set, or one set contains the other and the difference is only neutral category nouns (so "COCA COLA LATA 350 ML" = "REFRIGERANTE COCA COLA LATA 350 ML", but not "... ZERO ...")."""
if not query or not alias:
return False
if query == alias:
return True
if query < alias:
return (alias - query) <= NEUTRAL_EXTRAS
if alias < query:
return (query - alias) <= NEUTRAL_EXTRAS
return False
def name_candidates(session, description) -> list[KbProduct]:
"""Every KB product with an alias that matches the description under `alias_match` (exact fingerprint included). 0, 1 or several - the caller decides (unique VERIFIED only)."""
fp = textnorm.fingerprint(description)
if not fp:
return []
ids = set(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "name_fingerprint", KbKnowledge.value == fp).distinct()))
query = _tokset(description)
anchor = max((t for t in query if not t.replace(".", "").isdigit() and len(t) > 2), key=len, default=None) # most selective informative token, then verify in Python
if anchor:
for row in session.execute(select(KbKnowledge.kb_product_id, KbKnowledge.value).where(KbKnowledge.knowledge_key == "alias", KbKnowledge.value.contains(anchor))):
if alias_match(query, _tokset(row[1])):
ids.add(row[0])
return [p for p in (session.get(KbProduct, i) for i in sorted(ids)) if p]
def upsert_product(session, key: str | None, description, *, brand=None, quantity=None) -> KbProduct:
if key:
from ..gtin_validator import validate_gtin
valid, _ = validate_gtin(key)
if not valid:
key = None # Regra C.6: GTIN inválido (DV ou prefixo errado) não entra na KB
product = find_by_gtin(session, key)
if product is None:
fp = gtin_fingerprint(key) if key else hashlib.sha256(f"name:{textnorm.fingerprint(description)}".encode()).hexdigest()
product = session.scalar(select(KbProduct).where(KbProduct.fingerprint == fp))
if product is None:
product = KbProduct(fingerprint=fp, gtin=key, identity_kind="GTIN" if key else "FINGERPRINT", normalized_description=textnorm.normalized(description))
session.add(product)
session.flush()
if description and not product.normalized_description:
product.normalized_description = textnorm.normalized(description)
if brand and not product.brand:
product.brand = brand
if quantity and not product.package_quantity:
product.package_quantity = str(quantity)[:32]
fp = textnorm.fingerprint(description)
norm = textnorm.normalized(description)
if norm:
add_alias(session, product, norm)
if fp:
add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=OBSERVED)
return product
def add_alias(session, product: KbProduct, normalized: str, cap: int = 20):
"""Non-fiscal aliases of the same GTIN (descriptions seen over the years). Helps name matching; never authority for a fiscal write."""
if not normalized or session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "alias", KbKnowledge.value == normalized[:200])):
return
if (session.scalar(select(func.count()).select_from(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "alias")) or 0) >= cap:
return
session.add(KbKnowledge(kb_product_id=product.id, knowledge_key="alias", value=normalized[:200], source="ALIAS", status=OBSERVED, evidence={"families": {}, "clients": [], "info": {}}))
def add_evidence(session, product: KbProduct, key: str, value: str, source: str, *, family: str, client_label: str | None = None, status: str = OBSERVED, info: dict | None = None,
lineage: dict | None = None) -> KbKnowledge:
"""lineage (client data only): {"master": 0/1, "docs": n, "years": {"2024": n}} of ONE client for this (GTIN, value); merged, never double counted."""
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key, KbKnowledge.value == value))
if row is None:
row = KbKnowledge(kb_product_id=product.id, knowledge_key=key, value=value, source=source[:32], status=status, evidence={"families": {}, "clients": [], "lineages": {}, "info": info or {}})
session.add(row)
session.flush()
evidence = dict(row.evidence or {})
families = dict(evidence.get("families") or {})
families[family] = _now().isoformat()
clients = list(evidence.get("clients") or [])
if client_label and client_label not in clients:
clients.append(client_label)
lineages = dict(evidence.get("lineages") or {})
if client_label and lineage:
cur = dict(lineages.get(client_label) or {"master": 0, "docs": 0, "years": {}})
cur["master"] = max(int(cur.get("master", 0)), int(lineage.get("master", 0)))
cur["docs"] = int(cur.get("docs", 0)) + int(lineage.get("docs", 0))
years = dict(cur.get("years") or {})
for y, n in (lineage.get("years") or {}).items():
years[str(y)] = int(years.get(str(y), 0)) + int(n)
cur["years"] = years
lineages[client_label] = cur
row.evidence = {**evidence, "families": families, "clients": clients, "lineages": lineages, "info": {**(evidence.get("info") or {}), **(info or {})}}
return row
def _lineage_summary(rows):
clients, docs, years = set(), 0, {}
for r in rows:
ev = r.evidence or {}
for c, lin in (ev.get("lineages") or {}).items():
clients.add(c)
docs += int(lin.get("docs", 0))
for y, n in (lin.get("years") or {}).items():
years[y] = years.get(y, 0) + int(n)
for c in ev.get("clients") or []:
clients.add(c)
return {"clients": sorted(clients), "docs": docs, "years": sorted(years), "last_year": max(int(y) for y in years) if years else None}
def independent_count(rows) -> tuple[int, list[str]]:
"""Independent families: each NCM-bearing provider = 1; ALL client-derived data = 1 and only when STRONG (>= STRONG_DOCS documents over >= STRONG_YEARS years, or >= 2 distinct clients)."""
names = sorted({f for r in rows for f in ((r.evidence or {}).get("families") or {}) if f.startswith("PROVIDER:")})
summary = _lineage_summary(rows)
has_client = any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in rows)
if has_client and (len(summary["clients"]) >= 2 or (summary["docs"] >= STRONG_DOCS and len(summary["years"]) >= STRONG_YEARS)):
names.append(CLIENT_FAMILY)
return len(names), names
def _official_period(session, value):
"""CURRENT_VALID / HISTORICAL_EXPIRED (exists in the dataset but not vigente today) / HISTORICAL_INVALID (never in the dataset)."""
from .resolver import ncm_exists_any
return "CURRENT_VALID" if ncm_exists_any(session, value, current=True) else ("HISTORICAL_EXPIRED" if ncm_exists_any(session, value, current=False) else "HISTORICAL_INVALID")
def _mark(row, state):
row.evidence = {**(row.evidence or {}), "info": {**((row.evidence or {}).get("info") or {}), "historical_state": state}}
OPERATOR_FAMILY = "OPERATOR_ATTESTATION"
def attest(session, gtin_key: str, ncm: str, operator: str, is_official, note: str = "") -> dict:
"""A human fiscal decision by an ADMIN (audited by the caller). Only a value that is ALREADY a candidate in the KB (some local/provider evidence) and vigente in the official dataset can be attested;
the dissenting candidates become STALE (REJECTED_BY_OPERATOR) and the product is promoted with the promotion audit naming the operator."""
product = find_by_gtin(session, gtin_key)
if product is None:
raise ValueError("GTIN_UNKNOWN_IN_KB")
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm", KbKnowledge.value == ncm))
if row is None:
raise ValueError("NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN")
if not is_official(ncm):
raise ValueError("NCM_NOT_VIGENTE_IN_OFFICIAL_DATASET")
add_evidence(session, product, "ncm", ncm, f"operator:{operator}"[:32], family=OPERATOR_FAMILY, info={"attested_by": operator, "attested_at": _now().isoformat(), "note": note[:200]})
for other in session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm", KbKnowledge.value != ncm)):
if OPERATOR_FAMILY not in ((other.evidence or {}).get("families") or {}):
other.status = STALE
_mark(other, "REJECTED_BY_OPERATOR")
return evaluate(session, product, "ncm", is_official)
def evaluate(session, product: KbProduct, key: str, is_official) -> dict:
"""Recomputes and PERSISTS the status of one field. Never 'majority wins': two current-valid values => CONFLICT."""
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key)))
if not rows:
return {"value": None, "status": UNVERIFIED, "families": [], "candidates": [], "rejected": [], "detail": {}}
valid = [r for r in rows if is_official(r.value)]
rejected = [r.value for r in rows if not is_official(r.value)]
for r in rows:
if r not in valid:
state = _official_period(session, r.value) if key == "ncm" else "HISTORICAL_INVALID"
r.status = STALE if state == "HISTORICAL_EXPIRED" else OBSERVED
_mark(r, state)
attested = {r.value for r in valid if OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {})}
if len(attested) == 1: # a human decision resolves the candidates: the others are recorded as rejected, not "outvoted"
winner = next(iter(attested))
for r in valid:
if r.value != winner:
r.status = STALE
_mark(r, "REJECTED_BY_OPERATOR")
valid = [r for r in valid if r.value == winner]
values = sorted({r.value for r in valid})
if len(values) > 1:
for r in valid:
r.status = CONFLICT
_mark(r, "HISTORICAL_CONFLICT")
return {"value": None, "status": CONFLICT, "families": [], "candidates": values, "rejected": rejected, "detail": {"clients": _lineage_summary(valid)["clients"]}}
if not values:
return {"value": None, "status": OBSERVED, "families": [], "candidates": [], "rejected": rejected, "detail": {}}
chosen = [r for r in valid if r.value == values[0]]
old = chosen[0].status
count, names = independent_count(chosen)
summary = _lineage_summary(chosen)
other_last = _lineage_summary([r for r in rows if r not in chosen])["last_year"]
superseded = other_last is not None and summary["last_year"] is not None and other_last >= summary["last_year"] # a different (expired) NCM is at least as recent: not stable
if superseded and CLIENT_FAMILY in names:
names.remove(CLIENT_FAMILY)
count -= 1
if any(OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen):
names = sorted(set(names) | {OPERATOR_FAMILY} | ({CLIENT_FAMILY} if any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen) else set()))
count = len(names)
providers = [n for n in names if n.startswith("PROVIDER:")]
already = any(r.status == VERIFIED_KB and r.validated_at for r in chosen)
has_client_data = any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen)
if count >= 2 or already:
status = VERIFIED_KB
elif providers and has_client_data:
status = EXTERNAL_CORROBORATED
elif CLIENT_FAMILY in names:
status = CORROBORATED
else:
status = OBSERVED
for r in chosen:
r.status = status
_mark(r, "HISTORICAL_CHANGED" if superseded else "HISTORICAL_STABLE_CURRENT")
if status == VERIFIED_KB and not r.validated_at:
r.validated_at = _now()
if status == VERIFIED_KB and old != VERIFIED_KB and key == "ncm":
from .resolver import dataset_versions
session.add(KbPromotionAudit(kb_product_id=product.id, gtin=product.gtin, field=key, value=values[0], old_status=old, new_status=status, families=names,
sources={"clients": summary["clients"], "documents": summary["docs"], "years": summary["years"], "providers": providers,
"operator": next((((r.evidence or {}).get("info") or {}).get("attested_by") for r in chosen if OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {})), None)},
official_dataset=dataset_versions(session).get("NCM", {}), rule_version=RULE_VERSION))
return {"value": values[0], "status": status, "families": names, "candidates": values, "rejected": rejected, "detail": summary}
def revalidate(session, is_official) -> dict:
"""After the OFFICIAL NCM dataset changed: never delete knowledge. Rows whose NCM is no longer vigente become STALE (STALE_REVIEW_REQUIRED, audited when they were VERIFIED_KB);
rows still vigente keep their status (VERIFIED_KB stays valid while the NCM is vigente)."""
from .resolver import dataset_versions
stale = still = 0
versions = dataset_versions(session).get("NCM", {})
for row in session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm")):
if is_official(row.value):
still += 1
if row.status == STALE:
row.status = OBSERVED # became vigente again: needs a fresh evaluate() to regain its level
continue
if row.status != STALE:
was = row.status
row.status = STALE
_mark(row, "HISTORICAL_EXPIRED")
row.evidence = {**(row.evidence or {}), "info": {**((row.evidence or {}).get("info") or {}), "stale_reason": "STALE_REVIEW_REQUIRED"}}
stale += 1
if was == VERIFIED_KB:
product = session.get(KbProduct, row.kb_product_id)
session.add(KbPromotionAudit(kb_product_id=row.kb_product_id, gtin=product.gtin if product else None, field="ncm", value=row.value, old_status=was, new_status=STALE,
families=sorted((row.evidence or {}).get("families") or {}), sources={"reason": "NCM_NO_LONGER_VIGENTE"}, official_dataset=versions, rule_version=RULE_VERSION))
session.flush()
return {"stale": stale, "still_vigente": still}
@@ -0,0 +1,165 @@
"""Read-only views for the panel: product sources status, official dataset freshness and enrichment coverage metrics. Never exposes tokens."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import func, select
from ..config import settings
from ..datasets.updater import last_runs
from ..models import Dataset, GtinLookupCache, ProductEnrichment, ProviderState, ProviderUsageDaily
from .providers import build_providers
STATUS_LABELS = {"ONLINE": "Online", "DEGRADED": "Instável", "RATE_LIMITED": "Limite atingido", "AUTH_ERROR": "Credencial recusada", "OFFLINE": "Indisponível", "NOT_CONFIGURED": "Não configurado"}
OFFICIAL_KINDS = (("NCM", "NCM (Siscomex)"), ("TIPI_IPI", "TIPI / IPI"), ("CEST", "CEST (CONFAZ)"), ("BA_ST_MVA", "ST/MVA Bahia"))
def _aware(dt):
return dt.replace(tzinfo=timezone.utc) if dt is not None and dt.tzinfo is None else dt
def provider_sources(session, providers=None) -> list[dict]:
providers = providers or build_providers(settings)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
rows = []
for provider in providers:
state = session.get(ProviderState, provider.name)
usage = session.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == provider.name, ProviderUsageDaily.day == today))
requests = usage.requests if usage else 0
lookups = (usage.cache_hits + usage.cache_misses) if usage else 0
status = ("NOT_CONFIGURED" if not provider.configured else (state.status if state else "ONLINE"))
rows.append({
"provider": provider.name, "configured": bool(provider.configured), "status": status, "status_label": STATUS_LABELS.get(status, status),
"used": requests, "limit": provider.daily_limit or (state.daily_limit if state else None), "rate_per_minute": provider.rate_per_minute,
"remaining": state.remaining if state and state.remaining is not None else ((provider.daily_limit - requests) if provider.daily_limit else None),
"quota_kind": state.quota_kind if state else ("LOCAL_ESTIMATE" if provider.daily_limit else "DOCUMENTED_RATE"),
"reset_at": _aware(state.reset_at) if state and state.reset_at else None,
"last_success_at": _aware(state.last_success_at) if state and state.last_success_at else None,
"last_error": state.last_error if state else None, "last_error_at": _aware(state.last_error_at) if state and state.last_error_at else None,
"latency_ms": state.last_latency_ms if state else None,
"cache_hit_rate": round(100 * usage.cache_hits / lookups) if usage and lookups else None,
"errors": usage.errors if usage else 0, "rate_limited": usage.rate_limited if usage else 0,
"found_today": usage.found if usage else 0, "ncm_found_today": usage.ncm_found if usage else 0, "cache_hits": usage.cache_hits if usage else 0,
"next_reset": _aware(state.reset_at) if state and state.reset_at else None, "identity_only": not getattr(provider, "provides_ncm", False),
})
return rows
def official_sources(session) -> list[dict]:
out = []
for kind, label in OFFICIAL_KINDS:
ds = session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE").order_by(Dataset.id.desc()).limit(1))
item = {"kind": kind, "label": label, "active": ds is not None, "records": ds.record_count if ds else None, "version": ds.version_label if ds else None,
"sha256": (ds.sha256[:12] if ds else None), "last_update": _aware(ds.downloaded_at) if ds else None, "status": "ATUALIZADA" if ds else "SEM DADOS", "error": None, "auto_refresh": kind == "NCM"}
if kind == "NCM":
recent = last_runs(session, "NCM", 1)
if recent:
last = recent[0]
item["last_check"], item["last_run_status"] = _aware(last.finished_at or last.started_at), last.status
if last.status == "FAILED":
item["status"], item["error"] = "FALHA NA ÚLTIMA ATUALIZAÇÃO (dados anteriores mantidos)", last.error
out.append(item)
return out
def coverage_metrics(session) -> dict:
total = session.scalar(select(func.count()).select_from(ProductEnrichment)) or 0
by = dict(session.execute(select(ProductEnrichment.status, func.count()).group_by(ProductEnrichment.status)).all())
with_gtin = session.scalar(select(func.count()).select_from(ProductEnrichment).where(ProductEnrichment.gtin.is_not(None))) or 0
rows = [r.decisions or {} for r in session.scalars(select(ProductEnrichment).limit(5000))]
states = {}
for d in rows:
states[d.get("state")] = states.get(d.get("state"), 0) + 1
def rate(n):
return round(100 * n / total, 1) if total else None
auto_ncm = sum(1 for d in rows if ((d.get("fields") or {}).get("CLASSIFICACAO_FISCAL") or {}).get("status") == "AUTO_FILL_SAFE")
usage = session.execute(select(func.sum(ProviderUsageDaily.requests), func.sum(ProviderUsageDaily.cache_hits), func.sum(ProviderUsageDaily.cache_misses), func.sum(ProviderUsageDaily.rate_limited))).one()
requests, hits, misses, limited = (int(x or 0) for x in usage)
return {"tracked": total, "GTIN_VALID_RATE": rate(with_gtin), "KB_HIT_RATE": rate(states.get("EXACT_GTIN_VERIFIED", 0) + states.get("EXACT_KB_NAME_VERIFIED", 0)),
"EXTERNAL_LOOKUP_RATE": round(100 * requests / (requests + hits), 1) if (requests + hits) else None,
"PRODUCT_FOUND_RATE": rate(total - states.get("NOT_FOUND", 0) - states.get("INVALID_GTIN", 0) - states.get("AMBIGUOUS", 0)),
"NCM_AUTO_FILL_SAFE_RATE": rate(auto_ncm), "NAME_ONLY_RESOLUTION_RATE": rate(states.get("EXACT_KB_NAME_VERIFIED", 0)), "AMBIGUOUS_RATE": rate(states.get("AMBIGUOUS", 0)),
"CONFLICT_RATE": rate(states.get("CONFLICT", 0)), "PROVIDER_QUOTA_BLOCK_RATE": round(100 * limited / requests, 1) if requests else None, "states": states, "by_status": by,
"provider_cache_entries": session.scalar(select(func.count()).select_from(GtinLookupCache)) or 0, "provider_cache_hit_rate": round(100 * hits / (hits + misses), 1) if (hits + misses) else None}
def _candidate_cards(session, products) -> list[dict]:
from ..models import Dataset, KbKnowledge, NcmEntry
out = []
for prod in products:
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == prod.id, KbKnowledge.knowledge_key == "ncm")))
cands = []
for r in rows:
ev = r.evidence or {}
text = session.scalar(select(NcmEntry.description).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == r.value).limit(1))
cands.append({"ncm": r.value, "official_text": text, "status": r.status, "clients": sorted(ev.get("clients", [])), "documents": sum(int(l.get("docs", 0)) for l in (ev.get("lineages") or {}).values()),
"providers": sorted(f for f in (ev.get("families") or {}) if f.startswith("PROVIDER:")), "attested": (ev.get("info") or {}).get("attested_by")})
status = "VERIFIED_KB" if any(c["status"] == "VERIFIED_KB" for c in cands) else ("CONFLICT" if any(c["status"] == "CONFLICT" for c in cands) else (cands[0]["status"] if cands else "OBSERVED"))
out.append({"gtin": (prod.gtin or "")[-13:], "gtin_key": prod.gtin, "description": (prod.normalized_description or "")[:70], "status": status, "candidates": [c for c in cands if c["status"] != "STALE" or c["attested"] is None][:4]})
return out
def kb_search_ids(session, query: str) -> list[int]:
"""GLOBAL, server-side match over the whole KB (never limited to a rendered page): GTIN exact, or every normalized query word present somewhere among the
product's known names (any alias row OR its own normalized_description - words can come from different aliases of the same product). Deliberately lenient
(a human exploring the KB, e.g. "COCA COLA LATA 350" without the unit, must still find "COCA-COLA ORIGINAL LATA 350 ML"); this is DIFFERENT from
`kb.alias_match`, the strict rule that authorizes an automatic fiscal write, which stays untouched."""
import re
from ..models import KbKnowledge, KbProduct
from . import kb as kbm, textnorm
q = (query or "").strip()
if not q:
return []
digits = "".join(ch for ch in q if ch.isdigit())
if len(digits) >= 8 and re.fullmatch(r"[\d\s.\-]+", q):
p = kbm.find_by_gtin(session, digits.zfill(14))
return [p.id] if p else []
tokens = [t for t in textnorm.tokens(q) if t not in textnorm.MARKETING]
if not tokens:
return []
ids: set[int] | None = None
for token in tokens: # SQL ILIKE narrows the candidates (portable: Postgres in production, SQLite in tests); the exact whole-word
like = f"%{token}%" # check happens in Python so "ML" never matches inside "SIMPLES", etc.
matched = {pid for pid, val in session.execute(select(KbKnowledge.kb_product_id, KbKnowledge.value).where(KbKnowledge.knowledge_key == "alias", KbKnowledge.value.ilike(like))) if token in (val or "").split()}
matched |= {pid for pid, val in session.execute(select(KbProduct.id, KbProduct.normalized_description).where(KbProduct.normalized_description.ilike(like))) if token in (val or "").split()}
ids = matched if ids is None else (ids & matched)
if not ids:
return []
return sorted(ids)
def kb_search(session, query: str, *, page: int = 1, page_size: int = 10) -> dict:
"""query/filter GLOBAL over the whole KB -> count -> order -> pagination (never the other way round)."""
from ..models import KbProduct
ids = kb_search_ids(session, query)
total = len(ids)
page = max(1, page)
pages = max(1, -(-total // page_size))
page = min(page, pages)
page_ids = ids[(page - 1) * page_size: page * page_size]
products = [session.get(KbProduct, i) for i in page_ids]
return {"results": _candidate_cards(session, products), "total": total, "page": page, "pages": pages, "page_size": page_size}
def kb_summary(session, query: str | None = None, page: int = 1) -> dict:
"""Compact Knowledge Base panel: known products, statuses of the NCM knowledge, hits today, and the latest conflicts with their evidence (no dashboard of its own)."""
from ..models import KbKnowledge, KbProduct
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
products = session.scalar(select(func.count()).select_from(KbProduct).where(KbProduct.gtin.is_not(None))) or 0
by = dict(session.execute(select(KbKnowledge.status, func.count(func.distinct(KbKnowledge.kb_product_id))).where(KbKnowledge.knowledge_key == "ncm").group_by(KbKnowledge.status)).all())
hits = session.scalar(select(func.coalesce(func.sum(ProviderUsageDaily.kb_hits), 0)).where(ProviderUsageDaily.day == today)) or 0
conflicts = []
rows = session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.status == "CONFLICT").order_by(KbKnowledge.id.desc()).limit(60)).all()
grouped = {}
for r in rows:
grouped.setdefault(r.kb_product_id, []).append(r)
for pid, items in list(grouped.items())[:5]:
prod = session.get(KbProduct, pid)
conflicts.append({"gtin": (prod.gtin or "")[-13:], "description": (prod.normalized_description or "")[:60],
"candidates": [{"ncm": i.value, "clients": sorted({c for c in (i.evidence or {}).get("clients", [])}), "documents": sum(int(l.get("docs", 0)) for l in ((i.evidence or {}).get("lineages") or {}).values()),
"providers": sorted(f for f in ((i.evidence or {}).get("families") or {}) if f.startswith("PROVIDER:"))} for i in items]})
return {"products": products, "verified": by.get("VERIFIED_KB", 0), "corroborated": by.get("CORROBORATED", 0) + by.get("EXTERNAL_CORROBORATED", 0), "observed": by.get("OBSERVED", 0),
"conflicts": by.get("CONFLICT", 0), "stale": by.get("STALE", 0), "hits_today": hits, "recent_conflicts": conflicts,
"search": kb_search(session, query, page=page) if query else {"results": [], "total": 0, "page": 1, "pages": 1, "page_size": 10}}
@@ -0,0 +1,27 @@
"""ERP observations -> KB evidence. A client observation is EVIDENCE of family CLIENT_OBSERVATION; it never becomes VERIFIED_KB by itself
(needs the official dataset AND a second independent family, or >= 2 distinct clients + another family - see kb.evaluate)."""
from __future__ import annotations
from ..classification import normalize_code
from . import kb
from .gtin import normalize_gtin
def observe(session, rows, client_label: str, is_official) -> dict:
"""rows: iterable of {"GTIN","PRODUTO","NCM"}. Only VALID_GTIN rows with an OFFICIAL 8-digit NCM are recorded. Returns counters."""
stats = {"rows": 0, "valid_gtin": 0, "with_official_ncm": 0, "recorded": 0}
for row in rows:
stats["rows"] += 1
g = normalize_gtin(row.get("GTIN"))
if g["status"] != "VALID_GTIN":
continue
stats["valid_gtin"] += 1
ncm = normalize_code(row.get("NCM"))
if len(ncm) != 8 or not is_official(ncm):
continue
stats["with_official_ncm"] += 1
product = kb.upsert_product(session, g["key"], row.get("PRODUTO"))
kb.add_evidence(session, product, "ncm", ncm, "client:observation", family=kb.CLIENT_FAMILY, client_label=client_label)
stats["recorded"] += 1
session.flush()
return stats
@@ -0,0 +1,27 @@
"""TECHNICAL_PLACEHOLDER semantics for the product enrichment.
The SGV refuses to save a product without an NCM, so operators type a technical filler to get past the form. That filler is NOT a human fiscal decision: it means "not classified yet".
Explicit allowlist PER FIELD (never generalised: "0", "99999999", "SEM NCM" ... are NOT placeholders until the real SGV behaviour is confirmed). The Agent carries the same table
(agent/src/gti_fiscal_agent/enrichment.py PLACEHOLDERS); a test asserts both are identical.
"""
import re
PRODUCT_ENRICHMENT_PLACEHOLDERS = {"CLASSIFICACAO_FISCAL": frozenset({"00000000"})}
def _norm(field: str, value) -> str:
text = "" if value is None else str(value).strip()
return re.sub(r"\D", "", text) if field == "CLASSIFICACAO_FISCAL" else text # 0000.00.00 and 00000000 are the same NCM filler
def is_placeholder(field: str, value) -> bool:
return _norm(field, value) in PRODUCT_ENRICHMENT_PLACEHOLDERS.get(field, frozenset())
def is_blank(value) -> bool:
return not ("" if value is None else str(value)).strip()
def is_writable_empty(field: str, value) -> bool:
"""True when the field is empty OR holds the approved technical placeholder. A real value (even a wrong one) is never writable here."""
return is_blank(value) or is_placeholder(field, value)
+338
View File
@@ -0,0 +1,338 @@
"""Free product-identity providers behind the Hub. Agents never talk to these; the Hub does, through the quota manager.
Documentation snapshots consulted 2026-09-25 (see docs/product_enrichment.md):
- UPCitemdb Explorer/trial: https://api.upcitemdb.com/prod/trial/lookup?upc=... - no key, 100 combined requests/day, burst 15 lookup / 5 search per 30 s,
headers X-RateLimit-Limit / -Remaining / -Reset. Product IDENTITY only (title/brand/category); no fiscal data.
- Open Food Facts: https://world.openfoodfacts.org/api/v2/product/{barcode} - 15 read req/min/IP, 10 search req/min/IP, identifiable User-Agent, ODbL data. Foods only; no fiscal data.
- Cosmos / Bluesoft: https://api.cosmos.bluesoft.com.br/gtins/{gtin} - X-Cosmos-Token (free token, 25 queries/day), returns NCM/CEST/brand/description. Optional: without a token it is NOT_CONFIGURED.
A provider result is EVIDENCE. Only the fiscal engine + official datasets decide what is written.
"""
from __future__ import annotations
import hashlib
import json
import time
from dataclasses import dataclass, field
import httpx
USER_AGENT = "GTIFiscalHub/1.0 (+https://gtifiscal.gtigrupo.com.br; product identity lookup)"
class ProviderError(Exception):
"""kind: RATE_LIMITED | AUTH_ERROR | TIMEOUT | SERVER_ERROR | NETWORK | BAD_RESPONSE"""
def __init__(self, kind: str, detail: str = "", headers: dict | None = None, retry_after: int | None = None):
super().__init__(f"{kind}: {detail}")
self.kind, self.detail, self.headers, self.retry_after = kind, detail, headers or {}, retry_after
@dataclass
class ProviderProduct:
provider: str
gtin: str
found: bool
title: str | None = None
brand: str | None = None
category: str | None = None
quantity: str | None = None
ncm: str | None = None
cest: str | None = None
raw_hash: str = ""
latency_ms: int = 0
headers: dict = field(default_factory=dict)
def as_data(self) -> dict:
return {k: v for k, v in {"title": self.title, "brand": self.brand, "category": self.category, "quantity": self.quantity, "ncm": self.ncm, "cest": self.cest}.items() if v}
def _digits(value) -> str | None:
text = "".join(ch for ch in str(value or "") if ch.isdigit())
return text or None
class Provider:
name = "base"
paid = False
supports_search = False
daily_limit: int | None = None
rate_per_minute: int | None = None
burst: tuple[int, int] | None = None # (requests, seconds)
provides_ncm = False
def __init__(self, client: httpx.Client | None = None, token: str | None = None):
self.client = client or httpx.Client(timeout=4.0, headers={"User-Agent": USER_AGENT})
self._token = token
@property
def configured(self) -> bool:
return True
def _get(self, url: str, **kwargs) -> httpx.Response:
started = time.monotonic()
try:
response = self.client.get(url, **kwargs)
except httpx.TimeoutException as error:
raise ProviderError("TIMEOUT", type(error).__name__) from error
except httpx.HTTPError as error:
raise ProviderError("NETWORK", type(error).__name__) from error
response.latency_ms = int((time.monotonic() - started) * 1000) # type: ignore[attr-defined]
headers = {k.lower(): v for k, v in response.headers.items()}
if response.status_code == 429:
retry = headers.get("retry-after")
raise ProviderError("RATE_LIMITED", "HTTP 429", headers, int(retry) if retry and retry.isdigit() else None)
if response.status_code in (401, 403):
raise ProviderError("AUTH_ERROR", f"HTTP {response.status_code}", headers)
if response.status_code >= 500:
raise ProviderError("SERVER_ERROR", f"HTTP {response.status_code}", headers)
return response
def lookup_gtin(self, gtin: str) -> ProviderProduct: # pragma: no cover - interface
raise NotImplementedError
def search_description(self, text: str) -> list[ProviderProduct]:
return []
def health(self) -> dict:
return {"configured": self.configured}
class UpcItemDb(Provider):
name = "upcitemdb"
supports_search = True
daily_limit = 100
burst = (15, 30)
def lookup_gtin(self, gtin: str) -> ProviderProduct:
response = self._get("https://api.upcitemdb.com/prod/trial/lookup", params={"upc": gtin})
if response.status_code == 404:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=dict(response.headers))
if response.status_code != 200:
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
try:
body = response.json()
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
items = body.get("items") or []
headers = {k.lower(): v for k, v in response.headers.items()}
if not items:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
item = items[0]
raw = hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
return ProviderProduct(self.name, gtin, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
quantity=item.get("size") or item.get("weight") or None, raw_hash=raw, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
def search_description(self, text: str) -> list[ProviderProduct]:
response = self._get("https://api.upcitemdb.com/prod/trial/search", params={"s": text, "match_mode": "1", "type": "product"})
if response.status_code != 200:
return []
out = []
for item in (response.json().get("items") or [])[:5]:
code = _digits(item.get("ean") or item.get("upc")) or ""
out.append(ProviderProduct(self.name, code, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
raw_hash=hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()))
return out
class OpenFoodFacts(Provider):
name = "openfoodfacts"
supports_search = True
rate_per_minute = 15
host = "world.openfoodfacts.org"
def lookup_gtin(self, gtin: str) -> ProviderProduct:
response = self._get(f"https://{self.host}/api/v2/product/{gtin}", params={"fields": "code,product_name,brands,quantity,categories,generic_name"})
headers = {k.lower(): v for k, v in response.headers.items()}
if response.status_code == 404:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
if response.status_code != 200:
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
try:
body = response.json()
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
product = body.get("product") or {}
if body.get("status") != 1 or not product:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
title = product.get("product_name") or product.get("generic_name")
return ProviderProduct(self.name, gtin, bool(title), title=title, brand=(product.get("brands") or "").split(",")[0].strip() or None, category=(product.get("categories") or "").split(",")[0].strip() or None,
quantity=product.get("quantity"), raw_hash=hashlib.sha256(json.dumps(product, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
latency_ms=getattr(response, "latency_ms", 0), headers=headers)
def search_description(self, text: str) -> list[ProviderProduct]:
response = self._get("https://world.openfoodfacts.org/cgi/search.pl", params={"search_terms": text, "json": "1", "page_size": "5", "fields": "code,product_name,brands,quantity"})
if response.status_code != 200:
return []
return [ProviderProduct(self.name, _digits(p.get("code")) or "", True, title=p.get("product_name"), brand=(p.get("brands") or "").split(",")[0].strip() or None, quantity=p.get("quantity"))
for p in (response.json().get("products") or [])[:5] if p.get("product_name")]
class OpenProductsFacts(OpenFoodFacts):
"""Same open (ODbL) platform and API as Open Food Facts, for non-food goods. Identity only (no NCM)."""
name = "openproductsfacts"
host = "world.openproductsfacts.org"
supports_search = False
class OpenBeautyFacts(OpenFoodFacts):
name = "openbeautyfacts"
host = "world.openbeautyfacts.org"
supports_search = False
class Cosmos(Provider):
name = "cosmos"
supports_search = True
daily_limit = 25
provides_ncm = True
@property
def configured(self) -> bool:
return bool(self._token)
def _headers(self) -> dict:
return {"X-Cosmos-Token": self._token or "", "User-Agent": USER_AGENT, "Content-Type": "application/json"}
def lookup_gtin(self, gtin: str) -> ProviderProduct:
if not self.configured:
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
response = self._get(f"https://api.cosmos.bluesoft.com.br/gtins/{gtin}", headers=self._headers())
headers = {k.lower(): v for k, v in response.headers.items()}
if response.status_code == 404:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
if response.status_code != 200:
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
try:
body = response.json()
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
ncm = _digits((body.get("ncm") or {}).get("code"))
cest = _digits((body.get("cest") or {}).get("code"))
brand = (body.get("brand") or {}).get("name") if isinstance(body.get("brand"), dict) else body.get("brand")
return ProviderProduct(self.name, gtin, True, title=body.get("description"), brand=brand, category=(body.get("gpc") or {}).get("description"), quantity=body.get("net_weight") and str(body.get("net_weight")),
ncm=ncm, cest=cest, raw_hash=hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), latency_ms=getattr(response, "latency_ms", 0), headers=headers)
def search_description(self, text: str) -> list[ProviderProduct]:
if not self.configured:
return []
response = self._get("https://api.cosmos.bluesoft.com.br/products", params={"query": text}, headers=self._headers())
if response.status_code != 200:
return []
out = []
for item in (response.json().get("products") or [])[:5]:
out.append(ProviderProduct(self.name, _digits(item.get("gtin")) or "", True, title=item.get("description"), brand=(item.get("brand") or {}).get("name") if isinstance(item.get("brand"), dict) else None,
ncm=_digits((item.get("ncm") or {}).get("code")) if isinstance(item.get("ncm"), dict) else None))
return out
class Oscbr(Provider):
"""OSCBR / RSC Sistemas GTIN API (https://gtin.rscsistemas.com.br). Free plan (no card): 50 calls/day, 5/min; account created BY THE OPERATOR, credentials only in the Hub env
(OSCBR_USER / OSCBR_PASSWORD). Bearer token lasts 1 h and is renewed automatically. Optional: NOT_CONFIGURED without credentials (no call is made)."""
name = "oscbr"
rate_per_minute = 5 # free plan as currently documented: 50 calls/day, 5/min (conservative: the lower figure wins)
daily_limit = 50
# ROLE = IDENTITY. The operator-provided V3 contract (GET /api/v3/gtin/{ean}) documents ean, ean_tipo, nome, marca, categoria, link_foto - NO ncm/cest. The public landing page mentions NCM/CEST
# and the legacy open-source client shows an `ncm` integer (leading zero lost), but the V3 documentation is behind the login, so NCM is UNPROVEN: the provider does not count as an NCM family
# (provides_ncm=False). If a real authenticated V3 answer ever carries `ncm`, it is stored as evidence but the role only changes after that proof is reviewed.
provides_ncm = False
base = "https://gtin.rscsistemas.com.br"
def __init__(self, client=None, token=None, user=None, password=None):
super().__init__(client, token) # token = a ready Bearer token (OSCBR_TOKEN); preferred over storing the login
self._user, self._password = user, password
self._bearer, self._bearer_until = None, 0.0
@property
def configured(self) -> bool:
return bool(self._token or (self._user and self._password))
def _auth(self) -> str:
if self._token:
return self._token
if self._bearer and time.time() < self._bearer_until:
return self._bearer
try:
response = self.client.post(f"{self.base}/oauth/token", auth=(self._user, self._password), headers={"User-Agent": USER_AGENT})
except httpx.TimeoutException as error:
raise ProviderError("TIMEOUT", type(error).__name__) from error
except httpx.HTTPError as error:
raise ProviderError("NETWORK", type(error).__name__) from error
if response.status_code in (400, 401, 403):
raise ProviderError("AUTH_ERROR", f"HTTP {response.status_code}")
if response.status_code == 429:
raise ProviderError("RATE_LIMITED", "HTTP 429")
if response.status_code >= 500 or response.status_code != 200:
raise ProviderError("SERVER_ERROR", f"HTTP {response.status_code}")
try:
token = response.json().get("token") or response.json().get("access_token")
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
if not token:
raise ProviderError("BAD_RESPONSE", "no token")
self._bearer, self._bearer_until = token, time.time() + 55 * 60
return token
def lookup_gtin(self, gtin: str) -> ProviderProduct:
if not self.configured:
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
response = self._get(f"{self.base}/api/v3/gtin/{gtin}", headers={"Authorization": f"Bearer {self._auth()}", "User-Agent": USER_AGENT})
headers = {k.lower(): v for k, v in response.headers.items()}
if response.status_code == 404:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
if response.status_code != 200:
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
try:
body = response.json()
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
if isinstance(body, list):
body = body[0] if body else {}
if not body or not (body.get("nome") or body.get("ncm")):
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
ncm = _digits(body.get("ncm"))
if ncm and len(ncm) == 7: # numeric JSON drops the leading zero (7133399 is NCM 07133399)
ncm = ncm.zfill(8)
return ProviderProduct(self.name, gtin, True, title=body.get("nome"), brand=body.get("marca"), category=body.get("categoria"), ncm=ncm if len(ncm or "") == 8 else None, cest=_digits(body.get("cest")) or None,
raw_hash=hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), latency_ms=getattr(response, "latency_ms", 0), headers=headers)
class Kodebar(Provider):
"""Kodebar (Korven Sistemas) - IDENTITY source only (name, brand, thumbnail; NO NCM). Free 'Teste' plan: 50 queries/day, no card, personal API key created by the operator
(KODEBAR_API_KEY in the Hub env). Terms (2026-09-26): integration into our own multi-user system and internal caching are allowed; redistribution/resale is not.
429 above the quota, no overage billing. NOT_CONFIGURED without a key: no request is made."""
name = "kodebar"
daily_limit = 50
rate_per_minute = 30
base = "https://kodebar.korvensistemas.com.br"
@property
def configured(self) -> bool:
return bool(self._token)
def lookup_gtin(self, gtin: str) -> ProviderProduct:
if not self.configured:
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
response = self._get(f"{self.base}/gtin/lookup", params={"gtin": gtin}, headers={"X-API-Key": self._token or "", "User-Agent": USER_AGENT})
headers = {k.lower(): v for k, v in response.headers.items()}
if response.status_code == 404:
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
if response.status_code != 200:
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
try:
body = response.json()
except ValueError as error:
raise ProviderError("BAD_RESPONSE", "not JSON") from error
if not isinstance(body, dict) or not body.get("nome"):
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
return ProviderProduct(self.name, gtin, True, title=body.get("nome"), brand=body.get("marca"), raw_hash=hashlib.sha256(json.dumps({k: body.get(k) for k in ("gtin", "nome", "marca")}, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
latency_ms=getattr(response, "latency_ms", 0), headers=headers)
def build_providers(settings, client: httpx.Client | None = None) -> list[Provider]:
"""Fixed order chosen from the benchmark (docs/product_enrichment.md): Cosmos then OSCBR (fiscal-capable) first when configured, then UPCitemdb, then the Open *Facts family (food, general products, beauty; identity only). Paid providers do not exist here."""
providers: list[Provider] = [Oscbr(client, getattr(settings, "oscbr_token", None), getattr(settings, "oscbr_user", None), getattr(settings, "oscbr_password", None)), Kodebar(client, getattr(settings, "kodebar_api_key", None)), UpcItemDb(client), OpenFoodFacts(client), OpenProductsFacts(client), OpenBeautyFacts(client)]
if getattr(settings, "cosmos_enabled", False): # Cosmos is OFF by default (account unavailable); only an explicit COSMOS_ENABLED=1 puts it back in the chain
providers.insert(0, Cosmos(client, getattr(settings, "cosmos_token", None)))
if any(p.paid for p in providers) and not getattr(settings, "allow_paid_providers", False):
raise RuntimeError("paid providers are not allowed (ALLOW_PAID_PROVIDERS=false)")
return providers
+244
View File
@@ -0,0 +1,244 @@
"""Central quota / circuit breaker / cache / single-flight for external product providers. Quota belongs to the HUB: 70 Agents never spend it uncoordinated."""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from ..models import GtinLookupCache, ProviderState, ProviderUsageDaily
from .providers import Provider, ProviderError, ProviderProduct
POSITIVE_TTL = timedelta(days=30)
NEGATIVE_TTL = timedelta(hours=24)
CIRCUIT_BASE = timedelta(seconds=60)
CIRCUIT_MAX = timedelta(minutes=15)
FAILURES_TO_OPEN = 3
_windows: dict[str, deque] = defaultdict(deque) # per-process sliding windows (burst / per-minute governors)
_flights: dict[tuple, threading.Lock] = {}
_flights_guard = threading.Lock()
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _aware(value):
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def next_utc_midnight(now: datetime) -> datetime:
return (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
@dataclass
class Outcome:
provider: str
state: str # CACHE_HIT | FOUND | NOT_FOUND | SKIPPED | ERROR
product: ProviderProduct | None = None
reason: str | None = None # NOT_CONFIGURED | CIRCUIT_OPEN | RATE_LIMITED | RATE_WINDOW | TIMEOUT | AUTH_ERROR | NETWORK | SERVER_ERROR | ...
retry_at: datetime | None = None
from_cache: bool = False
class QuotaManager:
def __init__(self, session, now=utcnow):
self.session, self.now = session, now
# ---- state -------------------------------------------------------------------------------------------------------------------------------------
def state(self, provider: Provider) -> ProviderState:
row = self.session.get(ProviderState, provider.name)
if row is None:
row = ProviderState(provider=provider.name, configured=provider.configured, status="ONLINE" if provider.configured else "NOT_CONFIGURED",
daily_limit=provider.daily_limit, rate_per_minute=provider.rate_per_minute,
quota_kind="LOCAL_ESTIMATE" if provider.daily_limit else "DOCUMENTED_RATE")
self.session.add(row)
self.session.flush()
row.configured = provider.configured
if not provider.configured:
row.status = "NOT_CONFIGURED"
elif row.status == "NOT_CONFIGURED":
row.status = "ONLINE"
row.daily_limit = provider.daily_limit
row.rate_per_minute = provider.rate_per_minute
return row
def usage(self, provider_name: str, day: str | None = None) -> ProviderUsageDaily:
day = day or self.now().strftime("%Y-%m-%d")
row = self.session.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == provider_name, ProviderUsageDaily.day == day))
if row is None:
row = ProviderUsageDaily(provider=provider_name, day=day)
self.session.add(row)
self.session.flush()
return row
# ---- decision ----------------------------------------------------------------------------------------------------------------------------------
def allow(self, provider: Provider, purpose: str = "OPERATIONAL", reserve_percent: int = 0) -> tuple[bool, str | None, datetime | None]:
state, now = self.state(provider), self.now()
if not provider.configured:
return False, "NOT_CONFIGURED", None
if state.circuit_open_until and _aware(state.circuit_open_until) > now:
return False, "CIRCUIT_OPEN", _aware(state.circuit_open_until)
usage = self.usage(provider.name)
if provider.daily_limit and state.quota_kind == "REMOTE_QUOTA" and state.remaining is not None and state.remaining <= 0 and state.reset_at and _aware(state.reset_at) > now:
return False, "RATE_LIMITED", _aware(state.reset_at)
if provider.daily_limit and usage.requests >= provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
state.status = "RATE_LIMITED"
return False, "RATE_LIMITED", state.reset_at and _aware(state.reset_at) or next_utc_midnight(now)
if purpose == "BACKFILL" and provider.daily_limit: # backfill may only use what is left above the operational reserve
used = usage.requests
if state.quota_kind == "REMOTE_QUOTA" and state.remaining is not None:
used = provider.daily_limit - state.remaining
if used >= provider.daily_limit * (100 - reserve_percent) / 100:
return False, "OPERATIONAL_RESERVE", state.reset_at and _aware(state.reset_at) or next_utc_midnight(now)
window = (provider.burst[1] if provider.burst else 60)
limit = (provider.burst[0] if provider.burst else provider.rate_per_minute)
if limit:
q, cutoff = _windows[provider.name], time.monotonic() - window
while q and q[0] < cutoff:
q.popleft()
if len(q) >= limit:
return False, "RATE_WINDOW", now + timedelta(seconds=max(1, int(window - (time.monotonic() - q[0]))))
return True, None, None
# ---- accounting --------------------------------------------------------------------------------------------------------------------------------
def _remote_quota(self, state: ProviderState, headers: dict):
remaining, limit, reset = headers.get("x-ratelimit-remaining"), headers.get("x-ratelimit-limit"), headers.get("x-ratelimit-reset")
if remaining is not None and str(remaining).isdigit():
state.remaining = int(remaining)
state.quota_kind = "REMOTE_QUOTA"
if limit and str(limit).isdigit():
state.daily_limit = int(limit)
if reset and str(reset).isdigit():
state.reset_at = datetime.fromtimestamp(int(reset), tz=timezone.utc)
def _sent(self, provider: Provider):
q = _windows[provider.name]
q.append(time.monotonic())
def record_ok(self, provider: Provider, result: ProviderProduct):
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
self._sent(provider)
usage.requests += 1
usage.success += 1
if not result.found:
usage.not_found += 1
else:
usage.found += 1
if result.ncm:
usage.ncm_found += 1
state.consecutive_failures = 0
state.circuit_open_until = None
state.status = "ONLINE"
state.last_request_at = state.last_success_at = now
state.last_latency_ms = result.latency_ms or None
self._remote_quota(state, {k.lower(): v for k, v in (result.headers or {}).items()})
if provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
state.remaining = max(0, provider.daily_limit - usage.requests)
state.reset_at = next_utc_midnight(now)
self.session.commit()
def record_error(self, provider: Provider, error: ProviderError):
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
self._sent(provider)
usage.requests += 1
state.last_request_at, state.last_error_at = now, now
state.last_error = f"{error.kind}: {error.detail}"[:160]
state.consecutive_failures += 1
headers = {k.lower(): v for k, v in (error.headers or {}).items()}
self._remote_quota(state, headers)
if error.kind == "RATE_LIMITED":
usage.rate_limited += 1
state.status = "RATE_LIMITED"
wait = timedelta(seconds=error.retry_after) if error.retry_after else None
state.circuit_open_until = now + (wait or (state.reset_at and max(timedelta(minutes=15), _aware(state.reset_at) - now) or timedelta(minutes=60)))
if state.reset_at is None:
state.reset_at = state.circuit_open_until
elif error.kind == "AUTH_ERROR":
usage.errors += 1
state.status = "AUTH_ERROR"
state.circuit_open_until = now + timedelta(hours=1)
else:
if error.kind == "TIMEOUT":
usage.timeouts += 1
usage.errors += 1
state.status = "DEGRADED"
if state.consecutive_failures >= FAILURES_TO_OPEN:
delay = min(CIRCUIT_MAX, CIRCUIT_BASE * (2 ** (state.consecutive_failures - FAILURES_TO_OPEN)))
state.circuit_open_until = now + delay
state.status = "OFFLINE"
self.session.commit()
def record_kb_hit(self):
"""A resolution answered by the KB alone (no provider asked): counted once per day under the pseudo-provider 'kb'."""
self.usage("kb").kb_hits += 1
def record_cache(self, provider_name: str, hit: bool):
usage = self.usage(provider_name)
if hit:
usage.cache_hits += 1
else:
usage.cache_misses += 1
class ProviderGateway:
"""lookup(provider, gtin): cache -> single-flight -> quota/circuit -> HTTP -> account -> cache."""
def __init__(self, session, providers: list[Provider], now=utcnow):
self.session, self.providers, self.now = session, providers, now
self.quota = QuotaManager(session, now)
def _cached(self, provider: Provider, gtin: str) -> ProviderProduct | None | bool:
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
if row is None or _aware(row.expires_at) <= self.now():
return False
if not row.found:
return None
d = row.data or {}
return ProviderProduct(provider.name, gtin, True, title=d.get("title"), brand=d.get("brand"), category=d.get("category"), quantity=d.get("quantity"), ncm=d.get("ncm"), cest=d.get("cest"), raw_hash=d.get("raw_hash", ""))
def _store(self, provider: Provider, gtin: str, product: ProviderProduct):
now = self.now()
ttl = POSITIVE_TTL if product.found else NEGATIVE_TTL
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
data = {**product.as_data(), "raw_hash": product.raw_hash}
if row is None:
self.session.add(GtinLookupCache(gtin=gtin, provider=provider.name, found=product.found, data=data, fetched_at=now, expires_at=now + ttl))
else:
row.found, row.data, row.fetched_at, row.expires_at = product.found, data, now, now + ttl
self.session.commit()
def lookup(self, provider: Provider, gtin: str, purpose: str = "OPERATIONAL", reserve_percent: int = 0) -> Outcome:
hit = self._cached(provider, gtin)
if hit is not False:
self.quota.record_cache(provider.name, True)
self.session.commit()
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
with _flights_guard:
lock = _flights.setdefault((provider.name, gtin), threading.Lock())
with lock: # 10 installations, same EAN -> 1 external call, the rest wait for the same answer
self.session.expire_all()
hit = self._cached(provider, gtin)
if hit is not False:
self.quota.record_cache(provider.name, True)
self.session.commit()
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
self.quota.record_cache(provider.name, False)
ok, reason, retry_at = self.quota.allow(provider, purpose, reserve_percent)
if not ok:
self.session.commit()
return Outcome(provider.name, "SKIPPED", None, reason, retry_at)
try:
product = provider.lookup_gtin(gtin)
except ProviderError as error:
self.quota.record_error(provider, error)
return Outcome(provider.name, "ERROR", None, error.kind, self.quota.state(provider).circuit_open_until)
self.quota.record_ok(provider, product)
self._store(provider, gtin, product)
return Outcome(provider.name, "FOUND" if product.found else "NOT_FOUND", product if product.found else None)
+250
View File
@@ -0,0 +1,250 @@
"""Product resolver: GTIN/name -> KB -> providers -> official NCM validation -> existing fiscal engine, decided FIELD BY FIELD.
Deterministic. No AI, no scores: discrete states only.
Resolution states: EXACT_GTIN_VERIFIED, EXACT_KB_NAME_VERIFIED, MULTI_SOURCE_MATCH, SINGLE_WEAK_SOURCE, AMBIGUOUS, CONFLICT, NOT_FOUND
(+ operational: INVALID_GTIN, PENDING_PROVIDER_QUOTA, PENDING_PROVIDERS_OFFLINE).
Field decisions: AUTO_FILL_SAFE | SUGGESTION_ONLY | CONFLICT | INSUFFICIENT_EVIDENCE | ALREADY_SET | NOT_APPLICABLE.
"""
from __future__ import annotations
import time
from datetime import date
from sqlalchemy import select
from ..classification import Classifier, normalize_code
from ..datasets.service import DatasetService
from ..models import Dataset, NcmEntry
from . import fiscal_completion, kb, textnorm
from .gtin import normalize_gtin
from .placeholders import is_placeholder, is_writable_empty
from .quota import ProviderGateway
AUTO, SUGGEST, CONFLICT, INSUFFICIENT, ALREADY, NA = "AUTO_FILL_SAFE", "SUGGESTION_ONLY", "CONFLICT", "INSUFFICIENT_EVIDENCE", "ALREADY_SET", "NOT_APPLICABLE"
FIELDS = ("CLASSIFICACAO_FISCAL", "CEST", "IPI")
def _parse_br(text: str | None) -> date | None:
try:
d, m, y = str(text).split("/")
return date(int(y), int(m), int(d))
except (ValueError, TypeError):
return None
def ncm_is_official(session, code: str, today: date | None = None) -> bool:
"""8-digit code present in the ACTIVE official NCM dataset and vigente today (dd/mm/yyyy, 31/12/9999 = open)."""
code = normalize_code(code)
if len(code) != 8:
return False
today = today or date.today()
rows = session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == code)).all()
for row in rows:
start, end = _parse_br(row.valid_from), _parse_br(row.valid_to)
if (start is None or start <= today) and (end is None or end >= today):
return True
return False
class _ScopedDatasets(DatasetService):
"""The Classifier only needs the ONE exact NCM row here; loading the whole 15k-row catalogue on every product would cost ~300 ms of the 5 s budget."""
def __init__(self, session, code: str):
super().__init__(session)
self.code = code
def active_entries(self):
return self.session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == self.code)).all()
def ncm_exists_any(session, code: str, *, current: bool, today: date | None = None) -> bool:
"""current=True: vigente today; current=False: present in the ACTIVE dataset with any validity window (i.e. known but not vigente)."""
code = normalize_code(code)
if len(code) != 8:
return False
if current:
return ncm_is_official(session, code, today)
return session.scalar(select(NcmEntry.id).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == code).limit(1)) is not None
def dataset_versions(session) -> dict:
return {d.kind: {"sha256": (d.sha256 or "")[:12], "version": d.version_label} for d in session.scalars(select(Dataset).where(Dataset.status == "ACTIVE"))}
def _field(status, value=None, rule=None, kb_status=None, evidence=None, reason=None):
return {"status": status, "value": value, "rule": rule, "kb_status": kb_status, "evidence": evidence or [], "reason": reason}
def _empty(current: dict, name: str) -> bool:
return is_writable_empty(name, current.get(name))
def _merge_fiscal_completion(session, out: dict, *, ncm_official: bool, ncm_for_engine: str | None, current: dict, description: str,
product_code: str | None, installation, tenant_id: int | None, decision: dict | None, product_evidence: dict | None = None) -> None:
"""Merges field_contract.resolve_product's own field-by-field decisions (CFOP/CST/CSOSN/PIS-COFINS/...) into `out`, in the SAME
AUTO_FILL_SAFE/ALREADY_SET/NOT_APPLICABLE/INSUFFICIENT_EVIDENCE vocabulary CEST/IPI already use, plus NEEDS_REVIEW for anything that needs an
operator's context answer. No-op when the caller (a test, or a code path with no installation on hand) did not pass installation/tenant_id/product_code."""
if installation is None or tenant_id is None or product_code is None:
return
fields = fiscal_completion.resolve_fiscal_fields(session, product_code=product_code, description=description, current=current, ncm=ncm_for_engine or "",
ncm_official=ncm_official, installation=installation, tenant_id=tenant_id, decision=decision, extra_columns=current,
ev=product_evidence)
for field, r in fields.items():
out[field] = _field(r["status"], r.get("value"), r.get("rule"), reason=r.get("reason"))
if r.get("missing_key"):
out[field]["missing_key"], out[field]["missing_scope"] = r["missing_key"], r.get("missing_scope")
def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current: dict, description: str, *, name_verified=False,
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
out = {}
if not _empty(current, "CLASSIFICACAO_FISCAL"):
out["CLASSIFICACAO_FISCAL"] = _field(ALREADY, reason="campo já preenchido")
elif ncm_eval["status"] == kb.CONFLICT:
out["CLASSIFICACAO_FISCAL"] = _field(CONFLICT, kb_status=kb.CONFLICT, evidence=ncm_eval["candidates"], reason="fontes divergem no NCM")
elif ncm_eval["value"] and ncm_eval["status"] == kb.VERIFIED_KB:
out["CLASSIFICACAO_FISCAL"] = _field(AUTO, ncm_eval["value"], "NCM_KB_VERIFIED" if gtin_kb_verified or name_verified else "NCM_TWO_INDEPENDENT_SOURCES", kb.VERIFIED_KB, ncm_eval["families"])
elif ncm_eval["value"]:
rule = {"CORROBORATED": "NCM_CORROBORATED_LOCAL_HISTORY", "EXTERNAL_CORROBORATED": "NCM_EXTERNAL_CORROBORATED"}.get(ncm_eval["status"], "NCM_SINGLE_SOURCE")
out["CLASSIFICACAO_FISCAL"] = _field(SUGGEST, ncm_eval["value"], rule, ncm_eval["status"], ncm_eval["families"], "evidência ainda insuficiente para preencher automaticamente")
else:
out["CLASSIFICACAO_FISCAL"] = _field(INSUFFICIENT, reason="nenhuma fonte com NCM válido no dataset oficial")
ncm = out["CLASSIFICACAO_FISCAL"]
own = current.get("CLASSIFICACAO_FISCAL")
ncm_for_engine = ncm["value"] if ncm["status"] == AUTO else (None if is_placeholder("CLASSIFICACAO_FISCAL", own) else (normalize_code(own) or None))
if not ncm_for_engine or (ncm["status"] == ALREADY and not ncm_is_official(session, ncm_for_engine)):
for name in ("CEST", "IPI"):
out[name] = _field(ALREADY if not _empty(current, name) else INSUFFICIENT, reason="depende de um NCM oficial válido")
_merge_fiscal_completion(session, out, ncm_official=False, ncm_for_engine=ncm_for_engine, current=current, description=description,
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=None, product_evidence=product_evidence)
return out
# CEST / IPI are deterministic functions of an OFFICIAL NCM: the NCM we are about to write (AUTO) or one already in the product (validated above against the official dataset)
ncm_solid = ncm["status"] == AUTO or (ncm["status"] == ALREADY and ncm_is_official(session, ncm_for_engine))
decision = Classifier(_ScopedDatasets(session, ncm_for_engine)).classify({"current_ncm": ncm_for_engine, "description": description})
cest = (decision["classification"].get("cest") or [])
if not _empty(current, "CEST"):
out["CEST"] = _field(ALREADY, reason="campo já preenchido")
elif decision["cest_status"] == "CEST_CONFIRMED" and len(cest) == 1 and ncm_solid:
out["CEST"] = _field(AUTO, normalize_code(cest[0]), "CEST_OFFICIAL_UNIQUE_MAPPING", ncm["kb_status"], [f"NCM {ncm_for_engine}"])
elif decision["cest_status"] == "NO_CEST_MAPPING_FOUND":
out["CEST"] = _field(NA, reason="NCM sem mapeamento de CEST no dataset oficial")
else:
out["CEST"] = _field(INSUFFICIENT, reason=f"CEST não determinístico ({decision['cest_status']})")
ipi = decision["classification"].get("ipi") or {}
if not _empty(current, "IPI"):
out["IPI"] = _field(ALREADY, reason="campo já preenchido")
elif decision["ipi_status"] == "IPI_RESOLVED" and ipi.get("rate_kind") == "NUMERIC" and ncm_solid:
out["IPI"] = _field(AUTO, str(ipi["rate_numeric"]), "IPI_TIPI_NUMERIC", ncm["kb_status"], [f"TIPI NCM {ncm_for_engine}"])
elif decision["ipi_status"] in {"IPI_RESOLVED"}:
out["IPI"] = _field(NA, reason="TIPI sem alíquota numérica (NT/branca)")
else:
out["IPI"] = _field(INSUFFICIENT, reason=f"IPI não determinístico ({decision['ipi_status']})")
_merge_fiscal_completion(session, out, ncm_official=ncm_solid, ncm_for_engine=ncm_for_engine, current=current, description=description,
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=decision, product_evidence=product_evidence)
return out
def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current: dict | None = None, client_label: str | None = None, allow_external: bool = True, budget_s: float | None = None,
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
started = time.monotonic()
"""Returns {state, gtin_status, gtin, fields:{...}, providers:[...], retry_at, dataset_versions}. Never raises for provider trouble.
`product_code`/`installation`/`tenant_id`, when given, additionally resolve the post-NCM fiscal-completion fields (CFOP/CST/CSOSN/PIS-COFINS/...)
via the existing field_contract engine (see `_merge_fiscal_completion`) and merge them into `fields` alongside CLASSIFICACAO_FISCAL/CEST/IPI.
`product_evidence` is the Agent's targeted, product_code-scoped recipes/composed/purchases/stock evidence (firebird_reader.read_product_evidence) -
never a full-table scan. Named separately from the local `ev` variable below (NCM knowledge-base evidence), a different, pre-existing concept."""
current = {k: v for k, v in (current or {}).items()}
g = normalize_gtin(gtin_raw)
result = {"state": "NOT_FOUND", "gtin_status": g["status"], "gtin": g.get("key"), "fields": {}, "providers": [], "retry_at": None, "dataset_versions": dataset_versions(session)}
session.info["is_official"] = lambda v: ncm_is_official(session, v)
is_official = session.info["is_official"]
product, name_verified, key = None, False, g.get("key")
if g["status"] == "VALID_GTIN": # GTIN has absolute priority over the description
product = kb.find_by_gtin(session, key)
ev = kb.evaluate(session, product, "ncm", is_official) if product else None
if ev and ev["status"] == kb.VERIFIED_KB:
gateway.quota.record_kb_hit()
result["state"] = "EXACT_GTIN_VERIFIED"
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
session.commit()
return result
if ev and ev["status"] == kb.CONFLICT: # local evidence already conflicts: an external answer cannot settle it, so spend no quota
result["state"] = "CONFLICT"
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
gateway.quota.record_kb_hit()
session.commit()
return result
locally_known = bool(ev and ev["status"] in (kb.CORROBORATED, kb.EXTERNAL_CORROBORATED)) # identity + NCM candidate already local: only an NCM-bearing source can still add value
query = g["gtin"] if len(g["gtin"]) in (8, 14) else key[1:] # providers/caches use the canonical EAN-13 (UPC-12 is zero-padded to it)
found, pending = [], []
if allow_external:
for provider in gateway.providers:
if locally_known and not getattr(provider, "provides_ncm", False):
result["providers"].append({"provider": provider.name, "state": "SKIPPED", "reason": "LOCAL_IDENTITY_KNOWN", "from_cache": False})
continue
if budget_s is not None and time.monotonic() - started > budget_s:
result["providers"].append({"provider": provider.name, "state": "SKIPPED", "reason": "TIME_BUDGET", "from_cache": False})
pending.append(type("P", (), {"reason": "TIME_BUDGET", "retry_at": None})())
continue
out = gateway.lookup(provider, query)
result["providers"].append({"provider": out.provider, "state": out.state, "reason": out.reason, "from_cache": out.from_cache})
if out.state in ("FOUND", "CACHE_HIT") and out.product:
found.append(out.product)
product = kb.upsert_product(session, key, out.product.title or description, brand=out.product.brand, quantity=out.product.quantity)
if out.product.ncm:
kb.add_evidence(session, product, "ncm", out.product.ncm, f"provider:{out.provider}"[:32], family=f"PROVIDER:{out.provider}", info={"raw_hash": out.product.raw_hash})
ev = kb.evaluate(session, product, "ncm", is_official)
if ev["status"] == kb.VERIFIED_KB:
break # enough evidence: spare the remaining quota
elif out.state in ("SKIPPED", "ERROR"):
pending.append(out)
if product is None and description:
product = kb.upsert_product(session, key, description)
ev = kb.evaluate(session, product, "ncm", is_official) if product else {"value": None, "status": kb.UNVERIFIED, "families": [], "candidates": [], "rejected": []}
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
if ev["status"] == kb.CONFLICT:
result["state"] = "CONFLICT"
elif ev["status"] == kb.VERIFIED_KB:
result["state"] = "MULTI_SOURCE_MATCH" if found else "EXACT_GTIN_VERIFIED"
elif len(found) >= 2:
result["state"] = "MULTI_SOURCE_MATCH"
elif found:
result["state"] = "SINGLE_WEAK_SOURCE"
elif pending and not found:
quota = any(p.reason in ("RATE_LIMITED", "RATE_WINDOW") for p in pending)
offline = [p for p in pending if p.reason not in ("NOT_CONFIGURED",)]
if offline:
result["state"] = "PENDING_PROVIDER_QUOTA" if quota else "PENDING_PROVIDERS_OFFLINE"
result["retry_at"] = min((p.retry_at for p in offline if p.retry_at), default=None)
session.commit()
return result
if g["status"] == "INVALID_GTIN":
result["state"] = "INVALID_GTIN" # never spends provider quota on an invalid GTIN
# ---- name only: only a UNIQUE, specific description with a VERIFIED_KB fingerprint may fill anything
if not textnorm.is_specific(description):
if result["state"] != "INVALID_GTIN":
result["state"] = "AMBIGUOUS"
result["fields"] = {f: _field(INSUFFICIENT, reason="descrição genérica demais e sem GTIN válido") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
return result
candidates = kb.name_candidates(session, description)
verified = []
for cand in candidates:
ev = kb.evaluate(session, cand, "ncm", is_official)
if ev["status"] == kb.VERIFIED_KB:
verified.append((cand, ev))
if candidates and len(verified) == len(candidates) and len({ev["value"] for _, ev in verified}) == 1:
_, ev = verified[0]
result["state"] = "EXACT_KB_NAME_VERIFIED"
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", name_verified=True, product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
elif len({ev["value"] for _, ev in verified}) > 1:
result["state"] = "CONFLICT"
result["fields"] = {f: _field(CONFLICT, reason="descrição corresponde a produtos com NCM divergentes") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
else:
if result["state"] != "INVALID_GTIN":
result["state"] = "AMBIGUOUS" if len(candidates) > 1 else "NOT_FOUND"
result["fields"] = {f: _field(INSUFFICIENT, reason="sem correspondência verificada na base de conhecimento") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
session.commit()
return result
+86
View File
@@ -0,0 +1,86 @@
"""Human-facing status of every tracked product: nothing may silently 'do nothing'. Computed from the resolver result and refined when the Agent reports the write outcome."""
from __future__ import annotations
from sqlalchemy import select, update
LABELS = {
"ENRIQUECIDO": "Enriquecido: campos seguros preenchidos.",
"PRONTO_PARA_PREENCHER": "Evidência suficiente: aguardando a escrita segura pelo Agent.",
"SIMULADO": "Simulação (DRY_RUN): há candidato, nada foi gravado.",
"AGUARDANDO_EVIDENCIA": "Aguardando evidência: nenhuma fonte confiável ainda.",
"EAN_AUSENTE": "Sem EAN e sem produto único verificado na base: informe o código de barras ou peça a decisão de um administrador.",
"EAN_INVALIDO": "Código de barras inválido: nenhuma fonte externa foi consultada.",
"NOME_AMBIGUO": "Nome genérico ou ambíguo demais para identificar o produto com segurança.",
"SEM_MATCH_DE_PRODUTO": "Nenhum produto conhecido corresponde a este nome/EAN.",
"NCM_NAO_VERIFICADO": "Produto identificado, mas o NCM ainda não foi verificado (falta segunda fonte independente ou decisão de administrador).",
"CONFLITO_DE_NCM": "As fontes divergem sobre o NCM: nada é preenchido até um administrador decidir.",
"PROVIDER_SEM_QUOTA": "Limite diário das fontes externas atingido; será repetido após o reset.",
"PROVIDER_NAO_CONFIGURADO": "Nenhuma fonte externa com NCM está configurada.",
"AGUARDANDO_RETRY": "Fontes indisponíveis agora; nova tentativa automática agendada.",
"HUMAN_OVERRIDE": "Alterado por uma pessoa: nunca mais preenchido automaticamente.",
"TECHNICAL_PLACEHOLDER": "NCM provisório (00000000) reconhecido como 'ainda não classificado'.",
"WRITE_FAILED": "A escrita segura foi recusada ou falhou (veja o motivo).",
"VERIFICACAO_POS_WRITE_FALHOU": "A escrita foi feita mas a releitura não confirmou o valor.",
"NADA_A_PREENCHER": "Todos os campos elegíveis já estão preenchidos.",
"FUNCAO_DESATIVADA": "Enriquecimento desativado para esta instalação.",
}
def classify(result: dict, *, has_gtin: bool, specific_name: bool) -> tuple[str, str]:
"""(ui_status, reason_code) from a resolve() result."""
fields = result.get("fields") or {}
ncm = fields.get("CLASSIFICACAO_FISCAL") or {}
state = result.get("state")
if state == "FEATURE_DISABLED":
return "FUNCAO_DESATIVADA", state
if state == "INVALID_GTIN":
return "EAN_INVALIDO", state
if state == "PENDING_PROVIDER_QUOTA":
return "PROVIDER_SEM_QUOTA", state
if state in ("PENDING_PROVIDERS_OFFLINE", "PENDING_TIME_BUDGET"):
return "AGUARDANDO_RETRY", state
if state == "CONFLICT" or ncm.get("status") == "CONFLICT":
return "CONFLITO_DE_NCM", "CONFLICT"
if ncm.get("status") == "HUMAN_OVERRIDDEN":
return "HUMAN_OVERRIDE", "HUMAN_OVERRIDDEN"
if any(f.get("status") == "AUTO_FILL_SAFE" for f in fields.values()):
return "PRONTO_PARA_PREENCHER", state or "RESOLVED"
if ncm.get("status") == "ALREADY_SET" and not any(f.get("status") in ("AUTO_FILL_SAFE", "SUGGESTION_ONLY") for f in fields.values()):
return "NADA_A_PREENCHER", "ALL_FIELDS_SET"
if not has_gtin:
if state == "AMBIGUOUS" or not specific_name:
return "NOME_AMBIGUO", "GENERIC_OR_AMBIGUOUS_NAME"
return "EAN_AUSENTE", "NAME_ONLY_NO_VERIFIED_MATCH"
if ncm.get("status") == "SUGGESTION_ONLY":
return "NCM_NAO_VERIFICADO", ncm.get("rule") or "NCM_NOT_VERIFIED"
if state == "NOT_FOUND":
return "SEM_MATCH_DE_PRODUTO", "GTIN_UNKNOWN"
return "AGUARDANDO_EVIDENCIA", state or "INSUFFICIENT_EVIDENCE"
def after_write(outcomes: list[dict], mode: str) -> tuple[str | None, str | None]:
"""Refines the status with what the Agent reported (APPLIED / DRY_RUN / ABORTED / FAILED / HUMAN_OVERRIDDEN)."""
kinds = {o.get("outcome") for o in outcomes}
reasons = [o.get("reason") for o in outcomes if o.get("reason")]
if "HUMAN_OVERRIDDEN" in kinds and "APPLIED" not in kinds:
return "HUMAN_OVERRIDE", "HUMAN_OVERRIDDEN"
if "APPLIED" in kinds:
return "ENRIQUECIDO", None
if "FAILED" in kinds:
return ("VERIFICACAO_POS_WRITE_FALHOU" if "POST_WRITE_VERIFICATION_FAILED" in reasons else "WRITE_FAILED"), (reasons[0] if reasons else "FAILED")
if "ABORTED" in kinds:
return "WRITE_FAILED", (reasons[0] if reasons else "ABORTED")
if "DRY_RUN" in kinds:
return "SIMULADO", "DRY_RUN_NO_WRITE"
return None, None
def mark_for_reevaluation(session, installation_id: int, *, codes: list[str] | None = None, gtin: str | None = None) -> int:
"""Flags tracked, NOT-yet-enriched products for a new evaluation (KB/cache first; providers only when really needed). Idempotent; never touches enriched / human-overridden ones."""
from ..models import ProductEnrichment
q = update(ProductEnrichment).where(ProductEnrichment.installation_id == installation_id, ProductEnrichment.ui_status.notin_(["ENRIQUECIDO", "HUMAN_OVERRIDE"]) | ProductEnrichment.ui_status.is_(None))
if codes:
q = q.where(ProductEnrichment.product_code.in_(codes))
if gtin:
q = q.where(ProductEnrichment.gtin == gtin)
return session.execute(q.values(reevaluate=True)).rowcount or 0
+51
View File
@@ -0,0 +1,51 @@
"""Deterministic product-description normalization and fingerprint (no ML). Used to FIND candidates; never authorizes a fiscal value on its own."""
import hashlib
import re
import unicodedata
UNIT_MAP = {"LT": "L", "LTS": "L", "LITRO": "L", "LITROS": "L", "L": "L", "ML": "ML", "KG": "KG", "KGS": "KG", "QUILO": "KG", "G": "G", "GR": "G", "GRS": "G", "GRAMAS": "G", "MG": "MG", "UN": "UN", "UND": "UN", "UNID": "UN", "UNIDADE": "UN", "PCT": "PCT", "CX": "CX"}
ABBREVIATIONS = {"REFRIG": "REFRIGERANTE", "REFRI": "REFRIGERANTE", "CHOC": "CHOCOLATE", "BISC": "BISCOITO", "SAB": "SABONETE", "DET": "DETERGENTE", "SH": "SHAMPOO", "COND": "CONDICIONADOR",
"MACARR": "MACARRAO", "MAC": "MACARRAO", "AMAC": "AMACIANTE", "DESOD": "DESODORANTE", "PAP": "PAPEL", "HIG": "HIGIENICO", "ORIG": "ORIGINAL", "TRAD": "TRADICIONAL"}
STOP = {"DE", "DA", "DO", "DAS", "DOS", "E", "COM", "C", "PARA", "P", "EM", "A", "O"}
MARKETING = {"ORIGINAL", "TRADICIONAL", "NOVO", "NOVA", "PROMO", "PROMOCAO", "OFERTA"} # ignored ONLY by the fingerprint; a different pack/flavour still differs
_QTY = re.compile(r"^(\d+(?:[.,]\d+)?)(ML|L|KG|G|MG|UN|PCT|CX|LT|LTS|GR)$")
def _ascii_upper(text) -> str:
return unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode().upper()
def tokens(description) -> list[str]:
text = _ascii_upper(description)
text = re.sub(r"(\d)([A-Z])", r"\1 \2", text) # 350ML -> 350 ML
text = re.sub(r"([A-Z])(\d)", r"\1 \2", text)
text = re.sub(r"(\d)[,.](\d)", r"\1.\2", text) # decimal comma -> dot (kept inside the number)
text = re.sub(r"[^A-Z0-9.]+", " ", text)
out = []
for tok in text.split():
tok = tok.strip(".")
if not tok or tok in STOP:
continue
tok = UNIT_MAP.get(tok, ABBREVIATIONS.get(tok, tok))
out.append(tok)
return out
def normalized(description) -> str:
return " ".join(tokens(description))
def fingerprint(description) -> str | None:
toks = [t for t in tokens(description) if t not in MARKETING]
if not toks:
return None
return hashlib.sha256(" ".join(sorted(toks)).encode("utf-8")).hexdigest()
def is_specific(description) -> bool:
"""A description strong enough to be used as an identity key: at least 3 informative tokens AND a quantity (number + unit), or >= 4 tokens.
Generic names (PIPOCA, OLEO, REFRIGERANTE, SABONETE) are NEVER specific."""
toks = tokens(description)
has_qty = any(re.fullmatch(r"\d+(?:\.\d+)?", t) and i + 1 < len(toks) and toks[i + 1] in UNIT_MAP.values() for i, t in enumerate(toks))
informative = [t for t in toks if not re.fullmatch(r"\d+(?:\.\d+)?", t) and t not in UNIT_MAP.values()]
return (len(informative) >= 2 and has_qty and len(toks) >= 3) or len(informative) >= 4