Files
clientflow_backend/scripts/audit_deep_system.py

707 lines
32 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=<root>, 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())