chore: establish project baseline

This commit is contained in:
OpenCode
2026-09-21 15:51:47 -03:00
commit 9dcc263217
459 changed files with 447890 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
"""Authenticated version-one Hub routes."""
import hashlib, json, logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from .auth import authenticate, bootstrap
from .classification import ENGINE_VERSION, Classifier
from .config import settings
from .datasets.service import DatasetService
from .jobs import claim_next_job, complete_job, create_job, fail_job
from .context_keys import validate_entry
from .models import FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Job
logger = logging.getLogger("gti_fiscal_hub")
def router(session_factory, bootstrap_token):
routes = APIRouter(prefix="/v1")
def principal(request: Request):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "): raise HTTPException(401, "Bearer token required")
session = session_factory()
try:
if bootstrap_token: bootstrap(session, bootstrap_token)
agent = authenticate(session, header.removeprefix("Bearer "))
if not agent: raise HTTPException(401, "Invalid token")
return session, agent
except Exception:
session.close(); raise
def decide(payload, identity):
session, agent = identity; service = DatasetService(session)
supplied = payload.get("tenant_id")
if supplied is not None and str(supplied) != str(agent.tenant_id): raise HTTPException(403, "Tenant does not match token")
product = payload.get("product", payload)
# Cheap key first (engine version + dataset versions + input), then the cache; the heavy classifier runs only on a MISS.
engine_version, dataset_versions = ENGINE_VERSION, service.dataset_versions()
canonical = {"tenant": agent.tenant_id, "product": product, "context": payload.get("context", {}), "engine": engine_version, "datasets": dataset_versions}
fingerprint = hashlib.sha256(json.dumps(canonical, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest()
cached = session.get(ClassificationCache, fingerprint)
if cached:
result = dict(cached.result); result["provenance"] = {**result["provenance"], "cache_hit": True}; return result
decision = Classifier(service).classify(product)
if decision["engine_version"] != engine_version or decision["dataset_versions"] != dataset_versions: # never store under a key that does not describe the decision
canonical = {**canonical, "engine": decision["engine_version"], "datasets": decision["dataset_versions"]}
fingerprint = hashlib.sha256(json.dumps(canonical, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest()
result = {**decision, "request_fingerprint": fingerprint, "provenance": {"cache_hit": False, "dataset_versions": decision["dataset_versions"]}}
request = ClassificationRequest(tenant_id=agent.tenant_id, agent_id=agent.id, fingerprint=fingerprint, payload=payload); session.add(request); session.flush()
session.add(ClassificationResult(request_id=request.id, result=result)); session.add(ClassificationCache(fingerprint=fingerprint, tenant_id=agent.tenant_id, result=result)); session.commit(); return result
@routes.post("/validate/ncm")
def validate_ncm(payload: dict, identity=Depends(principal)):
return decide({"product": {"current_ncm": payload.get("ncm"), "description": ""}, "tenant_id": payload.get("tenant_id")}, identity)
@routes.post("/classify")
def classify(payload: dict, identity=Depends(principal)):
try: return decide(payload, identity)
finally: identity[0].close()
@routes.post("/classify/batch")
def classify_batch(payload: dict, identity=Depends(principal)):
try: return {"results": [decide({**payload, "product": product}, identity) for product in payload.get("products", [])]}
finally: identity[0].close()
@routes.get("/datasets/status")
def status(identity=Depends(principal)):
session, _ = identity
try: return [{"id": item.id, "kind": item.kind, "status": item.status, "sha256": item.sha256, "record_count": item.record_count} for item in session.scalars(select(Dataset)).all()]
finally: session.close()
@routes.get("/reference/cfop/{code}")
def reference_cfop(code: str, identity=Depends(principal)):
session, _ = identity
try:
rows = DatasetService(session).cfop_lookup(code=code)
return {"found": bool(rows), "entries": [{"code": r.code, "title": r.title, "description": r.description} for r in rows]}
finally: session.close()
@routes.get("/reference/ncm_utrib/{ncm}")
def reference_ncm_utrib(ncm: str, identity=Depends(principal)):
session, _ = identity
try:
row = DatasetService(session).ncm_utrib_for_ncm(ncm)
return {"found": row is not None, "utrib_abbreviation": row.utrib_abbreviation if row else None}
finally: session.close()
@routes.get("/reference/anp/{cprod_anp}")
def reference_anp(cprod_anp: str, identity=Depends(principal)):
session, _ = identity
try: return {"found": DatasetService(session).anp_code_exists(cprod_anp)}
finally: session.close()
@routes.get("/reference/fcp/{uf}")
def reference_fcp(uf: str, identity=Depends(principal)):
session, _ = identity
try:
row = DatasetService(session).fcp_for_uf(uf)
return {"found": row is not None, "aliq_1": row.aliq_1 if row else None, "observacao": row.observacao if row else None}
finally: session.close()
@routes.get("/reference/rtc_cclasstrib/{code}")
def reference_rtc_cclasstrib(code: str, identity=Depends(principal)):
session, _ = identity
try:
rows = DatasetService(session).rtc_cclasstrib(code=code)
return {"found": bool(rows), "entries": [{"cst": r.cst, "cclasstrib": r.cclasstrib, "name": r.name, "description": r.description, "tipo_aliquota": r.tipo_aliquota, "p_red_ibs": r.p_red_ibs, "p_red_cbs": r.p_red_cbs, "d_ini_vig": r.d_ini_vig, "d_fim_vig": r.d_fim_vig} for r in rows]}
finally: session.close()
@routes.get("/reference/rtc_cbs_rate/{year}")
def reference_rtc_cbs_rate(year: int, identity=Depends(principal)):
session, _ = identity
try:
row = DatasetService(session).rtc_cbs_rate(year)
return {"found": row is not None, "year": year, "rate": row.rate if row else None, "rate_text": row.rate_text if row else None}
finally: session.close()
@routes.get("/reference/fiscal_codes/{domain}")
def reference_fiscal_codes(domain: str, identity=Depends(principal)):
session, _ = identity
try:
rows = DatasetService(session).fiscal_codes(domain.upper())
return {"found": bool(rows), "domain": domain.upper(), "codes": sorted(r.code for r in rows)}
finally: session.close()
@routes.post("/context/import")
def context_import(payload: dict, identity=Depends(principal)):
"""Batch upsert of operator fiscal context: {"company_key": "...", "entries": [{scope, scope_key, key, value, reason}]}. All-or-nothing."""
session, agent = identity
try:
company_key = str(payload.get("company_key") or "").strip()
if not company_key:
raise HTTPException(400, "company_key is required")
entries, errors = [], []
for i, raw in enumerate(payload.get("entries") or []):
entry, error = validate_entry(raw)
(errors.append({"index": i, "error": error, "entry": raw}) if error else entries.append(entry))
if errors:
raise HTTPException(422, {"errors": errors[:50], "error_count": len(errors)})
created = updated = 0
for e in entries:
row = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == agent.tenant_id, FiscalContextEntry.company_key == company_key,
FiscalContextEntry.scope == e["scope"], FiscalContextEntry.scope_key == e["scope_key"], FiscalContextEntry.key == e["key"]))
if row:
row.value, row.reason, row.updated_at = e["value"], e["reason"], datetime.now(timezone.utc); updated += 1
else:
session.add(FiscalContextEntry(tenant_id=agent.tenant_id, company_key=company_key, source="OPERATOR", **e)); created += 1
session.commit()
return {"created": created, "updated": updated}
finally: session.close()
@routes.get("/context")
def context_list(company_key: str, identity=Depends(principal)):
session, agent = identity
try:
rows = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == agent.tenant_id, FiscalContextEntry.company_key == company_key)).all()
return {"company_key": company_key, "entries": [{"scope": r.scope, "scope_key": r.scope_key, "key": r.key, "value": r.value, "source": r.source, "reason": r.reason,
"created_at": r.created_at.isoformat() if r.created_at else None, "updated_at": r.updated_at.isoformat() if r.updated_at else None} for r in rows]}
finally: session.close()
@routes.post("/agents/heartbeat")
def heartbeat(payload: dict, identity=Depends(principal)):
session, agent = identity
try:
agent.last_seen_at = datetime.now(timezone.utc)
agent.last_status = payload.get("status")
if payload.get("schema_fingerprint"):
agent.schema_fingerprint = payload["schema_fingerprint"]
if payload.get("agent_version"):
agent.agent_version = payload["agent_version"]
session.add(agent); session.commit()
logger.info("heartbeat agent_id=%s status=%s last_job_id=%s", agent.id, agent.last_status, payload.get("last_job_id"))
return {"agent_id": agent.id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled}
finally: session.close()
@routes.get("/agents/me")
def agent_status(identity=Depends(principal)):
session, agent = identity
try:
last_seen = agent.last_seen_at
if last_seen is not None and last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc) # SQLite (tests) drops tz info
online = last_seen is not None and (datetime.now(timezone.utc) - last_seen).total_seconds() <= settings.agent_online_threshold_seconds
return {
"agent_id": agent.id, "company_id": agent.tenant_id, "name": agent.name, "enabled": agent.enabled,
"last_seen_at": agent.last_seen_at.isoformat() if agent.last_seen_at else None,
"last_status": agent.last_status, "schema_fingerprint": agent.schema_fingerprint,
"agent_version": agent.agent_version, "status": "ONLINE" if online else "OFFLINE",
}
finally: session.close()
def _job_view(job):
return {
"job_id": job.id, "agent_id": job.agent_id, "kind": job.kind, "status": job.status,
"payload": job.payload, "created_at": job.created_at.isoformat() if job.created_at else None,
"started_at": job.started_at.isoformat() if job.started_at else None,
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
"attempts": job.attempts, "result_summary": job.result_summary, "error": job.error,
}
@routes.post("/jobs")
def create_job_route(payload: dict, identity=Depends(principal)):
session, agent = identity
try:
kind = payload.get("kind")
if kind not in ("REPROCESS_ALL", "REPROCESS_PRODUCTS"):
raise HTTPException(400, "kind must be REPROCESS_ALL or REPROCESS_PRODUCTS")
job_payload = payload.get("payload", {})
if kind == "REPROCESS_PRODUCTS" and not job_payload.get("codigos"):
raise HTTPException(400, "REPROCESS_PRODUCTS requires payload.codigos")
job = create_job(session, agent, kind, job_payload)
logger.info("job created job_id=%s agent_id=%s kind=%s", job.id, agent.id, kind)
return _job_view(job)
finally: session.close()
@routes.get("/jobs/next")
def next_job(identity=Depends(principal)):
session, agent = identity
try:
job = claim_next_job(session, agent)
if job is None: return None
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
return _job_view(job)
finally: session.close()
@routes.get("/jobs/{job_id}")
def get_job(job_id: int, identity=Depends(principal)):
session, agent = identity
try:
job = session.get(Job, job_id)
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
return _job_view(job)
finally: session.close()
@routes.post("/jobs/{job_id}/complete")
def complete_job_route(job_id: int, payload: dict, identity=Depends(principal)):
session, agent = identity
try:
job = session.get(Job, job_id)
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
if not complete_job(session, job, payload.get("result_summary", {})): raise HTTPException(409, "job is not RUNNING")
logger.info("job completed job_id=%s agent_id=%s result=%s", job_id, agent.id, payload.get("result_summary", {}))
return _job_view(session.get(Job, job_id))
finally: session.close()
@routes.post("/jobs/{job_id}/fail")
def fail_job_route(job_id: int, payload: dict, identity=Depends(principal)):
session, agent = identity
try:
job = session.get(Job, job_id)
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
if not fail_job(session, job, payload.get("error", "unknown error")): raise HTTPException(409, "job is not RUNNING")
logger.warning("job failed job_id=%s agent_id=%s error=%s", job_id, agent.id, payload.get("error", "unknown error"))
return _job_view(session.get(Job, job_id))
finally: session.close()
return routes