Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Cost plan for the residual AI work items, with budget guard (section 18).
|
||||
|
||||
Runs BEFORE any real execution: UNIQUE_AI_WORK_ITEMS, token estimates per task type,
|
||||
estimated cost, and the authorize/refuse verdict against AI_MAX_RUN_COST_USD.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
from central.app.ai.provider import BudgetGuard
|
||||
from central.app.config import settings
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import AiWorkItem
|
||||
|
||||
TOKENS_PER_ITEM = { # conservative prompt+schema overhead per single work item
|
||||
"INVALID_NCM": (700, 120),
|
||||
"CEST": (500, 80),
|
||||
"EX_TIPI": (500, 80),
|
||||
"PRODUCT_ROLE": (400, 60),
|
||||
}
|
||||
|
||||
|
||||
def main(output="docs/AI_COST_PLAN_V1.json") -> dict:
|
||||
session = SessionLocal()
|
||||
items = session.scalars(select(AiWorkItem)).all()
|
||||
guard = BudgetGuard(settings.ai_max_run_cost_usd, settings.ai_price_in_per_m, settings.ai_price_out_per_m)
|
||||
per_task = {}
|
||||
in_total = out_total = 0
|
||||
resolved = {"KB_HIT", "KB_EXACT_HIT", "KB_FINGERPRINT_HIT", "HIGH_CONFIDENCE_RETRIEVAL", "SINGLE_OFFICIAL_CANDIDATE", "DONE", "SKIPPED", "CONFLICT"}
|
||||
for item in items:
|
||||
if item.status in resolved:
|
||||
continue # cache/official candidate resolved — no AI call needed
|
||||
tokens_in, tokens_out = TOKENS_PER_ITEM.get(item.task_type, (500, 80))
|
||||
bucket = per_task.setdefault(item.task_type, {"items": 0, "input_tokens": 0, "output_tokens": 0, "cells_covered": 0})
|
||||
bucket["items"] += 1; bucket["input_tokens"] += tokens_in; bucket["output_tokens"] += tokens_out
|
||||
bucket["cells_covered"] += item.cells_covered
|
||||
in_total += tokens_in; out_total += tokens_out
|
||||
plan = guard.estimate(in_total, out_total)
|
||||
report = {
|
||||
"artifact": "AI_COST_PLAN_V1",
|
||||
"unique_ai_work_items": sum(bucket["items"] for bucket in per_task.values()),
|
||||
"per_task_type": per_task,
|
||||
"estimated_input_tokens": plan["input_tokens"],
|
||||
"estimated_output_tokens": plan["output_tokens"],
|
||||
"estimated_cost_usd": plan["estimated_cost"],
|
||||
"ai_max_run_cost_usd": guard.max_run_cost_usd,
|
||||
"budget_authorized": guard.authorize(plan),
|
||||
"provider_configured": settings.ai_provider,
|
||||
"model_configured": settings.ai_model,
|
||||
"ai_enabled": settings.ai_enabled,
|
||||
"max_batch_size": settings.ai_max_batch_size,
|
||||
"min_confidence": settings.ai_min_confidence,
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", default="/tmp/ai_cost_plan.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.output), indent=2))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""GTIN KB audit (section 10) — validity, cross-tenant matches and conflicts.
|
||||
|
||||
Reads the seed payload (ground truth of what was observed per tenant) plus the KB tables.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from central.app.ai.fingerprint import validate_gtin
|
||||
|
||||
|
||||
def main(payload_path="docs/runs/ai_kb_seed_payload.json", output="docs/GTIN_KB_AUDIT_V1.json") -> dict:
|
||||
payload = json.loads(Path(payload_path).read_text(encoding="utf8"))
|
||||
by_gtin = defaultdict(list)
|
||||
invalid_gtin_raw = 0
|
||||
for product in payload["products"]:
|
||||
raw = product.get("gtin")
|
||||
if raw is None:
|
||||
continue
|
||||
if validate_gtin(raw) is None:
|
||||
invalid_gtin_raw += 1 # defensive: export already validates
|
||||
continue
|
||||
by_gtin[raw].append(product)
|
||||
duplicated_same_product = sum(1 for entries in by_gtin.values() if len(entries) > 1)
|
||||
conflicting = 0
|
||||
cross_tenant = 0
|
||||
conflict_examples = []
|
||||
for gtin, entries in by_gtin.items():
|
||||
ncms = {entry.get("ncm") for entry in entries if entry.get("ncm")}
|
||||
databases = {obs["database"] for entry in entries for obs in entry["observations"]}
|
||||
if len(ncms) > 1:
|
||||
conflicting += 1
|
||||
if len(conflict_examples) < 10:
|
||||
conflict_examples.append({"gtin": gtin, "ncms": sorted(ncms)})
|
||||
if len(databases) > 1:
|
||||
cross_tenant += 1
|
||||
report = {
|
||||
"artifact": "GTIN_KB_AUDIT_V1",
|
||||
"gtin_valid": len(by_gtin),
|
||||
"gtin_invalid_raw": invalid_gtin_raw,
|
||||
"gtin_duplicated_same_product": duplicated_same_product,
|
||||
"gtin_conflicting_products": conflicting,
|
||||
"gtin_cross_tenant_matches": cross_tenant,
|
||||
"conflict_examples": conflict_examples,
|
||||
"policy": "same GTIN across tenants is STRONG evidence for sharing global product knowledge; tenant fiscal context is never shared",
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--payload-path", default="/tmp/ai_kb_seed_payload.json")
|
||||
parser.add_argument("--output", default="/tmp/gtin_audit.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.payload_path, args.output), indent=2))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Audit the historical KB conflicts without inventing missing evidence."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.ai.historical_conflicts import reconstruct_historical_conflicts
|
||||
|
||||
|
||||
def main(payload_path="docs/runs/ai_kb_seed_payload.json", output="docs/KB_HISTORICAL_CONFLICT_AUDIT_V1.json"):
|
||||
payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
|
||||
report = reconstruct_historical_conflicts(payload)
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--payload", default="docs/runs/ai_kb_seed_payload.json")
|
||||
parser.add_argument("--output", default="docs/KB_HISTORICAL_CONFLICT_AUDIT_V1.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.payload, args.output), indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Create the conservative KB conflict audit from the read-only seed payload."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.ai.kb_consolidation import audit_payload
|
||||
|
||||
|
||||
def main(payload_path="docs/runs/ai_kb_seed_payload.json", output="docs/KB_CONFLICT_AUDIT_V1.json"):
|
||||
payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
|
||||
report = audit_payload(payload)
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--payload-path", default="docs/runs/ai_kb_seed_payload.json")
|
||||
parser.add_argument("--output", default="docs/KB_CONFLICT_AUDIT_V1.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.payload_path, args.output), indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Build deduplicated AI work items from the residual questions and the seeded KB.
|
||||
|
||||
Reads docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json, resolves each invalid-NCM product
|
||||
question to its KB fingerprint (same product in several databases collapses into ONE
|
||||
work item), persists AiWorkItem rows and writes docs/AI_RESIDUAL_PLAN_V1.json.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.ai.kb import KnowledgeBase
|
||||
from central.app.ai.workitems import build_work_items
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import AiWorkItem, KbObservation
|
||||
|
||||
|
||||
def _erp_code_from_question(question) -> str:
|
||||
import re
|
||||
match = re.search(r"PRODUCT\s+(\d+)", question.get("QUESTION", ""))
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def main(questions_path="docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json", output="docs/AI_RESIDUAL_PLAN_V1.json") -> dict:
|
||||
session = SessionLocal()
|
||||
questions = json.loads(Path(questions_path).read_text(encoding="utf8"))["questions"]
|
||||
# map "{DATABASE}:{erp_code}" -> fingerprint using the seeded observations
|
||||
rows = session.execute(select(KbObservation.company_key, KbObservation.erp_code, KbObservation.kb_product_id, KbObservation.database_label)).all()
|
||||
product_ids = {f"{row.database_label}:{row.erp_code}": row.kb_product_id for row in rows}
|
||||
questions = [dict(question, DATABASE_CODE=_erp_code_from_question(question)) for question in questions]
|
||||
product_fingerprints = {key: f"kb:{value}" for key, value in product_ids.items()}
|
||||
items = build_work_items(questions, product_fingerprints)
|
||||
raw = sum(len(item.question_refs) for item in items)
|
||||
persisted = {"created": 0, "updated": 0, "kb_hits": 0, "conflicts": 0}
|
||||
kb = KnowledgeBase(session)
|
||||
for item in items:
|
||||
row = session.scalar(select(AiWorkItem).where(AiWorkItem.dedup_key == item.dedup_key))
|
||||
if row is None:
|
||||
row = AiWorkItem(task_type=item.task_type, dedup_key=item.dedup_key, question_refs=item.question_refs, payload=item.payload, cells_covered=item.cells_covered, status="PENDING")
|
||||
session.add(row); persisted["created"] += 1
|
||||
else:
|
||||
row.question_refs = item.question_refs; row.cells_covered = item.cells_covered; row.payload = item.payload
|
||||
persisted["updated"] += 1
|
||||
# cache-first: an existing non-conflicting validated answer makes this a KB hit, no AI call
|
||||
if item.dedup_key.startswith("invalid_ncm:kb:") and item.dedup_key != "invalid_ncm:kb:None":
|
||||
kb_product_id = int(item.dedup_key.rsplit(":", 1)[1])
|
||||
knowledge = kb.current_knowledge(kb_product_id)
|
||||
validated = knowledge.get("validated_ncm", {})
|
||||
if validated and not validated.get("conflict"):
|
||||
row.status = "KB_HIT"; persisted["kb_hits"] += 1
|
||||
elif validated.get("conflict"):
|
||||
row.status = "CONFLICT"; persisted["conflicts"] += 1
|
||||
session.commit()
|
||||
by_task = {}
|
||||
for item in items:
|
||||
bucket = by_task.setdefault(item.task_type, {"items": 0, "cells": 0})
|
||||
bucket["items"] += 1; bucket["cells"] += item.cells_covered
|
||||
report = {
|
||||
"artifact": "AI_RESIDUAL_PLAN_V1",
|
||||
"raw_residual_questions": len(questions),
|
||||
"ai_work_items_raw": raw,
|
||||
"ai_work_items_deduped": len(items),
|
||||
"by_task_type": by_task,
|
||||
"persisted": persisted,
|
||||
"ai_write_allowed": False,
|
||||
"company_decisions_remaining": 14,
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--questions", default="docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json")
|
||||
parser.add_argument("--output", default="docs/AI_RESIDUAL_PLAN_V1.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.questions, args.output), indent=2))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Create a human panel administrator without ever printing the password."""
|
||||
|
||||
import getpass
|
||||
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.human_auth import create_admin
|
||||
|
||||
|
||||
def main():
|
||||
username = input("Admin username: ").strip()
|
||||
password = getpass.getpass("Admin password: ")
|
||||
confirmation = getpass.getpass("Confirm password: ")
|
||||
if password != confirmation:
|
||||
raise SystemExit("passwords do not match")
|
||||
with SessionLocal() as session:
|
||||
admin = create_admin(session, username, password)
|
||||
print(f"created admin {admin.username}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Generate explicit, deterministic non-production control-panel data."""
|
||||
|
||||
import argparse
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from central.app.config import settings
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Agent, AgentEvent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def generate_fixture(session, agent_count=100, seed=7):
|
||||
now = datetime.now(timezone.utc)
|
||||
states = ["ONLINE"] * 50 + ["DEGRADED"] * 20 + ["OFFLINE"] * 15 + ["FAILED"] * 10 + ["UNKNOWN"] * 5
|
||||
created = {"agents": 0, "events": 0, "blockers": 0, "jobs": 0}
|
||||
for index in range(agent_count):
|
||||
tenant = Tenant(name=f"Fixture Client {index:03d}"); session.add(tenant); session.flush()
|
||||
agent_version = None if index % 17 == 0 else ("0.9.0" if index % 5 == 0 else "1.0.0rc1")
|
||||
agent = Agent(tenant_id=tenant.id, name=f"fixture-agent-{index:03d}", token_hash=(f"fixture-{index:03d}".ljust(64, "0"))[:64], agent_version=agent_version); session.add(agent); session.flush()
|
||||
state = states[index % len(states)]
|
||||
last_seen = None if state == "UNKNOWN" else now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000)
|
||||
installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")), display_name=f"{tenant.name} - Servidor Principal", customer_code=f"F{index:03d}", hostname=f"srv-{index:03d}", company_name=f"Empresa {index:03d}", company_uf="BA", company_crt="1", last_seen_at=last_seen, last_status="IDLE")
|
||||
session.add(installation); session.flush()
|
||||
total_products = 5000 + index
|
||||
safe_blocked = 1 if index % 2 == 0 else 0
|
||||
failed_products = 1 if state == "FAILED" else 0
|
||||
fiscally_complete = total_products - safe_blocked - failed_products
|
||||
coverage_percent = round(fiscally_complete * 100 / total_products, 1)
|
||||
snapshot = AgentSnapshot(installation_id=installation.id, total_products=total_products, fiscally_complete=fiscally_complete, safe_blocked=safe_blocked, failed_products=failed_products, coverage_percent=coverage_percent, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED")
|
||||
session.add(snapshot); created["agents"] += 1
|
||||
|
||||
failed = state in {"DEGRADED", "FAILED"}
|
||||
job = Job(agent_id=agent.id, kind="REPROCESS_ALL", status="FAILED" if failed else "COMPLETED", payload={}, result_summary={"fixture": True}, error="fixture technical failure" if failed else None, started_at=now - timedelta(minutes=6) if failed else None, finished_at=now - timedelta(minutes=5) if failed else None)
|
||||
session.add(job); session.flush()
|
||||
created["jobs"] += 1
|
||||
|
||||
fixture_blocker = None
|
||||
if index % 2 == 0:
|
||||
fixture_blocker = BlockedProduct(tenant_id=tenant.id, installation_id=installation.id, company=tenant.name, product_code=f"P{index:06d}", description="Fixture product", blocker_type="CFOP", field="CFOP", reason_code="INSUFFICIENT_EVIDENCE", evidence_summary={"fixture": True}, input_fingerprint=f"{index:064d}", blocker_fingerprint=f"{index+1000:064d}")
|
||||
session.add(fixture_blocker); session.flush()
|
||||
created["blockers"] += 1
|
||||
if index == 0:
|
||||
session.add(BlockedProduct(tenant_id=tenant.id, installation_id=installation.id, company=tenant.name, product_code=fixture_blocker.product_code, description="Fixture product", blocker_type="TAX_RULE_MISSING", field="ST", reason_code="MISSING_REFERENCE", evidence_summary={"fixture": True}, input_fingerprint=f"{index+2000:064d}", blocker_fingerprint=f"{index+3000:064d}"))
|
||||
created["blockers"] += 1
|
||||
for event_index in range(5):
|
||||
linked = event_index == 0
|
||||
session.add(AgentEvent(tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if failed and linked else "INFO", event_type="JOB_FAILED" if failed and linked else "HEARTBEAT", message="Fixture job failed" if failed and linked else "Fixture operational event", structured_context={"fixture": True}, job_id=job.id if linked else None, product_code=fixture_blocker.product_code if linked and fixture_blocker else None, blocker_id=fixture_blocker.id if linked and fixture_blocker else None, occurred_at=now - timedelta(minutes=event_index)))
|
||||
created["events"] += 1
|
||||
session.commit()
|
||||
return created
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(); parser.add_argument("--database-url", default=None); parser.add_argument("--agents", type=int, default=100); parser.add_argument("--seed", type=int, default=7); parser.add_argument("--confirm-non-production", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if settings.environment == "production" or not args.confirm_non_production:
|
||||
raise SystemExit("refusing fixture generation without --confirm-non-production in non-production")
|
||||
session_factory = SessionLocal
|
||||
if args.database_url:
|
||||
session_factory = sessionmaker(bind=create_engine(args.database_url))
|
||||
with session_factory() as session:
|
||||
print(generate_fixture(session, args.agents, args.seed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Promote RICMS-BA Anexo 1 (BA_ST_MVA) from the local official PDF snapshot.
|
||||
|
||||
No network fetch: RICMS-BA Anexo 1 has no stable public direct-download URL
|
||||
(the SEFAZ-BA legislation portal serves it via a session-gated viewer), so
|
||||
the authoritative copy is kept in sources/raw/bootstrap_v1/. This script
|
||||
re-validates it against the currently ACTIVE Postgres dataset and only
|
||||
promotes a new version if the parsed content actually differs, never
|
||||
overwriting a valid ACTIVE version with a partial/regressed parse.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Dataset
|
||||
|
||||
SOURCE = Path("sources/raw/bootstrap_v1/decreto_2012_13780_ricms_anexo_1_vigente_2026.pdf")
|
||||
SOURCE_URL = f"file://{SOURCE.name}"
|
||||
|
||||
|
||||
def main():
|
||||
raw_bytes = SOURCE.read_bytes()
|
||||
raw_sha256 = hashlib.sha256(raw_bytes).hexdigest()
|
||||
|
||||
records, rejected = [], []
|
||||
for page, value in enumerate(PdfReader(SOURCE).pages, start=1):
|
||||
found, failed = parse_page(value.extract_text() or "", page)
|
||||
records.extend(found)
|
||||
rejected.extend(failed)
|
||||
|
||||
if not records:
|
||||
print(json.dumps({"status": "FAILED", "reason": "parser produced zero records; keeping existing ACTIVE dataset"}))
|
||||
return
|
||||
|
||||
with SessionLocal() as session:
|
||||
current_active = session.scalar(
|
||||
__import__("sqlalchemy").select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE")
|
||||
)
|
||||
if current_active is not None and current_active.raw_sha256 == raw_sha256:
|
||||
print(json.dumps({
|
||||
"status": "UNCHANGED", "dataset_id": current_active.id, "raw_sha256": raw_sha256,
|
||||
"record_count": current_active.record_count,
|
||||
}))
|
||||
return
|
||||
if current_active is not None and len(records) < current_active.record_count * 0.9:
|
||||
# Smoke gate: refuse to promote a parse that lost >10% of records versus
|
||||
# the currently ACTIVE version — never silently replace a good version
|
||||
# with a broken one.
|
||||
print(json.dumps({
|
||||
"status": "FAILED",
|
||||
"reason": f"new parse yielded {len(records)} records, active has {current_active.record_count} (>10% loss); keeping ACTIVE",
|
||||
}))
|
||||
return
|
||||
|
||||
dataset = DatasetService(session).import_ba_st_mva(records, raw_sha256, SOURCE_URL, datetime.now(timezone.utc))
|
||||
|
||||
print(json.dumps({
|
||||
"status": dataset.status, "dataset_id": dataset.id, "raw_sha256": raw_sha256,
|
||||
"record_count": dataset.record_count, "rejected": len(rejected),
|
||||
"previously_active_count": current_active.record_count if current_active else None,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Promote CFOP/FCP/ANP/NCM_UTRIB/RTC_BIOFUEL_INDEX/RTC_CCREDPRES from the
|
||||
official spreadsheets the operator placed in sources/raw/bootstrap_v1/ after
|
||||
the Portal NF-e/SPED downloads were session-blocked. No network fetch.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
ROOT = Path("sources/raw/bootstrap_v1")
|
||||
|
||||
JOBS = [
|
||||
("CFOP", ROOT / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx", parsers.parse_cfop, "import_cfop"),
|
||||
("FCP", ROOT / "Tabela_Alíquotas_FCP_por_UF_2025-02_07.xlsx", parsers.parse_fcp, "import_fcp"),
|
||||
("ANP", ROOT / "Tabela_Codigo_Produto_ANP-20241118.xlsx", parsers.parse_anp, "import_anp"),
|
||||
("NCM_UTRIB", ROOT / "IT2024.001v2.40 - Tabela NCM 2022 com Utrib_Comércio Exterior_vigência 01.10.26.xlsx", parsers.parse_ncm_utrib, "import_ncm_utrib"),
|
||||
("RTC_BIOFUEL_INDEX", ROOT / "IT 2025.004 v.1.20 - Tabela de Índice.xlsx", parsers.parse_rtc_biofuel_index, "import_rtc_biofuel_index"),
|
||||
("RTC_CCREDPRES", ROOT / "cCredPres_2026-06-22.xlsx", parsers.parse_rtc_cred_pres, "import_rtc_cred_pres"),
|
||||
("RTC_CCLASSTRIB", ROOT / "cClassTrib 2026-06-22.xlsx", parsers.parse_rtc_cclasstrib, "import_rtc_cclasstrib"),
|
||||
("RTC_CBS_RATES", ROOT / "IT 2026.002 v.1.00 - Aliquotas da CBS.xlsx", parsers.parse_rtc_cbs_rates, "import_rtc_cbs_rates"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
results = {}
|
||||
with SessionLocal() as session:
|
||||
service = DatasetService(session)
|
||||
for domain, path, parse_fn, method_name in JOBS:
|
||||
try:
|
||||
if not path.exists():
|
||||
results[domain] = {"status": "MISSING_BOOTSTRAP_FILE", "path": str(path)}
|
||||
continue
|
||||
payload = parse_fn(path.read_bytes())
|
||||
dataset = getattr(service, method_name)(payload, f"file://{path.name}", datetime.now(timezone.utc))
|
||||
results[domain] = {"status": dataset.status, "dataset_id": dataset.id, "sha256": dataset.sha256, "record_count": dataset.record_count}
|
||||
except Exception as error: # noqa: BLE001 — isolate one domain's failure from the rest
|
||||
results[domain] = {"status": "FAILED", "reason": f"{type(error).__name__}: {error}"}
|
||||
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Download and promote the official CONFAZ CV142_18 HTML CEST source."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from central.app.config import settings
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
|
||||
def main():
|
||||
response = httpx.get(settings.cest_dataset_url, timeout=settings.http_timeout_seconds, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
parsed = parse_cest_html(response.content)
|
||||
path = Path(settings.dataset_storage_path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{parsed.sha256}.html").write_bytes(response.content)
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_cest_payload(parsed, settings.cest_dataset_url, datetime.now(timezone.utc))
|
||||
print({"id": dataset.id, "status": dataset.status, "sha256": dataset.sha256, "record_count": dataset.record_count, "version": parsed.version})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Import historical ERP evidence (miner output) into the central KB. Dry-run by default; --apply commits. Local data only, no provider call.
|
||||
|
||||
python -m central.scripts.import_kb_history --staging /path/to/history [--apply] [--report out.json]
|
||||
|
||||
Runs against the database in DATABASE_URL (the Hub's PostgreSQL). Idempotent. Never touches any client database.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.enrichment import history
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--staging", required=True)
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
ap.add_argument("--report", default=None)
|
||||
ap.add_argument("--only-current-vigente-gtins", action="store_true", help="skip GTINs without any NCM evidence")
|
||||
args = ap.parse_args(argv)
|
||||
files = sorted(f for f in Path(args.staging).glob("*.json") if not f.name.startswith("_"))
|
||||
stagings = [json.loads(f.read_text(encoding="utf-8")) for f in files]
|
||||
t0 = time.time()
|
||||
obs = history.build_observations(stagings)
|
||||
if args.only_current_vigente_gtins:
|
||||
obs["gtins"] = {k: v for k, v in obs["gtins"].items() if v["ncm"]}
|
||||
with SessionLocal() as session:
|
||||
current = history.official_sets(session)
|
||||
report = history.apply(session, obs, lambda v: v in current, dry_run=not args.apply)
|
||||
report.update({"mode": "APPLY" if args.apply else "DRY_RUN", "clients": [s["client"] for s in stagings], "build_stats": obs["stats"], "seconds": round(time.time() - t0, 1)})
|
||||
text = json.dumps(report, indent=1, default=str)
|
||||
if args.report:
|
||||
Path(args.report).write_text(text, encoding="utf-8")
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Seed reviewed official legacy NCM evidence; it never selects a successor."""
|
||||
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import LegacyNcmEntry
|
||||
|
||||
|
||||
LEGACY_NCM = {
|
||||
"code": "02071400",
|
||||
"source": "Siscomex NCM official nomenclature download",
|
||||
"source_date": "2026-09-17",
|
||||
"reason": "NCM not present in current official nomenclature; no official successor.",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
with SessionLocal() as session:
|
||||
entry = session.get(LegacyNcmEntry, LEGACY_NCM["code"])
|
||||
if entry is None:
|
||||
session.add(LegacyNcmEntry(**LEGACY_NCM, successor_code=None))
|
||||
session.commit()
|
||||
print({"code": LEGACY_NCM["code"], "status": "LEGACY_NCM", "successor_code": None})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Promote ORIGEM_MERCADORIA/CST_ICMS/CSOSN/CST_PIS/CST_COFINS from the local
|
||||
official MOC (Anexo I NF-e/NFC-e) PDF. No network fetch — this manual has no
|
||||
stable public direct-download URL either (session-gated portal), so the
|
||||
authoritative copy lives in sources/raw/bootstrap_v1/.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.datasets.nfe_moc_tables import parse_moc_tables
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
SOURCE = Path("sources/raw/bootstrap_v1/moc7-anexo-i-leiaute-e-rv.pdf")
|
||||
SOURCE_URL = f"file://{SOURCE.name}"
|
||||
|
||||
|
||||
def main():
|
||||
raw_bytes = SOURCE.read_bytes()
|
||||
payload = parse_moc_tables(raw_bytes)
|
||||
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_moc_tables(payload, SOURCE_URL, datetime.now(timezone.utc))
|
||||
|
||||
from collections import Counter
|
||||
domains = Counter(e.domain for e in payload.entries)
|
||||
print(json.dumps({
|
||||
"status": dataset.status, "dataset_id": dataset.id, "sha256": dataset.sha256,
|
||||
"record_count": dataset.record_count, "domains": dict(domains),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Download, persist and promote the official NCM dataset."""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from central.app.config import settings
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
def main():
|
||||
response = httpx.get(settings.ncm_dataset_url, timeout=settings.http_timeout_seconds, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
parsed = parse_ncm_payload(response.content)
|
||||
path = Path(settings.dataset_storage_path); path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{parsed.sha256}.json").write_bytes(response.content)
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_payload(parsed, settings.ncm_dataset_url, datetime.now(timezone.utc))
|
||||
print({"id": dataset.id, "status": dataset.status, "sha256": dataset.sha256, "record_count": dataset.record_count})
|
||||
if __name__ == "__main__": main()
|
||||
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pypdf import PdfReader
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
|
||||
source = Path("sources/raw/bootstrap_v1/decreto_2012_13780_ricms_anexo_1_vigente_2026.pdf")
|
||||
records, rejected = [], []
|
||||
for page, value in enumerate(PdfReader(source).pages, start=1):
|
||||
found, failed = parse_page(value.extract_text() or "", page)
|
||||
records.extend(found); rejected.extend(failed)
|
||||
Path("docs/ba_st_mva_inspection.json").write_text(json.dumps({"records": records, "rejected": rejected}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"pages": page, "records": len(records), "rejected": len(rejected), "cest": sum(bool(x["cest"]) for x in records), "ncm": sum(bool(x["ncm_sh"]) for x in records), "mva": sum(bool(x["mva_original"] or x["mva_adjusted"]) for x in records)}))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Expire eligible operational events; audit and critical events are preserved."""
|
||||
|
||||
import argparse
|
||||
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.events import purge_expired_events
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--retention-days", type=int, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.dry_run:
|
||||
print("dry-run: no events deleted")
|
||||
return
|
||||
with SessionLocal() as session:
|
||||
print(f"deleted_events={purge_expired_events(session, retention_days=args.retention_days)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Verify a small, prioritised sample of CORROBORATED GTINs with OSCBR (free plan). Credentials come ONLY from the Hub environment (OSCBR_TOKEN or OSCBR_USER/OSCBR_PASSWORD).
|
||||
|
||||
python -m central.scripts.oscbr_backfill --limit 20 [--dry-run] [--report out.json]
|
||||
|
||||
Never spends the operational reserve (PROVIDER_OPERATIONAL_RESERVE_PERCENT), respects 5/min and 50/day, and never queries VERIFIED_KB, CONFLICT or invalid GTINs.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from central.app.config import settings
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.enrichment import backfill, history
|
||||
from central.app.enrichment.providers import build_providers
|
||||
from central.app.enrichment.quota import ProviderGateway
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--limit", type=int, default=20)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--report", default=None)
|
||||
args = ap.parse_args(argv)
|
||||
providers = build_providers(settings)
|
||||
oscbr = next(p for p in providers if p.name == "oscbr")
|
||||
if not oscbr.provides_ncm and not args.dry_run:
|
||||
print(json.dumps({"OSCBR_ROLE": "IDENTITY", "reason": "NCM not documented in the V3 contract; backfill for NCM verification is meaningless until proven", "requests": 0}))
|
||||
return 3
|
||||
if not oscbr.configured and not args.dry_run:
|
||||
print(json.dumps({"OSCBR_STATUS": "NOT_CONFIGURED", "requests": 0}))
|
||||
return 2
|
||||
with SessionLocal() as session:
|
||||
current = history.official_sets(session)
|
||||
report = backfill.run(session, ProviderGateway(session, [oscbr]), oscbr, limit=args.limit, reserve_percent=settings.provider_operational_reserve_percent,
|
||||
is_official=lambda v: v in current, dry_run=args.dry_run)
|
||||
text = json.dumps(report, indent=1, default=str)
|
||||
if args.report:
|
||||
open(args.report, "w", encoding="utf-8").write(text)
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Prepare versioned candidate evidence from already computed deterministic candidates."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.ai.retrieval_v3 import prepare_candidate_set
|
||||
|
||||
|
||||
def main(input_path, output_path):
|
||||
source = json.loads(Path(input_path).read_text(encoding="utf-8"))
|
||||
prepared = []
|
||||
for item in source.get("items", []):
|
||||
prepared.append(prepare_candidate_set(
|
||||
type("WorkItem", (), {"task_type": item.get("task_type", "INVALID_NCM"), "dedup_key": item["dedup_key"], "payload": item.get("payload", {})})(),
|
||||
[tuple(row) for row in item.get("candidates", [])],
|
||||
dataset_versions=source.get("dataset_versions"),
|
||||
gtin_evidence=item.get("gtin_evidence"),
|
||||
kb_neighbors=item.get("kb_neighbors"),
|
||||
hierarchical_context=item.get("hierarchical_context"),
|
||||
))
|
||||
report = {"artifact": "AI_CANDIDATE_PREPARATION_V3", "items": prepared, "semantic_decisions": 0}
|
||||
Path(output_path).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input_path")
|
||||
parser.add_argument("output_path")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.input_path, args.output_path), indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Administrative CLI: publish a built Agent package as an AgentRelease, ready
|
||||
to be downloaded/enrolled from the panel. Accepts either the legacy universal
|
||||
ZIP (`scripts/build_agent_package.py` output) or the universal Windows
|
||||
installer EXE (`scripts/build_windows_installer.ps1` output). Whatever is
|
||||
published is served verbatim to every installation later.
|
||||
|
||||
Like register_agent.py, this is a one-time, operator-run action against
|
||||
DATABASE_URL directly — never a browser upload endpoint, so the release
|
||||
binary never has to pass through an HTTP request body. The artifact is copied
|
||||
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
|
||||
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
|
||||
|
||||
Usage:
|
||||
python central/scripts/publish_agent_release.py --artifact dist/GTIFiscalAgent-1.0.0.zip \\
|
||||
--notes "First distributable build with secure enrollment"
|
||||
python central/scripts/publish_agent_release.py --artifact dist/installer/GTIFiscalAgent-Setup-1.1.1.exe \\
|
||||
--notes "Universal Windows installer with install-code enrollment"
|
||||
|
||||
Refuses to publish the same version+platform+architecture twice with a
|
||||
different sha256 (an accidental respin must get a new version). Publishing
|
||||
the exact same bytes again is a no-op (idempotent), and a PUBLISHED release
|
||||
is never replaced with different content.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app import release_storage # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
from app.models import AgentRelease # noqa: E402
|
||||
|
||||
|
||||
def publish(session, artifact_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None, filename=None):
|
||||
data = artifact_path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
version = version or _version_from_artifact_name(artifact_path)
|
||||
filename = filename or _release_filename(artifact_path, version, platform, architecture)
|
||||
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
|
||||
if existing:
|
||||
if existing.sha256 == digest and existing.filename == filename:
|
||||
return existing, False # identical content already published: idempotent no-op
|
||||
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published "
|
||||
f"(sha256 {existing.sha256[:12]}..., filename {existing.filename!r}) — the release is immutable; "
|
||||
f"bump the version instead of overwriting it")
|
||||
dest = release_storage.storage_root() / filename
|
||||
dest.write_bytes(data)
|
||||
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
|
||||
size_bytes=len(data), sha256=digest, storage_path=filename, status="PUBLISHED",
|
||||
published_at=datetime.now(timezone.utc),
|
||||
release_notes=notes, created_by=created_by)
|
||||
session.add(release)
|
||||
session.commit()
|
||||
return release, True
|
||||
|
||||
|
||||
def _release_filename(artifact_path: Path, version: str, platform: str, architecture: str) -> str:
|
||||
"""Filename used for the stored artifact. Installer EXEs keep their own
|
||||
Setup-versioned name; ZIPs keep the legacy GTIFiscalAgent-<v>-<p>-<a>.zip."""
|
||||
if artifact_path.name.lower().endswith(".exe"):
|
||||
return artifact_path.name if artifact_path.name.lower() == f"gtifiscalagent-setup-{version}.exe" else release_storage.setup_exe_filename(version)
|
||||
return release_storage.release_filename(version, platform, architecture)
|
||||
|
||||
|
||||
def _version_from_artifact_name(artifact_path: Path) -> str:
|
||||
# GTIFiscalAgent-<version>.zip / GTIFiscalAgent-Setup-<version>.exe
|
||||
stem = artifact_path.stem
|
||||
for prefix in ("GTIFiscalAgent-Setup-", "GTIFiscalAgent-"):
|
||||
if stem.startswith(prefix):
|
||||
return stem[len(prefix):]
|
||||
raise SystemExit(f"cannot infer version from filename {artifact_path.name!r} — pass --version explicitly")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--artifact", required=True, type=Path, help="Path to the built package zip or installer exe")
|
||||
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
|
||||
parser.add_argument("--platform", default="windows")
|
||||
parser.add_argument("--architecture", default="x64")
|
||||
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.artifact.exists():
|
||||
raise SystemExit(f"not found: {args.artifact}")
|
||||
|
||||
with Session(engine) as session:
|
||||
release, created = publish(session, args.artifact, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
|
||||
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
|
||||
f"platform={release.platform} architecture={release.architecture} filename={release.filename} "
|
||||
f"sha256={release.sha256} size_bytes={release.size_bytes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Administrative CLI: register a new Agent directly against Postgres.
|
||||
|
||||
No new HTTP endpoint is needed for this — it's a one-time, operator-run
|
||||
action per installation, run from wherever DATABASE_URL is reachable (the
|
||||
Hub host, or a machine with network access to Postgres). Only the token's
|
||||
SHA-256 hash is ever stored; the plaintext token is printed once and must
|
||||
be copied into that installation's local Agent config immediately.
|
||||
|
||||
Usage:
|
||||
python central/scripts/register_agent.py --company "Restaurante X" --name "loja-01"
|
||||
"""
|
||||
import argparse
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app.auth import token_hash # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
from app.models import Agent, Tenant # noqa: E402
|
||||
|
||||
|
||||
def register_agent(session, company_name, agent_name, token=None):
|
||||
token = token or secrets.token_urlsafe(32)
|
||||
tenant = session.query(Tenant).filter_by(name=company_name).one_or_none()
|
||||
if tenant is None:
|
||||
tenant = Tenant(name=company_name)
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name=agent_name, token_hash=token_hash(token), enabled=True)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
# Read everything the caller needs now, while the session is still open —
|
||||
# session.commit() expires ORM attributes by default, and callers may use
|
||||
# these values after the session (and its `with` block) has closed.
|
||||
return {"id": agent.id, "tenant_id": tenant.id, "enabled": agent.enabled}, token
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--company", required=True, help="Company/tenant name (created if it doesn't exist)")
|
||||
parser.add_argument("--name", required=True, help="Human-readable name for this Agent installation, e.g. 'loja-01'")
|
||||
args = parser.parse_args()
|
||||
|
||||
with Session(engine) as session:
|
||||
info, token = register_agent(session, args.company, args.name)
|
||||
|
||||
print("Agent registered. Copy these into that installation's local .env NOW — the token is shown only once:")
|
||||
print(f" AGENT_ID={info['id']}")
|
||||
print(f" AGENT_TOKEN={token}")
|
||||
print(f" COMPANY_ID={info['tenant_id']}")
|
||||
print(" FIREBIRD_WRITES=0")
|
||||
print(f"enabled={info['enabled']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Reprocess the 750 AI work items with V2 retrieval (section 16).
|
||||
|
||||
Classification per item (INVALID_NCM): identity resolve (GTIN -> fingerprint) against the
|
||||
KB; validated non-conflicting knowledge resolves without AI. Otherwise hybrid retrieval:
|
||||
a HIGH_CONFIDENCE_RETRIEVAL requires hybrid score >= 0.75, an officially valid NCM and
|
||||
cross-tenant agreement >= 2 — it is NOT an automatic fiscal resolution (validator still
|
||||
required). Everything else with candidates -> LLM_SELECTION_REQUIRED; without candidates
|
||||
-> UNRESOLVED.
|
||||
|
||||
CEST/EX_TIPI items never call retrieval semantics beyond the official candidate lists:
|
||||
exactly one official candidate resolves deterministically; none is UNRESOLVED
|
||||
(NO_OFFICIAL_MAPPING); more than one requires (future) LLM selection.
|
||||
PRODUCT_ROLE stays LLM/manual by contract.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.ai.candidates import cest_candidates_for_ncm, ex_tipi_candidates_for_ncm
|
||||
from central.app.ai.kb import EXACT, FINGERPRINT, KnowledgeBase
|
||||
from central.app.ai.retrieval import (EnrichedNcmCorpus, KbNeighborIndex, derive_abbreviation_lexicon, hybrid_candidates)
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import AiWorkItem, Dataset, KbObservation, KbProduct, NcmEntry
|
||||
|
||||
HIGH_CONFIDENCE_THRESHOLD = 0.75
|
||||
|
||||
|
||||
def _resolve_product(session, dedup_key: str):
|
||||
"""invalid_ncm:kb:{id} -> KB product by id; invalid_ncm:{db}:{code} -> via observation."""
|
||||
if dedup_key.startswith("invalid_ncm:kb:"):
|
||||
return session.get(KbProduct, int(dedup_key.rsplit(":", 1)[1]))
|
||||
parts = dedup_key.split(":")
|
||||
if len(parts) == 3:
|
||||
observation = session.scalar(select(KbObservation).where(KbObservation.database_label == parts[1], KbObservation.erp_code == parts[2]))
|
||||
return session.get(KbProduct, observation.kb_product_id) if observation else None
|
||||
return None
|
||||
|
||||
|
||||
def main(output="docs/AI_RESIDUAL_PLAN_V2.json") -> dict:
|
||||
session = SessionLocal()
|
||||
kb = KnowledgeBase(session)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
lexicon = derive_abbreviation_lexicon(index.corpus(), min_support=3)
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
valid_ncm = set(session.scalars(select(NcmEntry.code).where(NcmEntry.dataset_id == dataset.id)).all()) if dataset else set()
|
||||
items = session.scalars(select(AiWorkItem)).all()
|
||||
counts = {key: 0 for key in ("KB_EXACT_HIT", "KB_FINGERPRINT_HIT", "HIGH_CONFIDENCE_RETRIEVAL", "SINGLE_OFFICIAL_CANDIDATE", "LLM_SELECTION_REQUIRED", "UNRESOLVED")}
|
||||
llm_cells = 0
|
||||
for item in items:
|
||||
if item.task_type == "INVALID_NCM":
|
||||
product = None
|
||||
product = _resolve_product(session, item.dedup_key)
|
||||
if product is not None and product.gtin:
|
||||
resolved, hit = kb.resolve(gtin=product.gtin)
|
||||
if resolved and hit == EXACT:
|
||||
knowledge = kb.current_knowledge(resolved.id).get("validated_ncm", {})
|
||||
if knowledge and not knowledge.get("conflict"):
|
||||
item.status = "KB_EXACT_HIT"; counts["KB_EXACT_HIT"] += 1; continue
|
||||
if product is not None:
|
||||
resolved, hit = kb.resolve(fingerprint=product.fingerprint)
|
||||
if resolved and hit == FINGERPRINT:
|
||||
knowledge = kb.current_knowledge(resolved.id).get("validated_ncm", {})
|
||||
if knowledge and not knowledge.get("conflict"):
|
||||
item.status = "KB_FINGERPRINT_HIT"; counts["KB_FINGERPRINT_HIT"] += 1; continue
|
||||
description = product.normalized_description if product is not None else (item.payload or {}).get("description", "")
|
||||
ranked = hybrid_candidates(corpus, index, description, k=10, lexicon=lexicon, exclude_product_id=(product.id if product else None))
|
||||
if ranked and ranked[0][2] >= HIGH_CONFIDENCE_THRESHOLD and ranked[0][0] in valid_ncm and ranked[0][5] >= 2:
|
||||
item.status = "HIGH_CONFIDENCE_RETRIEVAL"; counts["HIGH_CONFIDENCE_RETRIEVAL"] += 1
|
||||
item.payload = {**item.payload, "retrieval_top1": ranked[0][0], "retrieval_score": ranked[0][2], "tenant_agreement": ranked[0][5]}
|
||||
elif ranked:
|
||||
item.status = "LLM_SELECTION_REQUIRED"; counts["LLM_SELECTION_REQUIRED"] += 1; llm_cells += item.cells_covered
|
||||
else:
|
||||
item.status = "UNRESOLVED"; counts["UNRESOLVED"] += 1
|
||||
elif item.task_type in ("CEST", "EX_TIPI"):
|
||||
ncm = (item.payload or {}).get("ncm", "")
|
||||
candidates = cest_candidates_for_ncm(session, ncm) if item.task_type == "CEST" else ex_tipi_candidates_for_ncm(session, ncm)
|
||||
if len(candidates) == 1:
|
||||
item.status = "SINGLE_OFFICIAL_CANDIDATE"; counts["SINGLE_OFFICIAL_CANDIDATE"] += 1
|
||||
elif not candidates:
|
||||
item.status = "UNRESOLVED_NO_OFFICIAL_MAPPING"; counts["UNRESOLVED"] += 1
|
||||
else:
|
||||
item.status = "LLM_SELECTION_REQUIRED"; counts["LLM_SELECTION_REQUIRED"] += 1; llm_cells += item.cells_covered
|
||||
else: # PRODUCT_ROLE
|
||||
item.status = "LLM_SELECTION_REQUIRED"; counts["LLM_SELECTION_REQUIRED"] += 1; llm_cells += item.cells_covered
|
||||
session.commit()
|
||||
llm_items = counts["LLM_SELECTION_REQUIRED"]
|
||||
report = {
|
||||
"artifact": "AI_RESIDUAL_PLAN_V2",
|
||||
"work_items": len(items),
|
||||
"classification": counts,
|
||||
"llm_selection_required_items": llm_items,
|
||||
"llm_selection_required_cells": llm_cells,
|
||||
"high_confidence_threshold": HIGH_CONFIDENCE_THRESHOLD,
|
||||
"note": "HIGH_CONFIDENCE_RETRIEVAL is a candidate, not a fiscal resolution: the official validator + operator gate still apply. AI_WRITE_ALLOWED=false.",
|
||||
"dataset_versions": {"ncm": dataset.sha256} if dataset else {},
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", default="/tmp/plan_v2.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.output), indent=2))
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Offline reprocessing of residual questions against the final canonical identity map."""
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.ai.kb_consolidation import canonical_product_id
|
||||
from central.app.ai.workitems import build_work_items
|
||||
|
||||
|
||||
def _erp_code(question):
|
||||
import re
|
||||
match = re.search(r"PRODUCT\s+(\d+)", question.get("QUESTION", ""))
|
||||
return match.group(1) if match else str(question.get("DATABASE_CODE") or "")
|
||||
|
||||
|
||||
def main(questions_path, payload_path, output_path):
|
||||
questions_doc = json.loads(Path(questions_path).read_text(encoding="utf-8"))
|
||||
payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
|
||||
canonical = {}
|
||||
fingerprints = {}
|
||||
by_observation = defaultdict(list)
|
||||
for product in payload.get("products", []):
|
||||
cid = canonical_product_id(product)
|
||||
for observation in product.get("observations", []):
|
||||
key = f"{observation.get('database')}:{observation.get('erp_code')}"
|
||||
canonical[key] = cid
|
||||
fingerprints[key] = product.get("fingerprint") or cid
|
||||
questions = [dict(question, DATABASE_CODE=_erp_code(question)) for question in questions_doc.get("questions", [])]
|
||||
items = build_work_items(questions, fingerprints, canonical)
|
||||
by_task = {}
|
||||
for item in items:
|
||||
bucket = by_task.setdefault(item.task_type, {"items": 0, "cells": 0})
|
||||
bucket["items"] += 1
|
||||
bucket["cells"] += item.cells_covered
|
||||
report = {
|
||||
"artifact": "AI_RESIDUAL_PLAN_V3",
|
||||
"work_items_before": 750,
|
||||
"work_items_after": len(items),
|
||||
"llm_items_before": 511,
|
||||
# Candidate preparation is evidence-only; without an official validated
|
||||
# answer it must not reclassify a selection item as resolved.
|
||||
"llm_items_after": 511,
|
||||
"llm_reclassification": "UNCHANGED_PENDING_FINAL_PROVIDER_SELECTION",
|
||||
"by_task_type": by_task,
|
||||
"canonical_observation_keys": len(canonical),
|
||||
"ai_write_allowed": False,
|
||||
"semantic_decisions": 0,
|
||||
}
|
||||
Path(output_path).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--questions", default="docs/LOCAL_FISCAL_CONTEXT_QUESTIONS_V2.json")
|
||||
parser.add_argument("--payload", default="docs/runs/ai_kb_seed_payload.json")
|
||||
parser.add_argument("--output", default="docs/AI_RESIDUAL_PLAN_V3.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.questions, args.payload, args.output), indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""NCM evaluation benchmark (section 19) — same pipeline as production, known answer hidden.
|
||||
|
||||
For a stratified sample of products whose NCM is valid in the ACTIVE dataset:
|
||||
candidate generation (ncm_top_k) -> provider selection -> validator.
|
||||
Metrics: EXACT_MATCH / WRONG_SELECTION / NONE_WHEN_KNOWN / INVALID_OUTPUT (+ recall@K),
|
||||
per segment (database). With no AI credential the BaselineProvider runs: it measures the
|
||||
deterministic floor of the same pipeline and costs nothing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.ai.candidates import ncm_top_k
|
||||
from central.app.ai.provider import BaselineProvider, PROMPT_VERSION, SemanticOutputError, result_from_dict
|
||||
from central.app.ai.fingerprint import normalize_description
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Dataset, KbObservation, KbProduct, NcmEntry
|
||||
|
||||
SEGMENTS = {"CONSTRUCAO": "construcao", "MMMODAS": "moda", "RINCAO": "restaurante", "UNIAO": "mercado", "TESTESOFICIAIS": "base_testes"}
|
||||
|
||||
|
||||
def _samples(session, per_segment=40, seed=42):
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
valid_ncm = set(session.scalars(select(NcmEntry.code).where(NcmEntry.dataset_id == dataset.id)).all())
|
||||
rows = session.execute(select(KbProduct.normalized_description, KbObservation.database_label, KbObservation.observed_fields).join(KbObservation, KbObservation.kb_product_id == KbProduct.id)).all()
|
||||
by_segment = {}
|
||||
for description, database, fields in rows:
|
||||
ncm = (fields or {}).get("ncm")
|
||||
if ncm and ncm in valid_ncm and description:
|
||||
by_segment.setdefault(SEGMENTS.get(database, database), set()).add((description, ncm))
|
||||
rng = random.Random(seed)
|
||||
samples = []
|
||||
for segment, values in sorted(by_segment.items()):
|
||||
picked = sorted(values)[: per_segment] if len(values) <= per_segment else rng.sample(sorted(values), per_segment)
|
||||
samples.extend({"segment": segment, "description": description, "true_ncm": ncm} for description, ncm in picked)
|
||||
return samples, dataset.sha256
|
||||
|
||||
|
||||
def main(per_segment=40, k=10, output="docs/AI_EVALUATION_V1.json") -> dict:
|
||||
session = SessionLocal()
|
||||
samples, ncm_sha = _samples(session, per_segment)
|
||||
provider = BaselineProvider()
|
||||
metrics = {"EXACT_MATCH": 0, "WRONG_SELECTION": 0, "NONE_WHEN_KNOWN": 0, "INVALID_OUTPUT": 0, "RECALL_AT_K": 0}
|
||||
per_segment = {}
|
||||
for sample in samples:
|
||||
candidates = ncm_top_k(session, sample["description"], k=k)
|
||||
codes = [code for code, _, _ in candidates]
|
||||
bucket = per_segment.setdefault(sample["segment"], {key: 0 for key in metrics} | {"TOTAL": 0})
|
||||
bucket["TOTAL"] += 1
|
||||
if sample["true_ncm"] in codes:
|
||||
metrics["RECALL_AT_K"] += 1; bucket["RECALL_AT_K"] += 1
|
||||
item = type("W", (), {"task_type": "INVALID_NCM", "dedup_key": f"eval:{sample['segment']}:{normalize_description(sample['description'])[:60]}", "payload": {"description": sample["description"]}})()
|
||||
try:
|
||||
result = result_from_dict(provider.select(item, candidates, dataset_versions={"ncm": ncm_sha}).to_dict())
|
||||
except SemanticOutputError:
|
||||
metrics["INVALID_OUTPUT"] += 1; bucket["INVALID_OUTPUT"] += 1
|
||||
continue
|
||||
if result.selected_candidate == sample["true_ncm"]:
|
||||
metrics["EXACT_MATCH"] += 1; bucket["EXACT_MATCH"] += 1
|
||||
elif result.selected_candidate == "NONE":
|
||||
metrics["NONE_WHEN_KNOWN"] += 1; bucket["NONE_WHEN_KNOWN"] += 1
|
||||
else:
|
||||
metrics["WRONG_SELECTION"] += 1; bucket["WRONG_SELECTION"] += 1
|
||||
total = len(samples)
|
||||
report = {
|
||||
"artifact": "AI_EVALUATION_V1",
|
||||
"task": "INVALID_NCM candidate selection",
|
||||
"pipeline": "candidate_generation -> provider_selection -> validator",
|
||||
"provider": provider.name,
|
||||
"model": provider.model,
|
||||
"prompt_version": PROMPT_VERSION,
|
||||
"dataset_versions": {"ncm": ncm_sha},
|
||||
"k": k,
|
||||
"eval_total": total,
|
||||
"metrics": metrics,
|
||||
"accuracy_exact_match": round(metrics["EXACT_MATCH"] / total, 4) if total else None,
|
||||
"recall_at_k": round(metrics["RECALL_AT_K"] / total, 4) if total else None,
|
||||
"by_segment": per_segment,
|
||||
"cost_usd": 0.0,
|
||||
"note": "baseline provider (deterministic, zero-cost): measures the pipeline floor; rerun with a real provider once credentials exist",
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--per-segment", type=int, default=40)
|
||||
parser.add_argument("--k", type=int, default=10)
|
||||
parser.add_argument("--output", default="docs/AI_EVALUATION_V1.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.per_segment, args.k, args.output), indent=2))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""NCM retrieval evaluation V2 (sections 1, 13).
|
||||
|
||||
Same sampling as V1 (seed 42, per-segment) with each sample now quality-classified:
|
||||
GOLD - GTIN product or cross-tenant agreement on the same fingerprint/NCM
|
||||
SILVER - single-tenant valid NCM, no independent confirmation
|
||||
NOISY - commercial description with < 2 informative tokens (not pursued)
|
||||
Retrieval measured separately: OFFICIAL_ONLY (hierarchical enriched corpus),
|
||||
KB_ONLY (neighbor bridge), HYBRID; recall@1/5/10/20/50 + MRR, per class and segment.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.ai.retrieval import (EnrichedNcmCorpus, KbNeighborIndex, commercial_tokens, derive_abbreviation_lexicon,
|
||||
hierarchical_candidates, hybrid_candidates, kb_neighbor_candidates)
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Dataset, KbObservation, KbProduct, NcmEntry
|
||||
|
||||
SEGMENTS = {"CONSTRUCAO": "construcao", "MMMODAS": "moda", "RINCAO": "restaurante", "UNIAO": "mercado", "TESTESOFICIAIS": "base_testes"}
|
||||
KS = (1, 5, 10, 20, 50)
|
||||
|
||||
|
||||
def _samples(session, per_segment=40, seed=42):
|
||||
import random
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
valid_ncm = set(session.scalars(select(NcmEntry.code).where(NcmEntry.dataset_id == dataset.id)).all())
|
||||
rows = session.execute(
|
||||
select(KbProduct, KbObservation).join(KbObservation, KbObservation.kb_product_id == KbProduct.id)
|
||||
).all()
|
||||
per_product_tenants = {}
|
||||
for product, observation in rows:
|
||||
per_product_tenants.setdefault(product.id, set()).add(observation.tenant_id)
|
||||
by_segment = {}
|
||||
for product, observation in rows:
|
||||
ncm = (observation.observed_fields or {}).get("ncm")
|
||||
if ncm and ncm in valid_ncm and product.normalized_description:
|
||||
by_segment.setdefault(SEGMENTS.get(observation.database_label, observation.database_label), set()).add(
|
||||
(product.id, product.normalized_description, ncm, bool(product.gtin), len(per_product_tenants[product.id]) >= 2)
|
||||
)
|
||||
rng = random.Random(seed)
|
||||
samples = []
|
||||
for segment, values in sorted(by_segment.items()):
|
||||
picked = sorted(values) if len(values) <= per_segment else rng.sample(sorted(values), per_segment)
|
||||
for product_id, description, ncm, has_gtin, cross_tenant in picked:
|
||||
quality = "NOISY" if len(commercial_tokens(description)) < 2 else ("GOLD" if (has_gtin or cross_tenant) else "SILVER")
|
||||
samples.append({"segment": segment, "product_id": product_id, "description": description, "true_ncm": ncm, "quality": quality})
|
||||
return samples
|
||||
|
||||
|
||||
def _recall_mrr(runs, samples):
|
||||
"""runs: {mode: [ranked_codes_per_sample]}"""
|
||||
out = {}
|
||||
for mode, ranked_lists in runs.items():
|
||||
metrics = {f"recall_at_{k}": 0 for k in KS} | {"mrr": 0.0}
|
||||
total = 0
|
||||
for sample, ranked in zip(samples, ranked_lists):
|
||||
codes = [row[0] for row in ranked]
|
||||
total += 1
|
||||
for k in KS:
|
||||
if sample["true_ncm"] in codes[:k]:
|
||||
metrics[f"recall_at_{k}"] += 1
|
||||
if sample["true_ncm"] in codes:
|
||||
metrics["mrr"] += 1.0 / (codes.index(sample["true_ncm"]) + 1)
|
||||
if total:
|
||||
for key in metrics:
|
||||
metrics[key] = round(metrics[key] / total, 4)
|
||||
out[mode] = {"total": total, **metrics}
|
||||
return out
|
||||
|
||||
|
||||
def main(per_segment=40, output="docs/NCM_RETRIEVAL_EVALUATION_V2.json") -> dict:
|
||||
session = SessionLocal()
|
||||
samples = _samples(session, per_segment)
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
lexicon = derive_abbreviation_lexicon(index.corpus(), min_support=3)
|
||||
runs = {"OFFICIAL_ONLY": [], "KB_ONLY": [], "HYBRID": []}
|
||||
for sample in samples:
|
||||
# leave-one-out: the queried product never retrieves itself from the KB
|
||||
runs["OFFICIAL_ONLY"].append(hierarchical_candidates(corpus, sample["description"], k=50))
|
||||
runs["KB_ONLY"].append(kb_neighbor_candidates(index, sample["description"], k=50, exclude_product_id=sample["product_id"]))
|
||||
runs["HYBRID"].append(hybrid_candidates(corpus, index, sample["description"], k=50, lexicon=lexicon, exclude_product_id=sample["product_id"]))
|
||||
overall = _recall_mrr(runs, samples)
|
||||
by_quality = {quality: _recall_mrr({mode: [row for row, sample in zip(list_rows, samples) if sample["quality"] == quality] for mode, list_rows in runs.items()},
|
||||
[sample for sample in samples if sample["quality"] == quality]) for quality in ("GOLD", "SILVER", "NOISY")}
|
||||
by_segment = {segment: _recall_mrr({mode: [row for row, sample in zip(list_rows, samples) if sample["segment"] == segment] for mode, list_rows in runs.items()},
|
||||
[sample for sample in samples if sample["segment"] == segment]) for segment in sorted({sample["segment"] for sample in samples})}
|
||||
report = {
|
||||
"artifact": "NCM_RETRIEVAL_EVALUATION_V2",
|
||||
"eval_total": len(samples),
|
||||
"quality_counts": {quality: sum(1 for sample in samples if sample["quality"] == quality) for quality in ("GOLD", "SILVER", "NOISY")},
|
||||
"lexicon_size": len(lexicon),
|
||||
"lexicon_sample": dict(sorted(lexicon.items())[:40]),
|
||||
"overall": overall,
|
||||
"by_quality": by_quality,
|
||||
"by_segment": by_segment,
|
||||
"llm_liberation_gate": {"gold_recall_at_20_min": 0.80, "gold_recall_at_50_preferred": 0.90},
|
||||
"cost_usd": 0.0,
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--per-segment", type=int, default=40)
|
||||
parser.add_argument("--output", default="docs/NCM_RETRIEVAL_EVALUATION_V2.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.per_segment, args.output), indent=2))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Run the real evaluation plumbing without network, credentials, or Firebird writes."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.ai.batch_runner import run_batches
|
||||
from central.app.ai.fake_provider import DeterministicFakeSemanticProvider
|
||||
from central.app.ai.llm_provider import LlmEvalItem
|
||||
|
||||
|
||||
def _items(prefix, count):
|
||||
return [
|
||||
LlmEvalItem(
|
||||
work_item_id=f"{prefix}-{index:03d}",
|
||||
description=f"Produto de teste {index}",
|
||||
normalized_description=f"PRODUTO TESTE {index}",
|
||||
brand="TESTE",
|
||||
unit="UN",
|
||||
segment="offline",
|
||||
candidates=[(f"{index:08d}", "CANDIDATO OFICIAL PRINCIPAL", 1.0, 1.0, 0.0, 0),
|
||||
(f"{index + 1:08d}", "CANDIDATO OFICIAL ALTERNATIVO", 0.5, 0.5, 0.0, 0)],
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _run_phase(name, items, batch_sizes):
|
||||
phase = {"items": len(items), "batch_sizes": {}, "provider_calls": 0, "retries": 0, "answered": 0, "errors": 0}
|
||||
for batch_size in batch_sizes:
|
||||
provider = DeterministicFakeSemanticProvider()
|
||||
result = run_batches(items, provider, batch_size=batch_size)
|
||||
phase["batch_sizes"][str(batch_size)] = {
|
||||
"provider_calls": result.provider_calls,
|
||||
"retries": result.retries,
|
||||
"answered": len(result.results_by_work_item_id),
|
||||
"errors": len(result.errors),
|
||||
}
|
||||
phase["provider_calls"] += result.provider_calls
|
||||
phase["retries"] += result.retries
|
||||
phase["answered"] = max(phase["answered"], len(result.results_by_work_item_id))
|
||||
phase["errors"] = max(phase["errors"], len(result.errors))
|
||||
phase["phase"] = name
|
||||
return phase
|
||||
|
||||
|
||||
def run_offline_ai_simulation(output_dir="docs/runs/offline_ai", scenario="CORRECT", batch_sizes=(5, 10, 20), final_work_items_path=None, final_candidates_path=None):
|
||||
"""Exercise GOLD -> gate -> SILVER/NOISY -> residual plumbing only."""
|
||||
if scenario != "CORRECT":
|
||||
raise ValueError("E2E simulation uses CORRECT only; failure scenarios belong to focused tests")
|
||||
gold = _items("gold", 65)
|
||||
silver = _items("silver", 118)
|
||||
noisy = _items("noisy", 17)
|
||||
gold_phase = _run_phase("GOLD", gold, batch_sizes)
|
||||
correct = gold_phase["answered"]
|
||||
selection_accuracy = correct / len(gold) if gold else 0.0
|
||||
gate = selection_accuracy >= 0.95 and gold_phase["errors"] == 0
|
||||
phases = [gold_phase]
|
||||
if gate:
|
||||
phases.extend((_run_phase("SILVER", silver, batch_sizes), _run_phase("NOISY", noisy, batch_sizes)))
|
||||
final_inputs = {}
|
||||
for label, path in (("work_items", final_work_items_path), ("candidates", final_candidates_path)):
|
||||
if path:
|
||||
source = Path(path)
|
||||
if source.exists():
|
||||
final_inputs[label] = {"path": str(source), "bytes": source.stat().st_size, "loaded": True}
|
||||
report = {
|
||||
"status": "PASS_PLUMBING" if gate else "FAIL_PLUMBING",
|
||||
"scenario": scenario,
|
||||
"gold": {"count": len(gold), "selection_accuracy": selection_accuracy, "gate_opened": gate},
|
||||
"phases": phases,
|
||||
"residual_plan": {"executed": gate, "semantic_results_accepted": False, "llm_selection_required": 511},
|
||||
"external_calls": 0,
|
||||
"firebird_ai_writes": 0,
|
||||
"ai_write_allowed": False,
|
||||
"fake_results_are_authoritative": False,
|
||||
"final_inputs": final_inputs,
|
||||
}
|
||||
path = Path(output_dir) / "OFFLINE_AI_SIMULATION_V1.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-dir", default="docs/runs/offline_ai")
|
||||
parser.add_argument("--final-work-items")
|
||||
parser.add_argument("--final-candidates")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(run_offline_ai_simulation(args.output_dir, final_work_items_path=args.final_work_items, final_candidates_path=args.final_candidates), indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Real LLM NCM evaluation over known-answer cases (mission REAL LLM EVALUATION V1).
|
||||
|
||||
Phases: gold first (65 cases, gold completely hidden from the prompt); silver and noisy
|
||||
run ONLY after the gold quality gate passes (selection accuracy >= 0.95 among valid
|
||||
answers AND high-confidence wrong rate <= 0.02). Batching verified progressively
|
||||
(5/10/20) with 1:1 id matching; results cached permanently per
|
||||
(input_fingerprint, candidate_set_hash, prompt_version, model, dataset_versions) so an
|
||||
identical re-execution makes ZERO new calls. AI_WRITE_ALLOWED=false; nothing here can
|
||||
produce a Firebird write.
|
||||
|
||||
If no AI credential exists the run refuses to call anything and exits with
|
||||
BLOCKED_EXTERNAL_AI_CREDENTIAL (no key is ever invented).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.ai.eval_metrics import attribution, confidence_buckets, metrics
|
||||
from central.app.ai.llm_provider import (PROMPT_VERSION_LLM, LlmAnswerError, LlmEvalItem, OpenAICompatibleProvider,
|
||||
candidate_set_hash, input_fingerprint, validate_answer)
|
||||
from central.app.ai.retrieval import EnrichedNcmCorpus, KbNeighborIndex, derive_abbreviation_lexicon, hybrid_candidates
|
||||
from central.app.config import settings
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import AiEvalCache, Dataset, NcmEntry
|
||||
|
||||
GATE_SELECTION_ACCURACY = 0.95
|
||||
GATE_HIGH_CONF_WRONG = 0.02
|
||||
BATCH_PROBE_SIZES = (20, 10, 5)
|
||||
|
||||
|
||||
def _load_samples(session, per_segment=40):
|
||||
from central.scripts.run_ncm_retrieval_eval_v2 import _samples
|
||||
return _samples(session, per_segment)
|
||||
|
||||
|
||||
def _build_items(samples, corpus, index, lexicon, top_k=20):
|
||||
items = []
|
||||
meta = []
|
||||
for sample in samples:
|
||||
ranked = hybrid_candidates(corpus, index, sample["description"], k=top_k, lexicon=lexicon, exclude_product_id=sample["product_id"])
|
||||
item = LlmEvalItem(
|
||||
work_item_id=f"{sample['segment']}:{sample['product_id']}",
|
||||
description=sample["description"],
|
||||
normalized_description=sample["description"],
|
||||
brand=None, unit=None, segment=sample["segment"],
|
||||
candidates=ranked,
|
||||
)
|
||||
items.append(item)
|
||||
meta.append({**sample, "gold_in_candidates": sample["true_ncm"] in {code for code, *_ in ranked}})
|
||||
return items, meta
|
||||
|
||||
|
||||
def _cached_answers(session, items, model, ncm_sha, phase):
|
||||
hits = {}
|
||||
for item in items:
|
||||
row = session.scalar(select(AiEvalCache).where(
|
||||
AiEvalCache.input_fingerprint == input_fingerprint(item),
|
||||
AiEvalCache.candidate_set_hash == candidate_set_hash(item),
|
||||
AiEvalCache.prompt_version == PROMPT_VERSION_LLM,
|
||||
AiEvalCache.model == model,
|
||||
AiEvalCache.dataset_version_ncm == ncm_sha,
|
||||
AiEvalCache.phase == phase,
|
||||
))
|
||||
if row:
|
||||
hits[item.work_item_id] = row.answer
|
||||
return hits
|
||||
|
||||
|
||||
def _diagnose_http(status_code: int) -> str:
|
||||
return {401: "credential invalid (AI_API_KEY)", 403: "permission/plan does not allow this endpoint or model", 404: "endpoint or model not found (AI_BASE_URL/AI_MODEL)", 429: "quota or rate limit"}.get(status_code, f"http {status_code}")
|
||||
|
||||
|
||||
def _select_model(available: list, requested: str) -> tuple:
|
||||
"""Prefer the operator-requested model; else a current GLM suitable for classification.
|
||||
Never invents a name and never switches vendor — returns (model, source)."""
|
||||
if requested in available:
|
||||
return requested, "requested"
|
||||
glm = [model for model in available if model.lower().startswith("glm")]
|
||||
for candidate in glm:
|
||||
if "flash" in candidate.lower():
|
||||
return candidate, "fallback_glm_flash"
|
||||
if glm:
|
||||
return sorted(glm)[-1], "fallback_glm_latest"
|
||||
return None, "no_glm_available"
|
||||
|
||||
|
||||
def _preflight(provider: OpenAICompatibleProvider, requested_model: str) -> dict:
|
||||
"""Cheap connectivity check (GET /models — no completion tokens). Validates auth,
|
||||
base URL and model availability; diagnoses 401/403/404/429 autonomously."""
|
||||
import httpx
|
||||
try:
|
||||
available = provider.list_models()
|
||||
except httpx.HTTPStatusError as error:
|
||||
return {"zai_connection": "FAIL", "diagnosis": _diagnose_http(error.response.status_code), "model_available": False}
|
||||
except httpx.HTTPError as error:
|
||||
return {"zai_connection": "FAIL", "diagnosis": f"transport: {type(error).__name__}", "model_available": False}
|
||||
model, source = _select_model(available, requested_model)
|
||||
return {"zai_connection": "PASS", "model_available": model is not None, "model": model, "model_source": source,
|
||||
"available_models": available if model is None else None, "base_url": provider.base_url}
|
||||
|
||||
|
||||
def _probe_batch_size(provider, items):
|
||||
"""Try the largest batch that keeps valid JSON with 1:1 ids on a small probe slice."""
|
||||
for size in BATCH_PROBE_SIZES:
|
||||
probe = items[:size]
|
||||
try:
|
||||
provider.select_batch(probe)
|
||||
return size
|
||||
except (LlmAnswerError, json.JSONDecodeError):
|
||||
continue
|
||||
return 1
|
||||
|
||||
|
||||
def run_phase(provider_factory, phase, samples, corpus, index, lexicon, session, ncm_sha, valid_ncm):
|
||||
items, meta = _build_items(samples, corpus, index, lexicon)
|
||||
cached = _cached_answers(session, items, provider_factory.model if hasattr(provider_factory, "model") else settings.ai_model, ncm_sha, phase)
|
||||
todo = [item for item in items if item.work_item_id not in cached]
|
||||
answers = dict(cached)
|
||||
provider = None
|
||||
batch_size = 0
|
||||
if todo:
|
||||
provider = provider_factory()
|
||||
batch_size = _probe_batch_size(provider, todo)
|
||||
for start in range(0, len(todo), batch_size):
|
||||
for answer in provider.select_batch(todo[start:start + batch_size]):
|
||||
answers[answer["work_item_id"]] = answer
|
||||
rows, errors = [], []
|
||||
for item, sample in zip(items, meta):
|
||||
answer = answers.get(item.work_item_id)
|
||||
invalid = False
|
||||
if answer is None:
|
||||
invalid = True
|
||||
answer = {"selected_ncm": None, "confidence": 0.0, "abstain": True, "reason_short": "missing"}
|
||||
else:
|
||||
try:
|
||||
validate_answer(answer, item)
|
||||
if answer["selected_ncm"] is not None and answer["selected_ncm"] not in valid_ncm:
|
||||
invalid = True
|
||||
except LlmAnswerError:
|
||||
invalid = True
|
||||
selected = None if (invalid or answer["abstain"] or answer["selected_ncm"] is None) else answer["selected_ncm"]
|
||||
rows.append({"gold_in_candidates": sample["gold_in_candidates"], "selected": selected, "gold": sample["true_ncm"], "confidence": answer["confidence"], "invalid": invalid})
|
||||
if selected is not None and selected != sample["true_ncm"]:
|
||||
errors.append({"work_item_id": item.work_item_id, "gold": sample["true_ncm"], "selected": selected, "confidence": answer["confidence"], "gold_in_candidates": sample["gold_in_candidates"], "reason_short": answer.get("reason_short", "")})
|
||||
if invalid or answer.get("abstain") or selected is not None:
|
||||
if item.work_item_id not in cached:
|
||||
session.add(AiEvalCache(input_fingerprint=input_fingerprint(item), candidate_set_hash=candidate_set_hash(item), prompt_version=PROMPT_VERSION_LLM,
|
||||
model=provider.model if provider else settings.ai_model, dataset_version_ncm=ncm_sha, phase=phase,
|
||||
answer=answer, gold_ncm=sample["true_ncm"]))
|
||||
session.commit()
|
||||
return rows, errors, provider, batch_size, len(cached)
|
||||
|
||||
|
||||
def main(output_dir="/tmp", per_segment=40) -> dict:
|
||||
if not settings.ai_api_key:
|
||||
print(json.dumps({"status": "BLOCKED_EXTERNAL_AI_CREDENTIAL",
|
||||
"operator_action": "set AI_API_KEY (and optionally AI_BASE_URL/AI_MODEL) in the Hub environment (.env); never commit or log it"}, indent=2))
|
||||
sys.exit(2)
|
||||
session = SessionLocal()
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
valid_ncm = set(session.scalars(select(NcmEntry.code).where(NcmEntry.dataset_id == dataset.id)).all())
|
||||
corpus = EnrichedNcmCorpus.build(session)
|
||||
index = KbNeighborIndex.build(session)
|
||||
lexicon = derive_abbreviation_lexicon(index.corpus(), min_support=3)
|
||||
samples = _load_samples(session, per_segment)
|
||||
# ---- preflight: cheap /models call, never spends completion tokens ----
|
||||
pre_provider = OpenAICompatibleProvider(api_key=settings.ai_api_key, model=settings.ai_model, base_url=settings.ai_base_url)
|
||||
preflight = _preflight(pre_provider, settings.ai_model)
|
||||
if preflight["zai_connection"] != "PASS" or not preflight["model_available"]:
|
||||
print(json.dumps({"status": "ZAI_PREFLIGHT_FAIL", "preflight": preflight}, indent=2))
|
||||
sys.exit(3)
|
||||
model_name = preflight["model"]
|
||||
factory = lambda: OpenAICompatibleProvider(api_key=settings.ai_api_key, model=model_name, base_url=settings.ai_base_url)
|
||||
gold = [sample for sample in samples if sample["quality"] == "GOLD"]
|
||||
provider = factory()
|
||||
rows, errors, provider_used, batch_size, cache_hits = run_phase(lambda: provider, "gold", gold, corpus, index, lexicon, session, dataset.sha256, valid_ncm)
|
||||
m = metrics(rows)
|
||||
prices_configured = settings.ai_price_in_per_m is not None and settings.ai_price_out_per_m is not None
|
||||
usage = provider.usage if provider is not None else {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
|
||||
cost_total = round(usage["input_tokens"] / 1e6 * settings.ai_price_in_per_m + usage["output_tokens"] / 1e6 * settings.ai_price_out_per_m, 6) if prices_configured else "UNKNOWN"
|
||||
report = {
|
||||
"artifact": "REAL_LLM_NCM_EVALUATION_V1", "phase": "gold",
|
||||
"provider": "z.ai", "base_url": settings.ai_base_url, "model": model_name, "prompt_version": PROMPT_VERSION_LLM,
|
||||
"preflight": preflight,
|
||||
"metrics": m, "attribution": attribution(rows), "confidence_buckets": confidence_buckets(rows),
|
||||
"batch_size_selected": batch_size,
|
||||
"gate": {"selection_accuracy_min": GATE_SELECTION_ACCURACY, "high_conf_wrong_max": GATE_HIGH_CONF_WRONG,
|
||||
"passed": (m["SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE"] or 0) >= GATE_SELECTION_ACCURACY and (m["HIGH_CONFIDENCE_WRONG_RATE"] or 0) <= GATE_HIGH_CONF_WRONG},
|
||||
"cache_hits_first_run": cache_hits, "ai_write_allowed": False,
|
||||
"usage": {**usage, "http_calls": provider.http_calls, "retries": provider.retries,
|
||||
"average_items_per_call": round(len(rows) / max(provider.http_calls, 1), 2),
|
||||
"tokens_per_item": round((usage["input_tokens"] + usage["output_tokens"]) / max(len(rows), 1), 1),
|
||||
"cost_total": cost_total, "cost_per_item": (round(cost_total / max(len(rows), 1), 6) if prices_configured else "UNKNOWN"),
|
||||
"prices_configured": prices_configured},
|
||||
}
|
||||
Path(f"{output_dir}/REAL_LLM_NCM_EVALUATION_V1.json").write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
Path(f"{output_dir}/REAL_LLM_NCM_ERRORS_V1.json").write_text(json.dumps(errors, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
# cache proof: identical re-execution must make zero new calls
|
||||
provider2 = factory()
|
||||
rows2, _, _, _, cache_hits2 = run_phase(lambda: provider2, "gold", gold, corpus, index, lexicon, session, dataset.sha256, valid_ncm)
|
||||
second_calls = provider2.http_calls
|
||||
report["cache_proof"] = {"second_run_calls": second_calls, "second_run_cache_hits": cache_hits2,
|
||||
"cache_hit_reexecution": second_calls == 0, "identical_metrics": metrics(rows2)["CORRECT"] == m["CORRECT"]}
|
||||
Path(f"{output_dir}/REAL_LLM_NCM_EVALUATION_V1.json").write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
Path(f"{output_dir}/REAL_LLM_COST_V1.json").write_text(json.dumps({"artifact": "REAL_LLM_COST_V1", "usage": report["usage"], "previous_estimate_usd": 0.44, "cache_proof": report["cache_proof"]}, indent=2), encoding="utf-8")
|
||||
session.close()
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-dir", default="/tmp")
|
||||
parser.add_argument("--per-segment", type=int, default=40)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.output_dir, args.per_segment), indent=2))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Compare V3 retrieval policy with the recorded V2 holdout without retuning on GOLD rows."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(v2_path="docs/NCM_RETRIEVAL_EVALUATION_V2.json", output="docs/NCM_RETRIEVAL_EVALUATION_V3.json"):
|
||||
baseline = json.loads(Path(v2_path).read_text(encoding="utf-8"))
|
||||
gold = baseline.get("by_quality", {}).get("GOLD", {}).get("HYBRID", {})
|
||||
# V3 is deliberately not activated from a synthetic or incomplete replay.
|
||||
# Its optional evidence layer is covered by unit tests and retains V2 ordering
|
||||
# when no new evidence is supplied.
|
||||
report = {
|
||||
"artifact": "NCM_RETRIEVAL_EVALUATION_V3",
|
||||
"baseline_artifact": "NCM_RETRIEVAL_EVALUATION_V2",
|
||||
"activation": "NOT_ACTIVATED_PENDING_FULL_HOLDOUT_REPLAY",
|
||||
"gold_recall_at_1_before": gold.get("recall_at_1"),
|
||||
"gold_recall_at_20_before": gold.get("recall_at_20"),
|
||||
"gold_mrr_before": gold.get("mrr"),
|
||||
"gold_recall_at_1_after": gold.get("recall_at_1"),
|
||||
"gold_recall_at_20_after": gold.get("recall_at_20"),
|
||||
"gold_mrr_after": gold.get("mrr"),
|
||||
"local_embeddings_used": False,
|
||||
"local_embeddings_improvement": None,
|
||||
"evidence_features": ["BM25-compatible lexical baseline", "word/character evidence hooks", "token rarity", "hierarchy", "KB neighbors", "GTIN", "product group"],
|
||||
"policy": "retain V2 until a complete untouched holdout replay proves recall_at_20 >= 0.954 and no regression",
|
||||
}
|
||||
Path(output).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--v2", default="docs/NCM_RETRIEVAL_EVALUATION_V2.json")
|
||||
parser.add_argument("--output", default="docs/NCM_RETRIEVAL_EVALUATION_V3.json")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(main(args.v2, args.output), indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Seed the Product Knowledge Base deterministically from the 5 Firebird databases.
|
||||
|
||||
Two phases (Firebird is only reachable from Windows; Postgres only inside the compose network):
|
||||
|
||||
export (Windows, READ-ONLY SELECT against Firebird):
|
||||
python scripts/seed_product_kb.py export --output docs/runs/ai_kb_seed_payload.json
|
||||
load (inside the fiscal-hub container, against Postgres):
|
||||
python -m central.scripts.seed_product_kb load --input docs/runs/ai_kb_seed_payload.json
|
||||
|
||||
Export computes, per physical product row: the stable global fingerprint
|
||||
(description|unit; GTIN validated by checksum), and emits observations. Load upserts the
|
||||
GLOBAL product, records per-tenant observations, seeds knowledge already proven by the
|
||||
gate (validated_ncm when the declared NCM is active-official; ERP-declared values stay
|
||||
ERP_OBSERVATION evidence), and links TENANT_PRODUCT_CONTEXT rows. Tenant context is
|
||||
never promoted to global knowledge.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
DATABASES = [
|
||||
("TESTESOFICIAIS", None), # path from FIREBIRD_DATABASE_PATH
|
||||
("CONSTRUCAO", "Banco/CONSTRUCAO.FDB"),
|
||||
("MMMODAS", "Banco/MMMODAS.FDB"),
|
||||
("RINCAO", "Banco/RINCAO.FDB"),
|
||||
("UNIAO", "Banco/UNIAO.FDB"),
|
||||
]
|
||||
|
||||
QUERY = "SELECT p.CODIGO, p.PRODUTO, p.UNIDADE, p.CODBARRA, p.GTIN, p.CLASSIFICACAO_FISCAL, p.CEST FROM C000025 p"
|
||||
|
||||
|
||||
def _canonical_ncm(value) -> str:
|
||||
digits = "".join(char for char in str(value or "") if char.isdigit())
|
||||
return digits if len(digits) == 8 else ""
|
||||
|
||||
|
||||
def export_payload(limit_per_db: int = 0, output: str = "docs/runs/ai_kb_seed_payload.json") -> dict:
|
||||
import fdb
|
||||
from dotenv import dotenv_values
|
||||
from central.app.ai.fingerprint import normalize_description, product_fingerprint, validate_gtin
|
||||
env = dotenv_values(".env")
|
||||
stats = {"databases": [], "rows_read": 0, "firebird_writes": 0}
|
||||
products = {}
|
||||
for label, relative in DATABASES:
|
||||
path = env["FIREBIRD_DATABASE_PATH"] if relative is None else str(Path(relative).resolve())
|
||||
connection = fdb.connect(database=path, user=env["FB_USER"], password=env["FB_PASSWORD"], fb_library_name=env.get("FIREBIRD_CLIENT_LIBRARY"))
|
||||
try:
|
||||
cursor = connection.cursor(); cursor.execute(QUERY)
|
||||
columns = [column[0].strip().upper() for column in cursor.description]
|
||||
rows = cursor.fetchmany(limit_per_db) if limit_per_db else cursor.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
# collapse the physical duplicate CODIGO rows into observations of the same product
|
||||
seen_codes = set()
|
||||
for row in rows:
|
||||
record = dict(zip(columns, row))
|
||||
code = str(record.get("CODIGO") or "").strip()
|
||||
dedupe = f"{label}:{code}"
|
||||
if dedupe in seen_codes: # duplicate physical key (e.g. MMMODAS 017421) -> one observation
|
||||
continue
|
||||
seen_codes.add(dedupe)
|
||||
unit = normalize_description(record.get("UNIDADE")) or None
|
||||
fingerprint = product_fingerprint(record.get("PRODUTO"), None, unit)
|
||||
gtin = validate_gtin(record.get("GTIN")) or validate_gtin(record.get("CODBARRA"))
|
||||
entry = products.setdefault(fingerprint, {
|
||||
"fingerprint": fingerprint,
|
||||
"gtin": gtin,
|
||||
"normalized_description": normalize_description(record.get("PRODUTO")),
|
||||
"unit": unit,
|
||||
"ncm": _canonical_ncm(record.get("CLASSIFICACAO_FISCAL")),
|
||||
"cest": str(record.get("CEST") or "").strip() or None,
|
||||
"observations": [],
|
||||
})
|
||||
if gtin and not entry["gtin"]:
|
||||
entry["gtin"] = gtin
|
||||
entry["observations"].append({"database": label, "company_key": label, "erp_code": code})
|
||||
stats["databases"].append({"database": label, "rows": len(rows)})
|
||||
stats["rows_read"] += len(rows)
|
||||
payload = {"artifact": "AI_KB_SEED_PAYLOAD", "exported_at": datetime.now(timezone.utc).isoformat(), "stats": stats, "products": list(products.values())}
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(output).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
stats["unique_products"] = len(products)
|
||||
stats["payload_path"] = output
|
||||
return stats
|
||||
|
||||
|
||||
def load_payload(input_path: str) -> dict:
|
||||
from sqlalchemy import select
|
||||
from central.app.ai.kb import KnowledgeBase
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Dataset, NcmEntry, Tenant, TenantProductContext
|
||||
payload = json.loads(Path(input_path).read_text(encoding="utf8"))
|
||||
session = SessionLocal()
|
||||
kb = KnowledgeBase(session)
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
active_ncm = set(session.scalars(select(NcmEntry.code).where(NcmEntry.dataset_id == dataset.id)).all()) if dataset else set()
|
||||
tenant_ids = {}
|
||||
stats = {"products": 0, "observations": 0, "validated_ncm": 0, "erp_ncm_observations": 0, "erp_cest_observations": 0, "tenant_contexts": 0, "firebird_writes": 0}
|
||||
for product in payload["products"]:
|
||||
row = kb.upsert_product(fingerprint=product["fingerprint"], gtin=product.get("gtin"), normalized_description=product.get("normalized_description", ""), unit=product.get("unit"))
|
||||
stats["products"] += 1
|
||||
for observation in product["observations"]:
|
||||
label = observation["database"]
|
||||
if label not in tenant_ids:
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == label))
|
||||
if tenant is None:
|
||||
tenant = Tenant(name=label); session.add(tenant); session.flush()
|
||||
tenant_ids[label] = tenant.id
|
||||
kb.record_observation(row.id, tenant_id=tenant_ids[label], company_key=label, erp_code=observation["erp_code"], database_label=label, observed_fields={"ncm": product.get("ncm") or None, "cest": product.get("cest")})
|
||||
stats["observations"] += 1
|
||||
existing = session.scalar(select(TenantProductContext).where(TenantProductContext.tenant_id == tenant_ids[label], TenantProductContext.company_key == label, TenantProductContext.erp_code == observation["erp_code"]))
|
||||
if existing is None:
|
||||
session.add(TenantProductContext(tenant_id=tenant_ids[label], company_key=label, erp_code=observation["erp_code"], kb_product_id=row.id))
|
||||
stats["tenant_contexts"] += 1
|
||||
ncm = product.get("ncm") or ""
|
||||
if ncm and ncm in active_ncm:
|
||||
kb.add_knowledge(row.id, "validated_ncm", ncm, source="OFFICIAL_DATASET", confidence=1.0, dataset_versions={"ncm": dataset.sha256})
|
||||
stats["validated_ncm"] += 1
|
||||
elif ncm:
|
||||
kb.add_knowledge(row.id, "candidate_ncm", ncm, source="ERP_OBSERVATION", confidence=0.3, evidence={"declared_in": [observation["database"] for observation in product["observations"]]})
|
||||
stats["erp_ncm_observations"] += 1
|
||||
if product.get("cest"):
|
||||
kb.add_knowledge(row.id, "candidate_cest", product["cest"], source="ERP_OBSERVATION", confidence=0.3, evidence={"declared_in": [observation["database"] for observation in product["observations"]]})
|
||||
stats["erp_cest_observations"] += 1
|
||||
if stats["products"] % 2000 == 0:
|
||||
session.commit()
|
||||
session.commit()
|
||||
stats.update(kb.stats())
|
||||
session.close()
|
||||
return stats
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="phase", required=True)
|
||||
export_parser = sub.add_parser("export")
|
||||
export_parser.add_argument("--limit", type=int, default=0)
|
||||
export_parser.add_argument("--output", default="docs/runs/ai_kb_seed_payload.json")
|
||||
load_parser = sub.add_parser("load")
|
||||
load_parser.add_argument("--input", default="docs/runs/ai_kb_seed_payload.json")
|
||||
load_parser.add_argument("--output", default="docs/runs/ai_kb_seed.json")
|
||||
args = parser.parse_args(argv)
|
||||
if args.phase == "export":
|
||||
result = export_payload(args.limit, args.output)
|
||||
else:
|
||||
result = load_payload(args.input)
|
||||
result["loaded_at"] = datetime.now(timezone.utc).isoformat()
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Administrative CLI: pull the client registry from GTI Gerenciador (the
|
||||
panel) and mirror it into Tenant rows (source='GTI_GERENCIADOR'). GTI
|
||||
Gerenciador is the single source of truth; the Fiscal Hub never gets MySQL
|
||||
credentials — this only calls the panel's read-only internal endpoint.
|
||||
|
||||
Usage (from inside the Hub container, or anywhere DATABASE_URL +
|
||||
GERENCIADOR_INTERNAL_URL + FISCAL_INTERNAL_API_TOKEN are reachable):
|
||||
|
||||
python central/scripts/sync_clients_from_gerenciador.py
|
||||
|
||||
Prints a reconciliation summary; never prints the bearer token.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app import client_sync # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
clients = client_sync.fetch_from_gerenciador()
|
||||
with Session(engine) as session:
|
||||
result = client_sync.sync_from_gerenciador(session, clients)
|
||||
session.commit()
|
||||
print(f"CLIENT_SYNC=ok fetched={result.fetched} created={result.created} updated={result.updated} unchanged={result.unchanged}")
|
||||
print(f"TOTAL_CLIENTS_GERENCIADOR={result.fetched} ACTIVE_CLIENTS_GERENCIADOR={result.active} INACTIVE_CLIENTS_GERENCIADOR={result.inactive}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except client_sync.ClientSyncUnavailable as error:
|
||||
print(f"CLIENT_SYNC=unavailable reason={error}")
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Post-deploy proof through the PUBLIC panel: the default release is served as the real installer EXE (not a zip)
|
||||
with the published sha256. Creates a throwaway LOCAL admin + an E2E installation, revokes/deactivates both after
|
||||
(never deleted: audit FKs). Run inside the Hub container: python central/scripts/verify_release_download.py <version>"""
|
||||
import hashlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
sys.path.insert(0, "central")
|
||||
from app.db import engine # noqa: E402
|
||||
from app.human_auth import hash_password # noqa: E402
|
||||
from app.models import AdminUser, AgentRelease, Tenant # noqa: E402
|
||||
|
||||
BASE = "https://gtifiscal.gtigrupo.com.br"
|
||||
PASSWORD = "Verify-tmp-2026!x"
|
||||
want = sys.argv[1]
|
||||
user = f"e2e-verify-{int(time.time())}"
|
||||
with Session(engine) as db:
|
||||
rel = db.query(AgentRelease).filter_by(version=want, platform="windows", architecture="x64").one()
|
||||
default = db.query(AgentRelease).filter_by(status="PUBLISHED").order_by(AgentRelease.created_at.desc()).first()
|
||||
assert rel.status == "PUBLISHED" and default.id == rel.id, f"default release is {default.version}, expected {want}"
|
||||
tenant = db.query(Tenant).filter(Tenant.source == "GTI_GERENCIADOR", Tenant.active.is_(True), Tenant.external_client_id.isnot(None)).order_by(Tenant.id).first()
|
||||
assert tenant, "no active Gerenciador-mirrored client to attach the throwaway installation to"
|
||||
db.add(AdminUser(username=user, password_hash=hash_password(PASSWORD), is_active=True, auth_source="LOCAL")); db.flush()
|
||||
client_id, release_id, sha, name = tenant.external_client_id, rel.id, rel.sha256, rel.filename
|
||||
db.commit()
|
||||
try:
|
||||
c = httpx.Client(follow_redirects=False, timeout=120)
|
||||
assert c.post(f"{BASE}/login", data={"username": user, "password": PASSWORD}).status_code == 303
|
||||
csrf = c.cookies.get("gti_csrf")
|
||||
r = c.post(f"{BASE}/agents/new", data={"csrf_token": csrf, "client_id": client_id, "display_name": "E2E-VERIFY Servidor", "release_id": release_id})
|
||||
assert r.status_code == 303, r.status_code
|
||||
uuid = r.headers["location"].rsplit("/", 1)[-1]
|
||||
d = c.get(f"{BASE}/agents/pending/{uuid}/download")
|
||||
disp = d.headers.get("content-disposition", "")
|
||||
assert d.status_code == 200 and name in disp and name.endswith(".exe"), (d.status_code, disp)
|
||||
assert d.content[:2] == b"MZ", "not a Windows executable"
|
||||
got = hashlib.sha256(d.content).hexdigest()
|
||||
assert got == sha, "sha256 mismatch"
|
||||
print(f"DOWNLOAD_IS_EXE=true filename={name} SHA256_MATCH=true size={len(d.content)}")
|
||||
c.post(f"{BASE}/agents/pending/{uuid}/revoke", data={"csrf_token": csrf})
|
||||
finally:
|
||||
with Session(engine) as db:
|
||||
db.query(AdminUser).filter_by(username=user).update({"is_active": False}); db.commit()
|
||||
print("VERIFY_DOWNLOAD=PASS")
|
||||
Reference in New Issue
Block a user