feat: central RTC write modes (DISABLED/DRY_RUN/APPROVAL/ENABLED), R4 suggestion-only, preview/approval, per-record rollback, bulk blocker upsert; 1.2.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 22:46:05 -03:00
co-authored by Claude Sonnet 5
parent 3c10422e81
commit 42dc172763
50 changed files with 2948 additions and 915 deletions
+36 -20
View File
@@ -105,9 +105,8 @@ class Config:
self.agent_jobs_enabled = True
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_enabled = env.get("RTC_AUTO_APPLY_ENABLED", "0").strip() == "1"
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
self.agent_version = env.get("AGENT_VERSION", "1.1.9")
self.agent_version = env.get("AGENT_VERSION", "1.2.0")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
@@ -208,30 +207,47 @@ def _fiscal_blocked_products(products, findings):
found = findings.get(code) or {}
for blocker in found.get("blockers") or []:
out.append({"company": "", "product_code": code, "gtin": product.get("GTIN") or product.get("EAN_GTIN"), "description": product.get("PRODUTO"), "blocker_type": "BLOCKED_FISCAL_DATA",
"field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability")},
"field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability"), **({"human": blocker["human"]} if blocker.get("human") else {})},
"input_fingerprint": found.get("input_fingerprint", "")})
return out
def _rtc_auto_apply(env, config, job, products, fiscal, company):
"""Report (always) and, only when all three gates are open, fill CCLASSTRIB for products whose class is PROVEN by a deterministic rule.
Returns the summary stored in the job result; never raises for a single product."""
gate = _rtc_apply.write_gate(firebird_writes=bool(getattr(config, "firebird_writes", False)), agent_enabled=bool(getattr(config, "rtc_auto_apply_enabled", False)),
hub_authorized=bool((job.get("authorizations") or {}).get("rtc_auto_apply")))
limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB))
candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=limit)
summary = {"gate": gate, "mode": "AUTO_APPLY" if gate["allowed"] else "REPORT_ONLY", "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0, "applied": [], "aborted": [], "aborted_reasons": {},
"allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS)}
if not (gate["allowed"] and candidates and fiscal["context"].get("rtc_enabled")):
return summary
"""Preview (always) and, only when the Hub-delivered mode permits it, fill CCLASSTRIB for products whose class is PROVEN by an AUTO_APPLY_SAFE rule; also executes
Hub-requested rollbacks. Authorization arrives ONLY inside the authenticated job (`authorizations`); there is no local switch. Never raises for a single product."""
from datetime import datetime, timezone
auth = job.get("authorizations") or {}
mode = auth.get("rtc_write_mode") if auth.get("rtc_write_mode") in _rtc_apply.MODES else "DISABLED"
approved = {str(c) for c in (auth.get("rtc_approved_codes") or [])}
limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB))
rtc_on = bool(fiscal["context"].get("rtc_enabled"))
all_candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=_rtc_apply.MAX_PREVIEW_CANDIDATES)
gate = _rtc_apply.write_gate(mode=mode, capability=True, approved=bool(approved))
summary = {"gate": gate, "mode": mode if mode != "DISABLED" else "REPORT_ONLY", "hub_mode": mode, "capability": _rtc_apply.CAPABILITY, "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0,
"applied": [], "aborted": [], "aborted_reasons": {}, "rollbacks": [], "allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS),
"preview": [{k: c[k] for k in ("product_code", "description", "ncm", "current", "proposed", "cst", "rule_id", "basis")} for c in all_candidates] if mode != "DISABLED" and rtc_on else [],
"preview_truncated": ready_total > len(all_candidates)}
if not rtc_on:
return summary
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
rollbacks = [r for r in (auth.get("rtc_rollbacks") or []) if isinstance(r, dict)]
if mode == "ENABLED":
wanted = all_candidates[:limit]
elif mode == "APPROVAL_REQUIRED":
wanted = [c for c in all_candidates if c["product_code"] in approved][:limit]
else:
wanted = []
if not (rollbacks or (gate["allowed"] and wanted)):
return summary
connection = open_write_connection(env, config.database_path)
try:
outcome = _rtc_apply.apply_candidates(connection, candidates, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash")},
now=datetime.now(timezone.utc).isoformat(timespec="seconds"), job_id=job.get("job_id"))
if rollbacks:
summary["rollbacks"] = _rtc_apply.rollback_records(connection, rollbacks, now=now, job_id=job.get("job_id"))["rollbacks"]
if gate["allowed"] and wanted:
outcome = _rtc_apply.apply_candidates(connection, wanted, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash"), "origin": f"HUB_MODE:{mode}"}, now=now, job_id=job.get("job_id"))
summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
finally:
connection.close()
summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
return summary
@@ -289,7 +305,7 @@ def execute_job(job, config, executor, state, env):
ncm_ok = {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(products, all_decisions)}
fiscal = _unified_fiscal(env, company, ncm_ok)
rtc_apply = _rtc_auto_apply(env, config, job, products, fiscal, company)
if rtc_apply["write_successes"]:
if rtc_apply["write_successes"] or any(r.get("outcome") == "ROLLED_BACK" for r in rtc_apply["rollbacks"]):
fiscal = _unified_fiscal(env, company, ncm_ok) # coverage must reflect the fields that were just filled
findings = fiscal["findings"]
product_states = [classify_product_state(p, d, fiscal=findings.get(str(p["CODIGO"]))) for p, d in zip(products, all_decisions)]
@@ -307,7 +323,7 @@ def execute_job(job, config, executor, state, env):
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.1.9",
"release_version": env.get("AGENT_VERSION") or "1.2.0",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -395,7 +411,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
"agent_version": config.agent_version, "last_job_id": last_job_id, "display_name": env.get("AGENT_DISPLAY_NAME", ""),
"customer_code": env.get("CUSTOMER_CODE"), "hostname": env.get("AGENT_HOSTNAME") or os.environ.get("COMPUTERNAME"),
"windows_version": env.get("WINDOWS_VERSION"), "company_code": config.company_id, "company_uf": config.company_uf,
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "rtc_auto_apply": True, "rtc_auto_apply_enabled": bool(getattr(config, "rtc_auto_apply_enabled", False))},
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "RTC_CCLASSTRIB_WRITE_V1": True},
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
if hasattr(client, "heartbeat_metadata"):
@@ -465,7 +481,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.9")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.2.0")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":