feat: product enrichment observability, lab KB study, benchmark, copy + local-Hub E2E, docs; 1.3.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-25 19:26:13 -03:00
co-authored by Claude Sonnet 5
parent 5deebea14e
commit 4fc83a33d5
38 changed files with 1282 additions and 60 deletions
+79
View File
@@ -0,0 +1,79 @@
"""Conservative provider benchmark on REAL GTINs taken (read-only) from the lab ERP databases. Free quotas only, paced below documented limits.
python scripts/enrichment_benchmark.py --sample scratch_enrich/benchmark_gtins.json --out docs/PRODUCT_PROVIDER_BENCHMARK_V1.json [--limit 30]
Each provider gets at most --limit lookups (UPCitemdb: also <= 12 per 30 s burst window; Open Food Facts: <= 12/min). Cosmos is measured only when COSMOS_TOKEN is set.
Reports sample_size / found / not_found / description / brand / NCM / CEST / latency / error_rate per provider. Secrets are never printed.
"""
import argparse
import json
import os
import statistics
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import httpx # noqa: E402
from central.app.enrichment.providers import Cosmos, OpenBeautyFacts, OpenFoodFacts, OpenProductsFacts, ProviderError, UpcItemDb, USER_AGENT # noqa: E402
PACE = {"upcitemdb": 2.6, "openfoodfacts": 5.2, "openproductsfacts": 5.2, "openbeautyfacts": 5.2, "cosmos": 1.0} # seconds between calls: comfortably under the documented burst / per-minute limits
def bench(provider, items):
out = {"provider": provider.name, "configured": provider.configured, "sample_size": 0, "found": 0, "not_found": 0, "with_description": 0, "with_brand": 0, "with_ncm": 0, "with_cest": 0, "errors": 0, "error_kinds": {}, "latencies_ms": []}
if not provider.configured:
out["status"] = "NOT_CONFIGURED"
return out
for item in items:
started = time.monotonic()
try:
product = provider.lookup_gtin(item["gtin"])
except ProviderError as error:
out["errors"] += 1
out["error_kinds"][error.kind] = out["error_kinds"].get(error.kind, 0) + 1
if error.kind in ("RATE_LIMITED", "AUTH_ERROR"):
break # never insist against a limit
time.sleep(PACE[provider.name])
continue
out["sample_size"] += 1
out["latencies_ms"].append(int((time.monotonic() - started) * 1000))
if product.found:
out["found"] += 1
out["with_description"] += bool(product.title)
out["with_brand"] += bool(product.brand)
out["with_ncm"] += bool(product.ncm)
out["with_cest"] += bool(product.cest)
else:
out["not_found"] += 1
time.sleep(PACE[provider.name])
n = out["sample_size"] + out["errors"]
lat = sorted(out.pop("latencies_ms"))
out.update({"error_rate": round(out["errors"] / n, 3) if n else None, "latency_p50_ms": statistics.median(lat) if lat else None, "latency_p95_ms": lat[int(0.95 * (len(lat) - 1))] if lat else None,
"found_rate": round(out["found"] / out["sample_size"], 3) if out["sample_size"] else None, "status": "MEASURED"})
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sample", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--limit", type=int, default=30)
ap.add_argument("--only", default="", help="comma list of provider names; results are MERGED into an existing --out")
args = ap.parse_args()
items = json.loads(Path(args.sample).read_text(encoding="utf-8"))[: args.limit]
client = httpx.Client(timeout=8.0, headers={"User-Agent": USER_AGENT})
providers = [Cosmos(client, os.environ.get("COSMOS_TOKEN")), UpcItemDb(client), OpenFoodFacts(client), OpenProductsFacts(client), OpenBeautyFacts(client)]
only = {n for n in args.only.split(",") if n}
out = Path(args.out)
result = json.loads(out.read_text(encoding="utf-8")) if only and out.exists() else {"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "sample_source": "lab ERP databases (read-only), valid GTINs only", "limit_per_provider": args.limit, "providers": []}
measured = [bench(p, items) for p in providers if not only or p.name in only]
result["providers"] = [p for p in result["providers"] if p["provider"] not in {m["provider"] for m in measured}] + measured
Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()
+237
View File
@@ -0,0 +1,237 @@
"""COPY-ONLY E2E of the new-product enrichment on a REAL Firebird (disposable copy restored from its own .fbk).
python scripts/enrichment_copy_e2e.py [--products 12]
The Agent code under test is the real one (gti_fiscal_agent.enrichment: real fdb connections, real typed UPDATE, real threads/timing). The Hub side is the real resolver run in-process on
SQLite with the official NCM dataset and fake providers (no internet). Proves on a real Firebird:
detection latency P50/P95 (product INSERTed by an "operator" -> fields written), only-empty fields written, other columns + every other table untouched, historical rows untouched,
second run = 0 writes, DRY_RUN = 0 writes, DISABLED / WAITING = 0 Firebird activity, human override incl. deleted value, Hub offline -> pending -> recovers, authorization revoked -> 0 writes.
"""
import json
import os
import statistics
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts")); sys.path.insert(0, str(ROOT / "agent" / "src"))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
import rtc_copy_write_e2e as base # noqa: E402
from gti_fiscal_agent import enrichment as E # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
from central.app.datasets.ncm import parse_ncm_payload # noqa: E402
from central.app.datasets.service import DatasetService # noqa: E402
from central.app.enrichment import kb # noqa: E402
from central.app.enrichment.providers import Provider, ProviderProduct # noqa: E402
from central.app.enrichment.quota import ProviderGateway # noqa: E402
from central.app.enrichment.resolver import resolve # noqa: E402
from central.app.models import Base, CestEntry, CestNcmEntry, Dataset, TipiEntry # noqa: E402
from datetime import datetime, timezone # noqa: E402
import httpx # noqa: E402
def gtin(body12):
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
return body12 + str((10 - total % 10) % 10)
class Fake(Provider):
def __init__(self, name, ncm):
super().__init__(client=httpx.Client())
self.name, self.ncm, self.provides_ncm = name, ncm, True
def lookup_gtin(self, g):
return ProviderProduct(self.name, g, True, title="PRODUTO DE TESTE ESPECIFICO 350ML", ncm=self.ncm)
class InProcessHub:
"""The real Hub resolver, in-process. `online=False` simulates the Hub being unreachable."""
def __init__(self):
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
self.session = Session(engine)
DatasetService(self.session).import_payload(parse_ncm_payload((ROOT / "scratch_enrich" / "ncm_siscomex.json").read_bytes()), "official", datetime.now(timezone.utc))
tipi = Dataset(kind="TIPI_IPI", provider="RFB", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
cest = Dataset(kind="CEST", provider="CONFAZ", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
self.session.add_all([tipi, cest]); self.session.flush()
self.session.add(TipiEntry(dataset_id=tipi.id, ncm="22021000", ex=None, description="x", ipi_rate=0.0, ipi_rate_text="0", source_row=1))
e = CestEntry(dataset_id=cest.id, code="03.007.00", description="PRODUTO DE TESTE ESPECIFICO 350ML", annex="I")
self.session.add(e); self.session.flush(); self.session.add(CestNcmEntry(cest_entry_id=e.id, ncm_code="22021000")); self.session.commit()
self.providers = [Fake("cosmos", "22021000"), Fake("second", "22021000")]
self.features = {"features_configured": True, "corrector_enabled": True, "enrichment_mode": "ENABLED"}
self.online, self.reports, self.overridden, self.calls = True, [], {}, 0
self.lock = threading.Lock()
def authorizations(self):
if not self.online:
raise ConnectionError("hub offline")
return {"features": dict(self.features)}
def resolve_product(self, payload):
if not self.online:
raise ConnectionError("hub offline")
with self.lock:
self.calls += 1
r = resolve(self.session, ProviderGateway(self.session, self.providers), gtin_raw=payload["gtin"], description=payload["description"], current=payload["current"])
for f in self.overridden.get(payload["product_code"], ()):
r["fields"][f] = {"status": "HUMAN_OVERRIDDEN", "value": None}
r["retry_at"] = None
return r
def report_enrichment(self, payload):
with self.lock:
self.reports.append(payload)
for it in payload["fields"]:
if it["outcome"] == "HUMAN_OVERRIDDEN":
self.overridden.setdefault(payload["product_code"], set()).add(it["field"])
return {"ok": True}
_orig_connect = base.connect
def _connect_retry(rw):
"""Same brief retry the Agent applies: local XNET connections can report 'connection lost' when several threads connect at the same instant."""
for attempt in range(6):
try:
return _orig_connect(rw)
except Exception: # noqa: BLE001
if attempt == 5:
raise
time.sleep(0.2 * (attempt + 1))
base.connect = _connect_retry
def insert_product(code, name, ean):
conn = base.connect(True); cur = conn.cursor()
cur.execute("INSERT INTO C000025 (CODIGO, PRODUTO, CODBARRA) VALUES (?, ?, ?)", (code, name, ean))
conn.commit(); conn.close()
def row(code):
conn = base.connect(False); cur = conn.cursor()
cur.execute("SELECT CLASSIFICACAO_FISCAL, CEST, IPI FROM C000025 WHERE CODIGO=?", (code,))
r = cur.fetchone(); conn.close()
return tuple(None if v is None else (str(v).strip() if not isinstance(v, float) else v) for v in r) if r else None
def side_hashes():
"""C000058 (ERP change journal) and C000100 (stock) are maintained by the SGV's OWN triggers on C000025 INSERT/UPDATE. Only rows of the test products (code 'Z...') may differ."""
import hashlib
conn = base.connect(False); cur = conn.cursor(); out = {}
for table, col in (("C000058", "CODREGISTRO"), ("C000100", "CODPRODUTO")):
cur.execute(f"SELECT * FROM {table} WHERE {col} NOT STARTING WITH 'Z'")
h = hashlib.sha256()
n = 0
for r in cur.fetchall():
h.update(repr(r).encode("utf-8", "replace")); n += 1
out[table] = f"{n}:{h.hexdigest()[:16]}"
conn.close()
return out
def main():
n = int(sys.argv[sys.argv.index("--products") + 1]) if "--products" in sys.argv else 12
checks = {}
base.restore_copy()
before_tables, _ = base.baseline()
before_side = side_hashes()
hub = InProcessHub()
for suffix in ("", "-wal", "-shm"):
try:
(ROOT / "scratch_enrich" / f"enrich_e2e.db{suffix}").unlink()
except FileNotFoundError:
pass
store = E.EnrichmentStore(ROOT / "scratch_enrich" / "enrich_e2e.db")
worker = E.EnrichmentWorker(hub=hub, store=store, read_conn=lambda: base.connect(False), write_conn=lambda: base.connect(True),
create_reprocess=lambda codes: None)
class Stop:
stop = False
stopper = Stop()
thread = threading.Thread(target=worker.run_forever, args=(stopper,), daemon=True)
# --- WAITING / DISABLED: zero Firebird activity, baseline NOT taken
hub.features = {"features_configured": False}
thread.start(); time.sleep(4)
checks["WAITING_NO_BASELINE_NO_CALLS"] = store.meta("baseline_done") is None and hub.calls == 0
hub.features = {"features_configured": True, "corrector_enabled": True, "enrichment_mode": "DISABLED"}; worker._features_at = -999; time.sleep(4)
checks["DISABLED_NO_BASELINE_NO_CALLS"] = store.meta("baseline_done") is None and hub.calls == 0
# --- DRY_RUN: baseline, detect, resolve, report, zero writes
hub.features = {"features_configured": True, "corrector_enabled": True, "enrichment_mode": "DRY_RUN"}; worker._features_at = -999; time.sleep(5)
checks["BASELINE_TAKEN_ON_ACTIVATION"] = store.meta("baseline_done") == "1"
hist = len(store.known())
insert_product("ZD001", "PRODUTO DE TESTE ESPECIFICO 350ML", gtin("789200000001")); time.sleep(8)
checks["DRY_RUN_ZERO_WRITES"] = row("ZD001") == (None, None, None) and hub.reports and hub.reports[-1]["fields"][0]["outcome"] == "DRY_RUN"
# --- ENABLED: N products inserted like an operator would, latency measured insert -> written
hub.features = {"features_configured": True, "corrector_enabled": True, "enrichment_mode": "ENABLED"}; worker._features_at = -999; worker._auth_cache = (None, 0.0)
latencies = []
for i in range(n):
code, ean = f"ZE{i:03d}", gtin(f"78930000{i:04d}")
t0 = time.monotonic()
insert_product(code, "PRODUTO DE TESTE ESPECIFICO 350ML", ean)
while time.monotonic() - t0 < 20 and row(code) == (None, None, None):
time.sleep(0.1)
latencies.append(round(time.monotonic() - t0, 2))
time.sleep(0.5)
ok_rows = [row(f"ZE{i:03d}") for i in range(n)]
checks["FIELDS_WRITTEN"] = all(r and r[0] == "22021000" and r[1] == "0300700" and r[2] == 0.0 for r in ok_rows)
time.sleep(6)
writes_before = sum(1 for rep in hub.reports for f in rep["fields"] if f["outcome"] == "APPLIED")
calls_before = hub.calls
time.sleep(8) # a "second run": nothing pending, nothing re-written
checks["SECOND_RUN_ZERO_WRITES"] = writes_before == sum(1 for rep in hub.reports for f in rep["fields"] if f["outcome"] == "APPLIED") and hub.calls - calls_before <= 0
checks["HISTORICAL_KNOWN_COUNT"] = hist > 100
# --- human override incl. deleted value
conn = base.connect(True); cur = conn.cursor()
cur.execute("UPDATE C000025 SET CEST = NULL WHERE CODIGO = 'ZE000'"); cur.execute("UPDATE C000025 SET CLASSIFICACAO_FISCAL = '19059090' WHERE CODIGO = 'ZE001'"); conn.commit(); conn.close()
time.sleep(E.OVERRIDE_CHECK_SECONDS + 6)
checks["OVERRIDE_DELETED_VALUE"] = store.is_overridden("ZE000", "CEST") and store.is_overridden("ZE001", "CLASSIFICACAO_FISCAL")
checks["OVERRIDE_REPORTED_TO_HUB"] = "CEST" in hub.overridden.get("ZE000", set())
# ... and a later description edit does NOT bring the value back
conn = base.connect(True); cur = conn.cursor(); cur.execute("UPDATE C000025 SET PRODUTO = 'PRODUTO DE TESTE ESPECIFICO 350ML ZERO' WHERE CODIGO = 'ZE000'"); conn.commit(); conn.close()
time.sleep(10)
checks["OVERRIDE_NEVER_REWRITTEN"] = row("ZE000")[1] in (None, "") and row("ZE001")[0] == "19059090"
# --- Hub offline -> pending -> recovers ; authorization revoked -> 0 writes
hub.online = False
insert_product("ZO001", "PRODUTO DE TESTE ESPECIFICO 350ML", gtin("789400000001")); time.sleep(8)
checks["HUB_OFFLINE_PENDING_NO_WRITE"] = row("ZO001") == (None, None, None) and store.get("ZO001")["state"] in ("RETRY", "PENDING")
hub.online = True
with store.db:
store.db.execute("UPDATE enrich_products SET next_attempt_at=0 WHERE code='ZO001'")
time.sleep(8)
checks["HUB_BACK_RECOVERS"] = row("ZO001") == ("22021000", "0300700", 0.0)
stopper.stop = True
thread.join(timeout=10)
# --- invariants on the real Firebird copy: only the 3 allowed columns of the new rows changed
after_tables, _ = base.baseline()
changed = base.diff_tables(before_tables, after_tables)
checks["OTHER_TABLES_ONLY_ERP_TRIGGER_SIDE_EFFECTS_OF_TEST_ROWS"] = set(changed) <= {"C000025", "C000058", "C000100"} and side_hashes() == before_side
conn = base.connect(False); cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM C000025 WHERE CODIGO STARTING WITH 'Z' AND COD_ORIGEM_MERCADORIA IS NOT NULL AND TRIM(COD_ORIGEM_MERCADORIA) <> ''"); origin = cur.fetchone()[0]; conn.close()
checks["ORIGIN_NEVER_WRITTEN"] = origin == 0
result = {"copy_only": True, "products_enabled": n, "detect_to_written_seconds": {"p50": statistics.median(latencies), "p95": sorted(latencies)[int(0.95 * (len(latencies) - 1))], "max": max(latencies), "all": latencies},
"hub_resolve_calls": hub.calls, "tables_changed": changed, "checks": checks, "ALL_PASS": all(checks.values()), "poll_seconds": worker.poll_seconds()}
Path(ROOT / "docs" / "runs").mkdir(parents=True, exist_ok=True)
(ROOT / "docs" / "runs" / "enrichment_copy_e2e.json").write_text(json.dumps(result, indent=1), encoding="utf-8")
print(json.dumps(result, indent=1))
sys.exit(0 if result["ALL_PASS"] else 1)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""READ-ONLY study of the five lab ERP databases for the product enrichment: GTIN quality, NCM coverage, cross-database agreement, and KB seeding.
python scripts/enrichment_lab_kb.py [--seed-url sqlite:///kb.db] [--sample 40 --sample-out scratch/benchmark_gtins.json]
Firebird is opened READ-ONLY (no write path exists here). The KB seed goes ONLY to the database given by --seed-url (default: an in-memory SQLite, i.e. statistics only) and stores
CLIENT_OBSERVATION evidence, never VERIFIED_KB by itself. Company-specific fiscal fields are not read at all.
"""
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
import local_completion as lc # noqa: E402
from sqlalchemy import create_engine, select # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from central.app.enrichment import kb # noqa: E402
from central.app.enrichment.gtin import normalize_gtin # noqa: E402
from central.app.enrichment.observations import observe # noqa: E402
from central.app.enrichment.resolver import ncm_is_official # noqa: E402
from central.app.models import Base, Dataset, KbKnowledge, NcmEntry # noqa: E402
def read_lab(name, path):
conn = lc.connect(path)
try:
cur = conn.cursor()
cur.execute("SELECT CODIGO, PRODUTO, GTIN, CLASSIFICACAO_FISCAL, CODBARRA FROM C000025")
# the SGV operator types the EAN in CODBARRA; GTIN (fiscal) is mostly empty. The first VALID one wins (same rule as the Agent).
return [{"CODIGO": r[0], "PRODUTO": (r[1] or "").strip(), "GTIN": next((x for x in (r[2], r[4]) if normalize_gtin(x)["status"] == "VALID_GTIN"), r[2] or r[4]), "NCM": r[3]} for r in cur.fetchall()]
finally:
conn.close()
def load_ncm_official(session, ncm_json: Path | None):
"""Official NCM into the throw-away/seed session when it has none (parsed from the saved official JSON)."""
if session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")):
return
from central.app.datasets.ncm import parse_ncm_payload
from central.app.datasets.service import DatasetService
from datetime import datetime, timezone
payload = parse_ncm_payload(ncm_json.read_bytes())
DatasetService(session).import_payload(payload, "official-json", datetime.now(timezone.utc))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--seed-url", default="sqlite://")
ap.add_argument("--ncm-json", default=None, help="official Siscomex JSON (used only if the seed DB has no NCM dataset)")
ap.add_argument("--sample", type=int, default=0)
ap.add_argument("--sample-out", default=None)
args = ap.parse_args()
engine = create_engine(args.seed_url)
Base.metadata.create_all(engine)
dbs = dict(lc.allowlist())
dbs["TESTESOFICIAIS"] = os.environ.get("FIREBIRD_DATABASE_PATH", str(ROOT / "TESTESOFICIAIS.FDB"))
report, all_rows = {}, {}
with Session(engine) as session:
load_ncm_official(session, Path(args.ncm_json) if args.ncm_json else None)
official = lambda v: ncm_is_official(session, v) # noqa: E731
for name, path in dbs.items():
if not Path(path).exists():
report[name] = {"error": "missing"}
continue
rows = read_lab(name, path)
all_rows[name] = rows
status = Counter(normalize_gtin(r["GTIN"])["status"] for r in rows)
stats = observe(session, rows, name, official)
ncm_ok = sum(1 for r in rows if len("".join(c for c in str(r["NCM"] or "") if c.isdigit())) == 8 and official("".join(c for c in str(r["NCM"]) if c.isdigit())))
report[name] = {"products": len(rows), "gtin_status": dict(status), "valid_gtin_rate": round(100 * status["VALID_GTIN"] / max(1, len(rows)), 1), "official_ncm_rate": round(100 * ncm_ok / max(1, len(rows)), 1), **stats}
session.commit()
by_gtin = defaultdict(set)
for name, rows in all_rows.items():
for r in rows:
g = normalize_gtin(r["GTIN"])
if g["status"] == "VALID_GTIN":
by_gtin[g["key"]].add(name)
shared = {k: v for k, v in by_gtin.items() if len(v) >= 2}
conflicts = 0
for kp in session.scalars(select(kb.KbProduct)):
ncms = {r.value for r in session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == kp.id, KbKnowledge.knowledge_key == "ncm"))}
conflicts += len(ncms) > 1
summary = {"databases": report, "distinct_valid_gtin": len(by_gtin), "gtin_in_2plus_databases": len(shared), "kb_products": session.scalar(select(kb.KbProduct.id).limit(1)) is not None and session.query(kb.KbProduct).count(),
"kb_ncm_conflicts_between_clients": conflicts}
if args.sample and args.sample_out:
picks = sorted(by_gtin)[:: max(1, len(by_gtin) // args.sample)][: args.sample]
names = {normalize_gtin(r["GTIN"])["key"]: r["PRODUTO"] for rows in all_rows.values() for r in rows if normalize_gtin(r["GTIN"])["status"] == "VALID_GTIN"}
Path(args.sample_out).parent.mkdir(parents=True, exist_ok=True)
Path(args.sample_out).write_text(json.dumps([{"gtin": k[1:] if k.startswith("0") else k, "erp_description": names[k]} for k in picks], ensure_ascii=False, indent=1), encoding="utf-8")
summary["sample_written"] = len(picks)
print(json.dumps(summary, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
"""Conservative LIVE smoke of the free providers (separate from the offline test suite): 1 lookup per provider on a famous, stable GTIN.
python scripts/enrichment_live_smoke.py
Goes through the real ProviderGateway (cache + quota + circuit breaker) on an in-memory database; tokens are never printed. Exit 0 when every CONFIGURED provider answered
without an infrastructure error (NOT_FOUND is a valid answer; a rate limit is reported, not treated as a failure of our code).
"""
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
from central.app.enrichment.providers import build_providers # noqa: E402
from central.app.enrichment.quota import ProviderGateway # noqa: E402
from central.app.models import Base # noqa: E402
GTIN = "7894900011517" # Coca-Cola 350 ml (BR): stable, widely catalogued
def main():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
settings = type("S", (), {"cosmos_token": os.environ.get("COSMOS_TOKEN", ""), "allow_paid_providers": False})()
out, ok = [], True
with Session(engine) as session:
gateway = ProviderGateway(session, build_providers(settings))
for provider in gateway.providers:
result = gateway.lookup(provider, GTIN)
row = {"provider": provider.name, "configured": provider.configured, "state": result.state, "reason": result.reason, "title": result.product.title if result.product else None}
out.append(row)
if provider.configured and result.state == "ERROR" and result.reason not in ("RATE_LIMITED",):
ok = False
print(json.dumps({"gtin": GTIN, "results": out, "OK": ok}, ensure_ascii=False, indent=1))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
"""E2E of the enrichment across REAL HTTP: the real Agent worker (real Firebird COPY) <-> the real Hub container (dev PostgreSQL, real datasets, real providers).
docker compose up -d --build fiscal-hub # Hub at http://localhost:8000
python scripts/enrichment_local_hub_e2e.py
Registers a throw-away dev Agent (AGENT_DEV_TOKEN), flips its per-installation features straight in the dev database (the panel route is covered by tests), seeds the KB of the dev Hub with a VERIFIED NCM for
one test GTIN (two independent provider families), then walks: DISABLED -> DRY_RUN -> ENABLED -> DISABLED, an unknown GTIN (real providers, internet) and an invalid GTIN (no provider call).
Writes go ONLY to scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (guarded by rtc_copy_write_e2e.guard). The dev database receives test rows only.
"""
import json
import os
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts")); sys.path.insert(0, str(ROOT / "agent" / "src"))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
import enrichment_copy_e2e as ce # noqa: E402 (also patches base.connect with the brief retry)
from gti_fiscal_agent import enrichment as E # noqa: E402
from gti_fiscal_agent.client import HubClient # noqa: E402
base = ce.base
HUB = "http://127.0.0.1:8000" # NOT "localhost": on Windows it resolves ::1 first and adds ~500 ms per request
TOKEN = os.environ["AGENT_DEV_TOKEN"]
CONTAINER = "projeto-postgres-1"
def sql(query):
done = subprocess.run(["docker", "exec", CONTAINER, "psql", "-U", "gti", "-d", "gti_fiscal", "-At", "-c", query], capture_output=True, text=True, timeout=60)
if done.returncode != 0:
raise RuntimeError(done.stderr[-300:])
return done.stdout.strip()
def seed_kb(gtin14, description, ncm):
code = f'''
from central.app.db import SessionLocal
from central.app.enrichment import kb
with SessionLocal() as s:
p = kb.upsert_product(s, "{gtin14}", "{description}")
kb.add_evidence(s, p, "ncm", "{ncm}", "provider:cosmos", family="PROVIDER:cosmos")
kb.add_evidence(s, p, "ncm", "{ncm}", "provider:other", family="PROVIDER:other")
s.commit()
print("seeded")
'''
done = subprocess.run(["docker", "exec", "-i", "projeto-fiscal-hub-1", "python", "-"], input=code, capture_output=True, text=True, timeout=120, cwd=str(ROOT))
assert "seeded" in done.stdout, done.stderr[-400:]
def set_features(installation_id, mode, corrector=True):
sql(f"UPDATE installations SET enrichment_mode='{mode}', corrector_enabled={'true' if corrector else 'false'}, features_configured=true WHERE installation_id='{installation_id}'")
def main():
checks = {}
base.restore_copy()
client = HubClient(HUB, TOKEN)
try:
installation_id = client.identity().get("installation_id") or str(uuid.uuid4()) # the dev Agent may already own an installation
except Exception: # noqa: BLE001
installation_id = str(uuid.uuid4())
client.heartbeat_metadata({"installation_id": installation_id, "status": "IDLE", "agent_version": "1.3.0", "display_name": "E2E-ENRICH", "capabilities": {"PRODUCT_ENRICHMENT_WRITE_V1": True}})
set_features(installation_id, "DISABLED") # start every run from the default
checks["REGISTERED_DEFAULT_DISABLED"] = client.authorizations()["features"]["enrichment_mode"] == "DISABLED"
gtin_ok = ce.gtin("789500000001")
seed_kb("0" + gtin_ok, "REFRIGERANTE COLA LATA 350ML", "22021000")
for suffix in ("", "-wal", "-shm"):
try:
(ROOT / "scratch_enrich" / f"enrich_hub_e2e.db{suffix}").unlink()
except FileNotFoundError:
pass
store = E.EnrichmentStore(ROOT / "scratch_enrich" / "enrich_hub_e2e.db")
worker = E.EnrichmentWorker(hub=client, store=store, read_conn=lambda: base.connect(False), write_conn=lambda: base.connect(True), create_reprocess=lambda codes: client.create_job("REPROCESS_PRODUCTS", {"codigos": list(codes), "reason": "ENRICHMENT"}))
class Stop:
stop = False
stopper = Stop()
threading.Thread(target=worker.run_forever, args=(stopper,), daemon=True).start()
def refresh():
worker._features_at = -999
worker._auth_cache = (None, 0.0)
time.sleep(4)
checks["DISABLED_NO_ACTIVITY"] = store.meta("baseline_done") is None
set_features(installation_id, "DRY_RUN"); refresh(); time.sleep(6)
checks["BASELINE_ON_ACTIVATION"] = store.meta("baseline_done") == "1"
ce.insert_product("HD001", "REFRIGERANTE COLA LATA 350ML", gtin_ok); time.sleep(8)
checks["DRY_RUN_ZERO_WRITES"] = ce.row("HD001") == (None, None, None)
audits = int(sql(f"SELECT count(*) FROM product_field_audits a JOIN installations i ON i.id=a.installation_id WHERE i.installation_id='{installation_id}' AND a.outcome='DRY_RUN' AND a.product_code='HD001'"))
checks["DRY_RUN_AUDITED_IN_HUB"] = audits >= 1
set_features(installation_id, "ENABLED"); refresh()
ce.insert_product("HE001", "REFRIGERANTE COLA LATA 350ML", gtin_ok)
t0 = time.monotonic()
while time.monotonic() - t0 < 25 and ce.row("HE001")[0] is None:
time.sleep(0.2)
latency = round(time.monotonic() - t0, 2)
r = ce.row("HE001")
checks["ENABLED_NCM_WRITTEN"] = r[0] == "22021000"
time.sleep(3) # the Agent reports the audit right after the write
applied = sql(f"SELECT field||':'||coalesce(new_value,'') FROM product_field_audits a JOIN installations i ON i.id=a.installation_id WHERE i.installation_id='{installation_id}' AND a.outcome='APPLIED' AND a.product_code='HE001' ORDER BY a.id")
checks["WRITTEN_FIELDS_MATCH_HUB_AUDIT"] = ("CLASSIFICACAO_FISCAL:22021000" in applied) and all(v in applied for v in ([f"CEST:{r[1]}"] if r[1] else []) + ([f"IPI:{r[2]}"] if r[2] not in (None,) else []))
time.sleep(6)
hd = ce.row("HD001")
checks["EARLIER_DRY_RUN_PRODUCT_STILL_UNTOUCHED"] = hd == (None, None, None)
# unknown GTIN goes to the REAL providers (internet); nothing may be written, a human blocker must appear
unknown = ce.gtin("789600000009")
ce.insert_product("HE002", "PRODUTO INEXISTENTE ESPECIAL XYZ 123", unknown); time.sleep(14)
checks["UNKNOWN_GTIN_NO_WRITE"] = ce.row("HE002") == (None, None, None)
blockers = sql("SELECT reason_code FROM blocked_products WHERE field='ENRICHMENT' AND product_code='HE002'")
checks["UNKNOWN_GTIN_BLOCKER_PT"] = blockers in ("NOT_FOUND", "SINGLE_WEAK_SOURCE", "PENDING_PROVIDER_QUOTA", "PENDING_PROVIDERS_OFFLINE") or blockers.startswith("NOT_FOUND")
bad = unknown[:-1] + str((int(unknown[-1]) + 1) % 10)
before_req = sql("SELECT coalesce(sum(requests),0) FROM provider_usage_daily")
ce.insert_product("HE003", "PRODUTO GTIN INVALIDO", bad); time.sleep(8)
checks["INVALID_GTIN_NO_PROVIDER_CALL"] = sql("SELECT coalesce(sum(requests),0) FROM provider_usage_daily") == before_req and ce.row("HE003") == (None, None, None)
# feature switched off centrally: the next new product is not touched
set_features(installation_id, "DISABLED"); refresh(); time.sleep(4)
ce.insert_product("HE004", "REFRIGERANTE COLA LATA 350ML", gtin_ok); time.sleep(8)
checks["DISABLED_AGAIN_ZERO_WRITES"] = ce.row("HE004") == (None, None, None)
# corrector off + enrichment on: independent switches
set_features(installation_id, "ENABLED", corrector=False); refresh(); time.sleep(3)
checks["CORRECTOR_OFF_NO_JOBS"] = client.next_job() is None
stopper.stop = True
result = {"copy_only": True, "hub": HUB, "detect_to_written_seconds_enabled": latency, "checks": checks, "ALL_PASS": all(checks.values())}
(ROOT / "docs" / "runs" / "enrichment_local_hub_e2e.json").write_text(json.dumps(result, indent=1), encoding="utf-8")
print(json.dumps(result, indent=1))
sys.exit(0 if result["ALL_PASS"] else 1)
if __name__ == "__main__":
main()