#!/usr/bin/env python3 """ ClientFlow deep system audit (read-only) Purpose: Validate end-to-end operational correctness, not only technical health. This script is intentionally READ-ONLY: it performs no UPDATE/INSERT/DELETE. Usage from project root: cd /mnt/ssd/home/plx/clientflow_backend source .venv/bin/activate export PYTHONPATH=/mnt/ssd/home/plx/clientflow_backend set -a; source .env; set +a python scripts/audit_deep_system.py --window-hours 72 --sample-limit 25 Outputs: audit_reports/deep_audit_YYYYMMDD_HHMMSS.txt audit_reports/deep_audit_YYYYMMDD_HHMMSS.json """ from __future__ import annotations import argparse import json import os import re import sys from collections import Counter, defaultdict from dataclasses import dataclass, field, asdict from datetime import date, datetime, timezone from decimal import Decimal from uuid import UUID from pathlib import Path from typing import Any, Iterable try: from dotenv import load_dotenv except Exception: # pragma: no cover load_dotenv = None PROJECT_ROOT = Path(__file__).resolve().parents[1] if len(Path(__file__).resolve().parents) > 1 else Path.cwd() if load_dotenv: load_dotenv(PROJECT_ROOT / ".env") try: from sqlalchemy import text from sqlalchemy.exc import OperationalError, ProgrammingError from app.db import engine except Exception as exc: # pragma: no cover print(f"FATAL: não foi possível importar app.db/SQLAlchemy: {exc}") print("Confirma: cd projeto, source .venv/bin/activate, export PYTHONPATH=, source .env") sys.exit(2) SEVERITY_ORDER = {"FAIL": 3, "WARN": 2, "OK": 1, "INFO": 0} EXECUTABLE_EXTENSIONS = [".exe", ".msi", ".bat", ".cmd", ".ps1", ".scr", ".vbs", ".js", ".jar"] REMOTE_ACCESS_KEYWORDS = ["screenconnect", "connectwise", "anydesk", "teamviewer", "rustdesk", "dwservice", "remoteutilities"] DANGEROUS_FINANCIAL_ACTIONS = {"CONFIRM_PAYMENT", "SEND_INVOICE", "SEND_PROFORMA", "SEND_QUOTE"} FINANCIAL_ACTIONS = {"CONFIRM_PAYMENT", "SEND_INVOICE", "SEND_PROFORMA"} MARKETING_ACTIONS = {"REMOVE_FROM_LIST"} REVIEW_ACTIONS = {"REVIEW_MANUALLY"} @dataclass class Finding: severity: str area: str title: str detail: str = "" count: int | None = None samples: list[dict[str, Any]] = field(default_factory=list) class Audit: def __init__(self, *, sample_limit: int): self.sample_limit = sample_limit self.findings: list[Finding] = [] self.schema: dict[str, set[str]] = {} self.table_cache: set[str] = set() self.started_at = datetime.now(timezone.utc).isoformat() def add(self, severity: str, area: str, title: str, detail: str = "", count: int | None = None, samples: list[dict[str, Any]] | None = None): if severity not in SEVERITY_ORDER: raise ValueError(f"invalid severity {severity}") self.findings.append(Finding(severity, area, title, detail, count, samples or [])) def summary(self) -> dict[str, int]: c = Counter(f.severity for f in self.findings) return {k: c.get(k, 0) for k in ["OK", "WARN", "FAIL", "INFO"]} def exit_code(self) -> int: s = self.summary() if s["FAIL"]: return 2 if s["WARN"]: return 1 return 0 def json_safe(value: Any) -> Any: if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, Decimal): # Keep integer-like decimals as int; otherwise float for compact reports. return int(value) if value == value.to_integral_value() else float(value) if isinstance(value, UUID): return str(value) if isinstance(value, (list, tuple, set)): return [json_safe(v) for v in value] if isinstance(value, dict): return {str(k): json_safe(v) for k, v in value.items()} return value def rows_to_dicts(rows: Iterable[Any], limit: int | None = None) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for i, r in enumerate(rows): if limit is not None and i >= limit: break out.append({k: json_safe(v) for k, v in dict(r).items()}) return out def q(conn, sql: str, params: dict[str, Any] | None = None): return conn.execute(text(sql), params or {}).mappings().all() def s(conn, sql: str, params: dict[str, Any] | None = None): return conn.execute(text(sql), params or {}).scalar() def table_exists(conn, table: str) -> bool: return bool(s(conn, """ SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = :table ) """, {"table": table})) def columns(conn, table: str) -> set[str]: return {r["column_name"] for r in q(conn, """ SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = :table """, {"table": table})} def require(audit: Audit, conn, tables: list[str]): missing = [t for t in tables if not table_exists(conn, t)] if missing: audit.add("FAIL", "schema", "Tabelas essenciais em falta", ", ".join(missing), len(missing)) return False return True def safe_query(audit: Audit, conn, area: str, title: str, sql: str, params: dict[str, Any] | None = None): try: return q(conn, sql, params) except OperationalError as exc: msg = str(exc) if "DeadlockDetected" in msg or "deadlock detected" in msg or "LockNotAvailable" in msg: audit.add("WARN", area, f"{title}: lock/deadlock temporário", msg[:800]) return [] audit.add("FAIL", area, f"{title}: erro DB", msg[:1200]) return [] except ProgrammingError as exc: audit.add("FAIL", area, f"{title}: erro SQL/schema", str(exc)[:1200]) return [] def inspect_schema(audit: Audit, conn): tables = [r["table_name"] for r in q(conn, """ SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name """)] audit.table_cache = set(tables) for t in tables: audit.schema[t] = columns(conn, t) audit.add("INFO", "schema", "Tabelas encontradas", count=len(tables), samples=[{"tables": tables[:80]}]) def check_base_health(audit: Audit, conn, window_hours: int): ok_tables = require(audit, conn, ["tasks", "raw_events", "opportunities"]) if ok_tables: audit.add("OK", "base", "Tabelas essenciais existem") for table in ["tasks", "raw_events", "opportunities", "customers", "integration_outbox"]: if table in audit.table_cache: try: count = s(conn, f"SELECT COUNT(*) FROM {table}") audit.add("INFO", "base", f"Contagem {table}", count=int(count)) except Exception as exc: audit.add("WARN", "base", f"Não foi possível contar {table}", str(exc)[:500]) def check_raw_events(audit: Audit, conn, window_hours: int): if "raw_events" not in audit.table_cache: return cols = audit.schema["raw_events"] needed = {"processed", "ignored", "created_at", "payload"} if not needed.issubset(cols): audit.add("WARN", "raw_events", "raw_events sem colunas esperadas", detail=str(sorted(needed - cols))) return pending = safe_query(audit, conn, "raw_events", "incoming pendentes", """ SELECT id::text AS raw_event_id, created_at, source_system, source_event_id, payload #>> '{message_type}' AS message_type, payload #>> '{conversation,id}' AS conversation_id, payload #>> '{sender,email}' AS sender_email, LEFT(COALESCE(payload #>> '{content}', payload::text), 500) AS content_excerpt FROM raw_events WHERE COALESCE(processed, false) = false AND COALESCE(ignored, false) = false AND created_at >= now() - (:window_hours || ' hours')::interval ORDER BY created_at DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) if pending: audit.add("FAIL", "raw_events", "raw_events pendentes por processar", count=len(pending), samples=rows_to_dicts(pending)) else: audit.add("OK", "raw_events", "Sem raw_events pendentes recentes") errors = safe_query(audit, conn, "raw_events", "erros recentes", """ SELECT id::text AS raw_event_id, created_at, source_system, source_event_id, payload #>> '{message_type}' AS message_type, LEFT(COALESCE(processing_error, ''), 700) AS processing_error FROM raw_events WHERE processing_error IS NOT NULL AND created_at >= now() - (:window_hours || ' hours')::interval ORDER BY created_at DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) real_errors = [r for r in errors if "processing_exception" in (r["processing_error"] or "")] if real_errors: audit.add("FAIL", "raw_events", "processing_exception em raw_events recentes", count=len(real_errors), samples=rows_to_dicts(real_errors)) elif errors: audit.add("INFO", "raw_events", "Apenas erros/ignored benignos recentes", count=len(errors), samples=rows_to_dicts(errors)) else: audit.add("OK", "raw_events", "Sem erros recentes em raw_events") dupes = safe_query(audit, conn, "raw_events", "source_event_id duplicado", """ SELECT source_system, source_event_id, COUNT(*) AS total, MIN(created_at) AS first_seen, MAX(created_at) AS last_seen FROM raw_events WHERE source_event_id IS NOT NULL AND created_at >= now() - (:window_hours || ' hours')::interval GROUP BY source_system, source_event_id HAVING COUNT(*) > 1 ORDER BY total DESC, last_seen DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) if dupes: audit.add("WARN", "raw_events", "source_event_id duplicados recentes", count=len(dupes), samples=rows_to_dicts(dupes)) else: audit.add("OK", "raw_events", "Sem duplicados recentes por source_event_id") def check_security_content(audit: Audit, conn, window_hours: int): if "raw_events" not in audit.table_cache: return risky_sql = """ SELECT r.id::text AS raw_event_id, r.created_at, r.source_event_id, r.payload #>> '{message_type}' AS message_type, r.payload #>> '{conversation,id}' AS conversation_id, r.payload #>> '{sender,name}' AS sender_name, r.payload #>> '{sender,email}' AS sender_email, LEFT(COALESCE(r.payload #>> '{content}', r.payload::text), 1200) AS content_excerpt, t.id::text AS task_id, t.route, t.action_code, t.status, t.priority, t.metadata AS task_metadata FROM raw_events r LEFT JOIN tasks t ON t.raw_event_id = r.id WHERE r.created_at >= now() - (:window_hours || ' hours')::interval AND ( r.payload::text ILIKE '%%.exe%%' OR r.payload::text ILIKE '%%.msi%%' OR r.payload::text ILIKE '%%.bat%%' OR r.payload::text ILIKE '%%.cmd%%' OR r.payload::text ILIKE '%%.ps1%%' OR r.payload::text ILIKE '%%.scr%%' OR r.payload::text ILIKE '%%screenconnect%%' OR r.payload::text ILIKE '%%connectwise%%' OR r.payload::text ILIKE '%%anydesk%%' OR r.payload::text ILIKE '%%teamviewer%%' ) ORDER BY r.created_at DESC LIMIT :limit """ risky = safe_query(audit, conn, "security", "conteúdo com indicadores de risco", risky_sql, {"window_hours": window_hours * 14, "limit": audit.sample_limit}) bad_financial = [r for r in risky if r.get("status") == "pending" and r.get("action_code") in DANGEROUS_FINANCIAL_ACTIONS and not (r.get("task_metadata") or {}).get("security_risk")] if bad_financial: audit.add("FAIL", "security", "Indicador de malware em task perigosa não bloqueada", count=len(bad_financial), samples=rows_to_dicts(bad_financial)) elif risky: audit.add("WARN", "security", "Conteúdo com indicadores de risco encontrado", "Validar que está em REVIEW_MANUALLY/security_risk", len(risky), rows_to_dicts(risky)) else: audit.add("OK", "security", "Sem indicadores de links executáveis/acesso remoto em eventos recentes") if "tasks" in audit.table_cache and "metadata" in audit.schema["tasks"]: security_tasks = safe_query(audit, conn, "security", "tasks security_risk pendentes", """ SELECT id::text AS task_id, created_at, route, action_code, priority, status, conversation_id, note, metadata FROM tasks WHERE status = 'pending' AND COALESCE((metadata ->> 'security_risk')::boolean, false) = true ORDER BY created_at DESC LIMIT :limit """, {"limit": audit.sample_limit}) if security_tasks: audit.add("WARN", "security", "Tasks de segurança pendentes", count=len(security_tasks), samples=rows_to_dicts(security_tasks)) else: audit.add("OK", "security", "Sem tasks security_risk pendentes") def check_tasks(audit: Audit, conn, window_hours: int): if "tasks" not in audit.table_cache: return cols = audit.schema["tasks"] pending_summary = safe_query(audit, conn, "tasks", "backlog por route/action", """ SELECT route, action_code, priority, status, COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM tasks WHERE status = 'pending' GROUP BY route, action_code, priority, status ORDER BY total DESC, oldest ASC """) audit.add("INFO", "tasks", "Backlog pendente por rota/action", count=sum(int(r["total"]) for r in pending_summary), samples=rows_to_dicts(pending_summary, audit.sample_limit)) old = safe_query(audit, conn, "tasks", "tasks antigas", """ SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.action, t.note, t.priority, t.conversation_id, t.opportunity_id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name AS opportunity_customer_name, o.customer_email AS opportunity_customer_email FROM tasks t LEFT JOIN opportunities o ON o.id = t.opportunity_id WHERE t.status = 'pending' AND t.created_at < now() - interval '14 days' ORDER BY t.created_at ASC LIMIT :limit """, {"limit": audit.sample_limit}) if old: audit.add("WARN", "tasks", "Tasks pendentes antigas > 14 dias", count=len(old), samples=rows_to_dicts(old)) else: audit.add("OK", "tasks", "Sem tasks pendentes com mais de 14 dias") review_recent = safe_query(audit, conn, "tasks", "REVIEW_MANUALLY recentes", """ SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.priority, t.conversation_id, t.note, t.metadata, o.title AS opportunity_title, o.customer_name AS opportunity_customer_name FROM tasks t LEFT JOIN opportunities o ON o.id = t.opportunity_id WHERE t.status = 'pending' AND t.action_code = 'REVIEW_MANUALLY' AND t.created_at >= now() - (:window_hours || ' hours')::interval ORDER BY t.created_at DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) if review_recent: audit.add("WARN", "tasks", "REVIEW_MANUALLY pendentes recentes", count=len(review_recent), samples=rows_to_dicts(review_recent)) else: audit.add("OK", "tasks", "Sem REVIEW_MANUALLY recente pendente") dupes = safe_query(audit, conn, "tasks", "tasks duplicadas por conversa/action", """ SELECT conversation_id, action_code, status, COUNT(*) AS total, MIN(created_at) AS first_created, MAX(created_at) AS last_created, array_agg(id::text ORDER BY created_at DESC) AS task_ids FROM tasks WHERE status = 'pending' AND conversation_id IS NOT NULL GROUP BY conversation_id, action_code, status HAVING COUNT(*) > 1 ORDER BY total DESC, last_created DESC LIMIT :limit """, {"limit": audit.sample_limit}) if dupes: audit.add("WARN", "tasks", "Possíveis tasks duplicadas pendentes por conversa/action", count=len(dupes), samples=rows_to_dicts(dupes)) else: audit.add("OK", "tasks", "Sem duplicados pendentes simples por conversa/action") def check_identity(audit: Audit, conn, window_hours: int): if not require(audit, conn, ["tasks", "opportunities"]): return opp_cols = audit.schema["opportunities"] if "local_customer_id" not in opp_cols: audit.add("WARN", "identity", "opportunities sem local_customer_id") return # Use customers table if available; otherwise only detect metadata/security detached. has_customers = "customers" in audit.table_cache and {"id", "name"}.issubset(audit.schema.get("customers", set())) if has_customers: email_col = "email" if "email" in audit.schema["customers"] else "NULL::text" name_expr = "c.name" email_expr = f"c.{email_col}" if email_col != "NULL::text" else "NULL::text" suspected = safe_query(audit, conn, "identity", "cliente fiscal possivelmente inseguro", f""" WITH data AS ( SELECT t.id::text AS task_id, t.action_code, t.route, t.status, t.conversation_id, o.id::text AS opportunity_id, o.title, o.customer_name, o.customer_email, o.local_customer_id::text AS local_customer_id, {name_expr} AS fiscal_name, {email_expr} AS fiscal_email, lower(regexp_replace(coalesce(o.title, '') || ' ' || coalesce(o.customer_name, ''), '[^[:alnum:]À-ÿ]+', ' ', 'g')) AS process_text, lower(coalesce({name_expr}, '')) AS fiscal_text, lower(split_part(coalesce(o.customer_email, ''), '@', 2)) AS opp_domain, lower(split_part(coalesce({email_expr}, ''), '@', 2)) AS fiscal_domain FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id JOIN customers c ON c.id = o.local_customer_id WHERE t.status = 'pending' AND o.local_customer_id IS NOT NULL ) SELECT * FROM data WHERE -- suspicious: fiscal email domain does not match opportunity email domain (fiscal_domain IS NOT NULL AND fiscal_domain <> '' AND opp_domain IS NOT NULL AND opp_domain <> '' AND fiscal_domain <> opp_domain) AND NOT ( -- accept if process text contains a strong fiscal token >= 5 chars EXISTS ( SELECT 1 FROM regexp_split_to_table(fiscal_text, '\\s+') tok WHERE length(tok) >= 5 AND process_text LIKE '%%' || tok || '%%' ) ) ORDER BY title LIMIT :limit """, {"limit": audit.sample_limit}) if suspected: audit.add("FAIL", "identity", "Cliente fiscal inseguro em task pendente", count=len(suspected), samples=rows_to_dicts(suspected)) else: audit.add("OK", "identity", "Sem suspeitas fortes de cliente fiscal inseguro") else: audit.add("INFO", "identity", "Tabela customers indisponível para comparação nominal detalhada") repair_marked = safe_query(audit, conn, "identity", "identity repair/detached ainda pendente", """ SELECT t.id::text AS task_id, t.route, t.action_code, t.status, t.priority, t.conversation_id, o.id::text AS opportunity_id, o.title, o.customer_name, o.customer_email, o.local_customer_id::text AS local_customer_id, o.metadata AS opportunity_metadata, t.metadata AS task_metadata FROM tasks t LEFT JOIN opportunities o ON o.id = t.opportunity_id WHERE t.status = 'pending' AND ( t.metadata::text ILIKE '%%identity_detached%%' OR t.metadata::text ILIKE '%%identity_repair%%' OR o.metadata::text ILIKE '%%identity_detached%%' OR o.metadata::text ILIKE '%%identity_repair%%' OR COALESCE((t.metadata ->> 'requires_fiscal_customer_review')::boolean, false) = true ) ORDER BY t.created_at DESC LIMIT :limit """, {"limit": audit.sample_limit}) if repair_marked: audit.add("WARN", "identity", "Tasks pendentes marcadas para revisão de identidade/fiscal", count=len(repair_marked), samples=rows_to_dicts(repair_marked)) else: audit.add("OK", "identity", "Sem tasks pendentes marcadas por identity repair") def check_financial_safety(audit: Audit, conn, window_hours: int): if not require(audit, conn, ["tasks", "opportunities"]): return no_customer = safe_query(audit, conn, "financial", "financeiras sem cliente fiscal", """ SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.priority, t.conversation_id, t.action, t.note, o.id::text AS opportunity_id, o.title, o.customer_name, o.customer_email, o.local_customer_id::text AS local_customer_id, t.metadata FROM tasks t LEFT JOIN opportunities o ON o.id = t.opportunity_id WHERE t.status = 'pending' AND t.action_code IN ('CONFIRM_PAYMENT', 'SEND_INVOICE', 'SEND_PROFORMA') AND (o.local_customer_id IS NULL OR t.opportunity_id IS NULL) ORDER BY t.created_at DESC LIMIT :limit """, {"limit": audit.sample_limit}) if no_customer: audit.add("FAIL", "financial", "Tasks financeiras pendentes sem cliente fiscal confirmado", count=len(no_customer), samples=rows_to_dicts(no_customer)) else: audit.add("OK", "financial", "Sem tasks financeiras pendentes sem cliente fiscal") security_fin = safe_query(audit, conn, "financial", "financeiras com security_risk", """ SELECT id::text AS task_id, created_at, route, action_code, priority, status, conversation_id, note, metadata FROM tasks WHERE status = 'pending' AND action_code IN ('CONFIRM_PAYMENT', 'SEND_INVOICE', 'SEND_PROFORMA') AND COALESCE((metadata ->> 'security_risk')::boolean, false) = true ORDER BY created_at DESC LIMIT :limit """, {"limit": audit.sample_limit}) if security_fin: audit.add("FAIL", "financial", "Task financeira com security_risk ainda não bloqueada", count=len(security_fin), samples=rows_to_dicts(security_fin)) else: audit.add("OK", "financial", "Nenhuma task financeira pendente marcada como security_risk") def check_llm_quality(audit: Audit, conn, window_hours: int): if "tasks" not in audit.table_cache or "metadata" not in audit.schema["tasks"]: return summary = safe_query(audit, conn, "llm", "qualidade LLM por decision_source/action", """ SELECT COALESCE(metadata ->> 'decision_source', 'unknown') AS decision_source, action_code, route, COUNT(*) AS total, COUNT(*) FILTER (WHERE COALESCE((metadata ->> 'needs_review')::boolean, false) = true) AS needs_review, COUNT(*) FILTER (WHERE COALESCE((metadata ->> 'llm_confidence')::numeric, 0) = 0) AS zero_confidence, ROUND(AVG(NULLIF((metadata ->> 'llm_confidence')::numeric, 0)), 3) AS avg_nonzero_confidence FROM tasks WHERE created_at >= now() - (:window_hours || ' hours')::interval GROUP BY decision_source, action_code, route ORDER BY total DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) audit.add("INFO", "llm", "Resumo de classificação LLM recente", count=sum(int(r["total"]) for r in summary), samples=rows_to_dicts(summary)) invalid_json = safe_query(audit, conn, "llm", "LLM sem JSON/parse inválido", """ SELECT id::text AS task_id, created_at, route, action_code, priority, conversation_id, note, metadata FROM tasks WHERE created_at >= now() - (:window_hours || ' hours')::interval AND ( note ILIKE '%%sem JSON%%' OR note ILIKE '%%fallback-parse%%' OR note ILIKE '%%Resposta LLM inválida%%' ) ORDER BY created_at DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) if invalid_json: audit.add("WARN", "llm", "Falhas recentes de formato/parse LLM", count=len(invalid_json), samples=rows_to_dicts(invalid_json)) else: audit.add("OK", "llm", "Sem falhas recentes de formato/parse LLM") high_conf_review = safe_query(audit, conn, "llm", "Alta confiança mas revisão/segurança", """ SELECT id::text AS task_id, created_at, route, action_code, priority, conversation_id, note, metadata FROM tasks WHERE created_at >= now() - (:window_hours || ' hours')::interval AND COALESCE((metadata ->> 'llm_confidence')::numeric, 0) >= 0.90 AND (action_code = 'REVIEW_MANUALLY' OR COALESCE((metadata ->> 'security_risk')::boolean, false) = true) ORDER BY created_at DESC LIMIT :limit """, {"window_hours": window_hours, "limit": audit.sample_limit}) if high_conf_review: audit.add("WARN", "llm", "Alta confiança mas caiu em revisão/segurança", "Pode indicar regra de segurança ou erro da IA", len(high_conf_review), rows_to_dicts(high_conf_review)) def check_opportunities(audit: Audit, conn, window_hours: int): if "opportunities" not in audit.table_cache: return cols = audit.schema["opportunities"] audit.add("INFO", "opportunities", "Colunas opportunities", samples=[{"columns": sorted(cols)}]) dupes = safe_query(audit, conn, "opportunities", "possíveis duplicados por email", """ SELECT lower(customer_email) AS customer_email, COUNT(*) AS total, array_agg(id::text ORDER BY created_at DESC) AS opportunity_ids, array_agg(title ORDER BY created_at DESC) AS titles, MAX(created_at) AS newest FROM opportunities WHERE customer_email IS NOT NULL AND customer_email <> '' GROUP BY lower(customer_email) HAVING COUNT(*) > 1 ORDER BY total DESC, newest DESC LIMIT :limit """, {"limit": audit.sample_limit}) if dupes: audit.add("WARN", "opportunities", "Possíveis oportunidades duplicadas por email", count=len(dupes), samples=rows_to_dicts(dupes)) else: audit.add("OK", "opportunities", "Sem duplicados simples por customer_email") no_task = safe_query(audit, conn, "opportunities", "oportunidades abertas sem task pendente", """ SELECT o.id::text AS opportunity_id, o.created_at, o.title, o.customer_name, o.customer_email, o.local_customer_id::text AS local_customer_id, o.status, o.stage, o.metadata FROM opportunities o LEFT JOIN tasks t ON t.opportunity_id = o.id AND t.status = 'pending' WHERE t.id IS NULL AND o.created_at >= now() - (:window_hours || ' hours')::interval AND COALESCE(o.status, '') NOT IN ('closed', 'won', 'lost', 'DONE') ORDER BY o.created_at DESC LIMIT :limit """, {"window_hours": window_hours * 7, "limit": audit.sample_limit}) if no_task: audit.add("WARN", "opportunities", "Oportunidades recentes sem task pendente", count=len(no_task), samples=rows_to_dicts(no_task)) else: audit.add("OK", "opportunities", "Sem oportunidades recentes abertas sem task pendente") def check_outbox(audit: Audit, conn, window_hours: int): if "integration_outbox" not in audit.table_cache: audit.add("INFO", "outbox", "Tabela integration_outbox não encontrada") return cols = audit.schema["integration_outbox"] status_col = "status" if "status" in cols else None if not status_col: audit.add("WARN", "outbox", "integration_outbox sem coluna status") return failed = safe_query(audit, conn, "outbox", "failed/error", """ SELECT * FROM integration_outbox WHERE status IN ('failed', 'error') ORDER BY created_at DESC NULLS LAST LIMIT :limit """, {"limit": min(audit.sample_limit, 10)}) if failed: audit.add("FAIL", "outbox", "integration_outbox com failed/error", count=len(failed), samples=rows_to_dicts(failed)) else: audit.add("OK", "outbox", "Sem integration_outbox failed/error") pending_old = safe_query(audit, conn, "outbox", "pending antigo", """ SELECT * FROM integration_outbox WHERE status IN ('pending', 'claimed') AND created_at < now() - interval '24 hours' ORDER BY created_at ASC NULLS LAST LIMIT :limit """, {"limit": min(audit.sample_limit, 10)}) if pending_old: audit.add("WARN", "outbox", "integration_outbox pending/claimed antigo", count=len(pending_old), samples=rows_to_dicts(pending_old)) else: audit.add("OK", "outbox", "Sem outbox pending/claimed antigo") def write_reports(audit: Audit, out_dir: Path): out_dir.mkdir(parents=True, exist_ok=True) stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") txt_path = out_dir / f"deep_audit_{stamp}.txt" json_path = out_dir / f"deep_audit_{stamp}.json" lines: list[str] = [] lines.append("ClientFlow Deep System Audit") lines.append("=" * 90) lines.append(f"started_at={audit.started_at}") lines.append(f"summary={audit.summary()} exit_code={audit.exit_code()}") lines.append("") for f in audit.findings: icon = {"OK": "✅", "WARN": "⚠️", "FAIL": "❌", "INFO": "ℹ️"}[f.severity] count = "" if f.count is None else f" count={f.count}" lines.append(f"{icon} [{f.severity}] {f.area}: {f.title}{count}") if f.detail: lines.append(f" {f.detail}") for sample in f.samples[:5]: lines.append(" sample: " + json.dumps(json_safe(sample), ensure_ascii=False)[:2000]) lines.append("") txt_path.write_text("\n".join(lines), encoding="utf-8") json_path.write_text(json.dumps({ "started_at": audit.started_at, "summary": audit.summary(), "exit_code": audit.exit_code(), "findings": [asdict(f) for f in audit.findings], }, ensure_ascii=False, indent=2, default=str), encoding="utf-8") return txt_path, json_path def main() -> int: parser = argparse.ArgumentParser(description="ClientFlow deep read-only system audit") parser.add_argument("--window-hours", type=int, default=72) parser.add_argument("--sample-limit", type=int, default=25) parser.add_argument("--out-dir", default=str(PROJECT_ROOT / "audit_reports")) args = parser.parse_args() audit = Audit(sample_limit=args.sample_limit) try: with engine.begin() as conn: inspect_schema(audit, conn) check_base_health(audit, conn, args.window_hours) check_raw_events(audit, conn, args.window_hours) check_security_content(audit, conn, args.window_hours) check_tasks(audit, conn, args.window_hours) check_identity(audit, conn, args.window_hours) check_financial_safety(audit, conn, args.window_hours) check_llm_quality(audit, conn, args.window_hours) check_opportunities(audit, conn, args.window_hours) check_outbox(audit, conn, args.window_hours) except Exception as exc: audit.add("FAIL", "runtime", "Auditoria abortou com exceção", str(exc)[:2000]) txt_path, json_path = write_reports(audit, Path(args.out_dir)) print("ClientFlow Deep System Audit") print("=" * 90) for k, v in audit.summary().items(): print(f"{k}: {v}") print(f"Relatório TXT: {txt_path}") print(f"Relatório JSON: {json_path}") print(f"exit_code={audit.exit_code()}") # Print FAIL/WARN concise list for f in audit.findings: if f.severity in {"FAIL", "WARN"}: print(f"{f.severity} | {f.area} | {f.title} | count={f.count}") if f.samples: print(json.dumps(f.samples[:2], ensure_ascii=False, indent=2, default=str)[:3000]) return audit.exit_code() if __name__ == "__main__": raise SystemExit(main())