CoolFace
Apppublic

ViniciusKhan/analytical_force

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
api.py537 linesDownload Raw Back to root
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