ViniciusKhan/analytical_force
0
1"""API HTTP (FastAPI) do Analytical-Force.2 3Expõe a execução do agente por API, para rodar 100% online (ex.: Hugging Face4Spaces tipo Docker) e ser acionado por um front-end.5 6Princípios:7- Toda a configuração vem de variáveis de ambiente (Secrets do Space).8- A execução é protegida por chave de API (cabeçalho ``X-API-Key``), pois9 dispara leitura no Salesforce e, opcionalmente, e-mail/ClickUp.10- O Salesforce continua somente leitura; nenhuma credencial é exposta.11 12Endpoints:13- ``GET /`` painel React (frontend-react/dist), quando compilado.14- ``GET /api`` página simples com instruções (info da API).15- ``GET /health`` verificação de saúde.16- ``GET /config/check`` validação da configuração (sem segredos).17- ``POST /run`` executa o pipeline diário (protegido por X-API-Key).18- ``GET /docs`` documentação interativa (Swagger).19"""20 21from __future__ import annotations22 23import os24import re25from pathlib import Path26from typing import Any27 28from fastapi import FastAPI, Header, HTTPException29from fastapi.middleware.cors import CORSMiddleware30from fastapi.responses import HTMLResponse31from fastapi.staticfiles import StaticFiles32from pydantic import BaseModel33 34# Validação simples de formato de e-mail (não substitui confirmação real de35# entrega — apenas evita cadastrar valores claramente inválidos).36_REGEX_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")37 38from src.agent.analytical_force_agent import AnalyticalForceAgent39from src.config import get_settings40from src.delivery.clickup_sender import criar_tarefas_de_alertas41from src.delivery.email_sender import enviar_relatorio_email42from src.utils.date_utils import parse_data43from src.utils.logger import get_logger44 45logger = get_logger("api")46 47app = FastAPI(48 title="Analytical-Force API",49 description="Agente analítico diário (Salesforce + Turso). Somente leitura.",50 version="1.0.0",51)52 53# CORS: libere os domínios do seu front em CORS_ORIGINS (separados por vírgula).54# Padrão "*" facilita testes; em produção, restrinja aos seus domínios.55_origens = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()]56app.add_middleware(57 CORSMiddleware,58 allow_origins=_origens or ["*"],59 allow_methods=["GET", "POST"],60 allow_headers=["*"],61)62 63 64# ----------------------------------------------------------------------65# Autenticação simples por chave de API66# ----------------------------------------------------------------------67def _exigir_token(x_api_key: str | None) -> None:68 """Valida o cabeçalho ``X-API-Key`` contra ``APP_API_TOKEN`` (se definido).69 70 Comportamento:71 - Se ``APP_API_TOKEN`` NÃO estiver definido: a API fica em "modo aberto"72 (sem autenticação). Registra um aviso — nesse caso, mantenha o Space73 como **Private** para não expor a execução publicamente.74 - Se estiver definido: exige o cabeçalho ``X-API-Key`` correspondente.75 """76 token = os.environ.get("APP_API_TOKEN", "").strip()77 if not token:78 logger.warning(79 "APP_API_TOKEN não definido: /run está SEM autenticação (modo aberto). "80 "Recomenda-se deixar o Space Private."81 )82 return83 if not x_api_key or x_api_key != token:84 raise HTTPException(status_code=401, detail="Chave de API inválida ou ausente.")85 86 87# ----------------------------------------------------------------------88# Modelos de requisição/resposta89# ----------------------------------------------------------------------90class RunRequest(BaseModel):91 """Parâmetros para execução do pipeline diário."""92 93 date: str | None = None # YYYY-MM-DD; vazio = ontem94 send_email: bool = False # envia e-mail (se SMTP configurado)95 create_clickup: bool = False # cria tarefas no ClickUp (se habilitado)96 97 98class EmailCcRequest(BaseModel):99 """Corpo para cadastrar um e-mail em cópia (Cc) no relatório diário."""100 101 email: str102 103 104# ----------------------------------------------------------------------105# Endpoints106# ----------------------------------------------------------------------107@app.get("/api", response_class=HTMLResponse)108def raiz() -> str:109 """Página com instruções básicas da API (o painel React vive em ``/``)."""110 return """\111<!doctype html><html lang="pt-br"><head><meta charset="utf-8">112<title>Analytical-Force API</title>113<style>body{font-family:Arial,Helvetica,sans-serif;max-width:720px;margin:40px auto;114padding:0 16px;color:#0f172a;line-height:1.5}code{background:#f1f5f9;padding:2px 6px;115border-radius:6px}a{color:#1f4fb2}</style></head><body>116<h1>📊 Analytical-Force API</h1>117<p>Agente de inteligência analítica (Salesforce → métricas em Python → Turso →118relatório). Opera <strong>somente leitura</strong> no Salesforce.</p>119<p>O painel web fica em <a href="/">/</a> (React, quando compilado).</p>120<ul>121<li><code>GET /health</code> — verificação de saúde</li>122<li><code>GET /config/check</code> — validação da configuração (sem segredos)</li>123<li><code>POST /run</code> — executa o pipeline (corpo JSON)</li>124<li><code>GET /run?date=YYYY-MM-DD</code> — executa pelo navegador (mais fácil)</li>125<li><code>GET /metrics/{data}</code> — lê as métricas já salvas (rápido, sem reexecutar)</li>126<li><code>GET /consulta/busca?termo=...</code> — busca de contas/oportunidades/contratos/itens</li>127<li><code>GET /consulta/objeto/{tipo}/{id}</code> — detalhe completo de um registro</li>128<li><a href="/docs">/docs</a> — documentação interativa (Swagger)</li>129</ul>130<p>Se <code>APP_API_TOKEN</code> estiver definido, envie o cabeçalho131<code>X-API-Key: SEU_TOKEN</code> nas chamadas de <code>/run</code> e132<code>/metrics</code>.</p>133</body></html>"""134 135 136@app.get("/health")137def health() -> dict[str, str]:138 """Verificação simples de disponibilidade."""139 return {"status": "ok", "service": "analytical-force"}140 141 142@app.get("/config/check")143def config_check() -> dict[str, Any]:144 """Resumo seguro da configuração + validação (não expõe segredos)."""145 settings = get_settings()146 return {147 "resumo": settings.resumo_seguro(),148 "validacao": settings.validar_tudo(),149 }150 151 152@app.get("/config/email-cc")153def listar_email_cc(154 x_api_key: str | None = Header(default=None, alias="X-API-Key"),155) -> dict[str, Any]:156 """Lista os e-mails cadastrados para receber cópia (Cc) do relatório."""157 _exigir_token(x_api_key)158 from src.database.repositories import ConfigRepository159 from src.database.turso_client import get_turso_client160 161 try:162 repo = ConfigRepository(get_turso_client())163 return {"emails_cc": repo.listar_emails_cc()}164 except Exception as exc:165 raise HTTPException(166 status_code=502,167 detail=f"Falha ao ler e-mails em cópia: {type(exc).__name__}: {exc}",168 )169 170 171@app.post("/config/email-cc")172def adicionar_email_cc(173 req: EmailCcRequest,174 x_api_key: str | None = Header(default=None, alias="X-API-Key"),175) -> dict[str, Any]:176 """Cadastra um e-mail em cópia (Cc) do relatório diário."""177 _exigir_token(x_api_key)178 email = req.email.strip().lower()179 if not _REGEX_EMAIL.match(email):180 raise HTTPException(status_code=400, detail=f"E-mail inválido: {req.email!r}")181 182 from src.database.repositories import ConfigRepository183 from src.database.turso_client import get_turso_client184 185 try:186 repo = ConfigRepository(get_turso_client())187 atuais = repo.listar_emails_cc()188 if email not in atuais:189 atuais.append(email)190 emails_cc = repo.definir_emails_cc(atuais)191 return {"emails_cc": emails_cc}192 except Exception as exc:193 raise HTTPException(194 status_code=502,195 detail=f"Falha ao cadastrar e-mail em cópia: {type(exc).__name__}: {exc}",196 )197 198 199@app.delete("/config/email-cc/{email}")200def remover_email_cc(201 email: str,202 x_api_key: str | None = Header(default=None, alias="X-API-Key"),203) -> dict[str, Any]:204 """Remove um e-mail da lista de cópia (Cc) do relatório diário."""205 _exigir_token(x_api_key)206 from src.database.repositories import ConfigRepository207 from src.database.turso_client import get_turso_client208 209 try:210 repo = ConfigRepository(get_turso_client())211 restantes = [e for e in repo.listar_emails_cc() if e != email.strip().lower()]212 emails_cc = repo.definir_emails_cc(restantes)213 return {"emails_cc": emails_cc}214 except Exception as exc:215 raise HTTPException(216 status_code=502,217 detail=f"Falha ao remover e-mail em cópia: {type(exc).__name__}: {exc}",218 )219 220 221def _executar_pipeline(req: RunRequest) -> dict[str, Any]:222 """Executa o pipeline e devolve o JSON de resultado (com erros tratados)."""223 settings = get_settings()224 agente = AnalyticalForceAgent(settings)225 226 erros = agente.validar_prerequisitos()227 if erros:228 raise HTTPException(status_code=400, detail={"prerequisitos": erros})229 230 try:231 dia = parse_data(req.date) if req.date else None232 except Exception as exc:233 raise HTTPException(status_code=400, detail=f"Data inválida: {exc}")234 235 try:236 resultado = agente.executar(dia)237 except Exception as exc: # rede/lib inesperada — resposta limpa, sem stacktrace238 raise HTTPException(239 status_code=502,240 detail=f"Falha ao executar o agente: {type(exc).__name__}: {exc}",241 )242 if resultado.status != "success":243 raise HTTPException(status_code=502, detail=f"Execução falhou: {resultado.erro}")244 245 entregas: dict[str, Any] = {}246 if req.send_email and settings.email.is_configured:247 from src.database.repositories import ConfigRepository248 from src.database.turso_client import get_turso_client249 250 try:251 emails_cc = ConfigRepository(get_turso_client()).listar_emails_cc()252 except Exception as exc: # não deve impedir o envio ao destinatário principal253 logger.warning("Falha ao ler e-mails em cópia no Turso: %s", type(exc).__name__)254 emails_cc = []255 256 entregas["email_enviado"] = enviar_relatorio_email(257 config=settings.email,258 assunto=f"Analytical-Force — Relatório {resultado.dia}",259 report_date=str(resultado.dia),260 metrics=resultado.metricas,261 alerts=resultado.alertas,262 report_markdown=resultado.markdown,263 highlights=resultado.destaques,264 cc_emails=emails_cc,265 )266 if req.create_clickup and settings.clickup.auto_create:267 entregas["clickup_tarefas"] = criar_tarefas_de_alertas(268 resultado.alertas,269 settings.clickup,270 settings.clickup.auto_create,271 instance_url=settings.salesforce.instance_url,272 report_date=str(resultado.dia),273 )274 275 return {276 "status": resultado.status,277 "date": str(resultado.dia),278 "provider": resultado.provider,279 "alerts_count": len(resultado.alertas),280 "alerts": [281 {282 "severity": a.get("severity"),283 "category": a.get("category"),284 "title": a.get("title"),285 "description": a.get("description"),286 "recommended_action": a.get("recommended_action"),287 "affected_records": a.get("affected_records") or [],288 "action_plan": a.get("action_plan"),289 }290 for a in resultado.alertas291 ],292 "metrics": resultado.metricas,293 "highlights": resultado.destaques,294 "report_markdown": resultado.markdown,295 "deliveries": entregas,296 }297 298 299@app.post("/run")300def run_post(301 req: RunRequest,302 x_api_key: str | None = Header(default=None, alias="X-API-Key"),303) -> dict[str, Any]:304 """Executa o pipeline diário (corpo JSON). Requer ``X-API-Key`` se definido."""305 _exigir_token(x_api_key)306 return _executar_pipeline(req)307 308 309@app.get("/run")310def run_get(311 date: str | None = None,312 send_email: bool = False,313 create_clickup: bool = False,314 x_api_key: str | None = Header(default=None, alias="X-API-Key"),315) -> dict[str, Any]:316 """Versão por query string: ``/run?date=YYYY-MM-DD`` (facilita testes)."""317 _exigir_token(x_api_key)318 return _executar_pipeline(319 RunRequest(date=date, send_email=send_email, create_clickup=create_clickup)320 )321 322 323@app.get("/days")324def days(325 x_api_key: str | None = Header(default=None, alias="X-API-Key"),326) -> dict[str, Any]:327 """Lista as datas que já têm relatório salvo no Turso (para o seletor)."""328 _exigir_token(x_api_key)329 from src.database.repositories import ReportRepository330 from src.database.turso_client import get_turso_client331 332 try:333 repo = ReportRepository(get_turso_client())334 return {"dates": repo.listar_datas(90)}335 except Exception as exc:336 raise HTTPException(337 status_code=502, detail=f"Falha ao listar dias: {type(exc).__name__}: {exc}"338 )339 340 341@app.get("/day/{data}")342def day(343 data: str,344 x_api_key: str | None = Header(default=None, alias="X-API-Key"),345) -> dict[str, Any]:346 """Retorna TODOS os dados salvos de um dia (sem reexecutar o agente).347 348 Lê o relatório salvo no Turso e devolve métricas, alertas, destaques e o349 Markdown — é o que alimenta as telas do front a partir do banco.350 """351 _exigir_token(x_api_key)352 from src.database.repositories import ReportRepository353 from src.database.turso_client import get_turso_client354 355 try:356 dia = parse_data(data)357 repo = ReportRepository(get_turso_client())358 registro = repo.buscar_relatorio(dia)359 except Exception as exc:360 raise HTTPException(361 status_code=502, detail=f"Falha ao ler o dia: {type(exc).__name__}: {exc}"362 )363 if not registro:364 raise HTTPException(status_code=404, detail=f"Sem relatório salvo para {data}.")365 366 p = registro.get("payload") or {}367 return {368 "date": str(dia),369 "provider": registro.get("provider"),370 "report_markdown": registro.get("markdown", ""),371 "metrics": p.get("metrics", {}),372 "alerts": p.get("alerts", []),373 "highlights": p.get("highlights", {}),374 "data_quality": p.get("data_quality", {}),375 "alerts_count": len(p.get("alerts", []) or []),376 }377 378 379@app.get("/history")380def history(381 days: int = 7,382 x_api_key: str | None = Header(default=None, alias="X-API-Key"),383) -> dict[str, Any]:384 """Série histórica de métricas salvas no Turso (para gráficos de tendência).385 386 Lê ``daily_metrics`` dos últimos ``days`` dias (sem reexecutar o pipeline).387 """388 _exigir_token(x_api_key)389 from datetime import timedelta390 391 from src.config import get_settings392 from src.database.repositories import MetricsRepository393 from src.database.turso_client import get_turso_client394 from src.utils.date_utils import agora_tz395 396 try:397 n = max(1, min(int(days), 60))398 settings = get_settings()399 base = agora_tz(settings.report_timezone).date()400 repo = MetricsRepository(get_turso_client())401 serie: list[dict[str, Any]] = []402 for i in range(n - 1, -1, -1):403 dia = base - timedelta(days=i)404 metricas = repo.buscar_metricas_do_dia(dia)405 if metricas:406 serie.append({"date": str(dia), "metrics": metricas})407 return {"days": serie}408 except Exception as exc:409 raise HTTPException(410 status_code=502, detail=f"Falha ao ler histórico: {type(exc).__name__}: {exc}"411 )412 413 414@app.get("/metrics/{data}")415def metrics_do_dia(416 data: str,417 x_api_key: str | None = Header(default=None, alias="X-API-Key"),418) -> dict[str, Any]:419 """Leitura rápida das métricas já salvas no Turso para uma data.420 421 Não reexecuta o Salesforce nem a IA — útil para o front carregar422 rapidamente um dia já processado.423 """424 _exigir_token(x_api_key)425 from src.database.repositories import MetricsRepository426 from src.database.turso_client import get_turso_client427 428 try:429 dia = parse_data(data)430 repo = MetricsRepository(get_turso_client())431 return {"date": str(dia), "metrics": repo.buscar_metricas_do_dia(dia)}432 except Exception as exc:433 raise HTTPException(434 status_code=502, detail=f"Falha ao ler métricas: {type(exc).__name__}: {exc}"435 )436 437 438# ----------------------------------------------------------------------439# Consulta (busca híbrida) — Contas, Oportunidades, Contratos, Itens.440# ----------------------------------------------------------------------441@app.get("/consulta/tipos")442def consulta_tipos(443 x_api_key: str | None = Header(default=None, alias="X-API-Key"),444) -> dict[str, Any]:445 """Lista os tipos de busca disponíveis e o estado da configuração."""446 _exigir_token(x_api_key)447 from src.query.search_service import status_tipos448 449 return status_tipos(get_settings())450 451 452@app.get("/consulta/busca")453def consulta_busca(454 termo: str,455 tipos: str | None = None,456 limite: int = 20,457 x_api_key: str | None = Header(default=None, alias="X-API-Key"),458) -> dict[str, Any]:459 """Busca híbrida (cache no Turso + fallback ao vivo no Salesforce).460 461 Args:462 termo: Texto buscado (mínimo 2 caracteres).463 tipos: Lista separada por vírgula (ex.: ``conta,contrato``). Vazio = todos.464 limite: Máximo de resultados por tipo (padrão 20).465 """466 _exigir_token(x_api_key)467 from src.database.turso_client import get_turso_client468 from src.query.search_service import TIPOS_VALIDOS, buscar469 from src.salesforce.client import get_salesforce_client470 471 lista_tipos = [t.strip() for t in tipos.split(",") if t.strip()] if tipos else None472 if lista_tipos:473 invalidos = [t for t in lista_tipos if t not in TIPOS_VALIDOS]474 if invalidos:475 raise HTTPException(476 status_code=400,477 detail=f"Tipo(s) inválido(s): {invalidos}. Use: {list(TIPOS_VALIDOS)}.",478 )479 480 settings = get_settings()481 try:482 sf_client = get_salesforce_client(settings)483 sf_client.connect()484 resultados = buscar(settings, get_turso_client(), sf_client, termo, lista_tipos, limite)485 return {"termo": termo, "resultados": resultados}486 except Exception as exc:487 raise HTTPException(488 status_code=502, detail=f"Falha na busca: {type(exc).__name__}: {exc}"489 )490 491 492@app.get("/consulta/objeto/{tipo}/{record_id}")493def consulta_detalhe(494 tipo: str,495 record_id: str,496 x_api_key: str | None = Header(default=None, alias="X-API-Key"),497) -> dict[str, Any]:498 """Detalhe completo de um registro (Conta, Oportunidade, Contrato ou Item)."""499 _exigir_token(x_api_key)500 from src.database.turso_client import get_turso_client501 from src.query.search_service import TIPOS_VALIDOS, detalhar502 from src.salesforce.client import get_salesforce_client503 504 if tipo not in TIPOS_VALIDOS:505 raise HTTPException(506 status_code=400, detail=f"Tipo inválido: {tipo!r}. Use: {list(TIPOS_VALIDOS)}."507 )508 509 settings = get_settings()510 try:511 sf_client = get_salesforce_client(settings)512 sf_client.connect()513 return detalhar(settings, get_turso_client(), sf_client, tipo, record_id)514 except ValueError as exc:515 raise HTTPException(status_code=404, detail=str(exc))516 except Exception as exc:517 raise HTTPException(518 status_code=502, detail=f"Falha ao obter detalhe: {type(exc).__name__}: {exc}"519 )520 521 522# ----------------------------------------------------------------------523# Painel React (estático) — serve frontend-react/dist em "/", se compilado.524# ----------------------------------------------------------------------525# Precisa ser o ÚLTIMO registro de rota: o Starlette tenta as rotas nesta526# ordem, então os endpoints acima (``/health``, ``/days`` etc.) continuam527# tendo prioridade sobre o mount; só cai aqui o que não bateu com nenhuma528# rota explícita (ou seja, os arquivos do painel).529_DIST_PAINEL = Path(__file__).resolve().parent / "frontend-react" / "dist"530if _DIST_PAINEL.is_dir():531 app.mount("/", StaticFiles(directory=str(_DIST_PAINEL), html=True), name="painel")532else:533 logger.warning(534 "frontend-react/dist não encontrado — painel React não será servido em '/'. "535 "Rode 'npm run build' em frontend-react/ (o Dockerfile já faz isso no deploy)."536 )537 