@danhachuel/leadmaster 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/backend/main.py +466 -0
- package/backend/requirements.txt +4 -0
- package/backend/services/__init__.py +1 -0
- package/backend/services/agents.py +235 -0
- package/backend/services/crm_agent.py +175 -0
- package/backend/services/extractors.py +322 -0
- package/backend/services/llm.py +69 -0
- package/backend/services/storage.py +440 -0
- package/frontend/dist/assets/fa-brands-400-D1LuMI3I.ttf +0 -0
- package/frontend/dist/assets/fa-brands-400-D_cYUPeE.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-BjRzuEpd.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-DZaxPHgR.ttf +0 -0
- package/frontend/dist/assets/fa-solid-900-CTAAxXor.woff2 +0 -0
- package/frontend/dist/assets/fa-solid-900-D0aA9rwL.ttf +0 -0
- package/frontend/dist/assets/fa-v4compatibility-C9RhG_FT.woff2 +0 -0
- package/frontend/dist/assets/fa-v4compatibility-CCth-dXg.ttf +0 -0
- package/frontend/dist/assets/index-CFQHp6yf.css +5 -0
- package/frontend/dist/assets/index-CPq53JWx.js +90 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/index.html +14 -0
- package/package.json +47 -4
- package/scripts/cli.mjs +512 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Todas as mudanças notáveis deste projeto serão documentadas neste ficheiro.
|
|
4
|
+
O formato é baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/).
|
|
5
|
+
|
|
6
|
+
A etiqueta visual de versão usa patch de dois dígitos (ex.: `0.1.00`); o registry NPM
|
|
7
|
+
normaliza para a forma canônica (ex.: `0.1.0`).
|
|
8
|
+
|
|
9
|
+
## [0.1.01] — 2026-10-23
|
|
10
|
+
|
|
11
|
+
### Removido
|
|
12
|
+
|
|
13
|
+
- **Pipeline de vídeos faceless** — o LeadMaster agora é 100% focado em prospecção
|
|
14
|
+
(CRM, extratores de leads e agentes de IA). Removidos:
|
|
15
|
+
- Aba lateral "Pipeline de Vídeos", página e rota `/pipeline`.
|
|
16
|
+
- Integrações com MoneyPrinterTurbo, Short-Video-Maker, OpenMontage e upload para
|
|
17
|
+
YouTube: serviços backend, endpoints `/api/pipeline`, cards e testes em
|
|
18
|
+
Ferramentas, menções no Painel, tutorial do header, FAQ do Suporte e
|
|
19
|
+
diagnóstico.
|
|
20
|
+
- Motor do pipeline, agendador de publicação e contadores diários de upload.
|
|
21
|
+
- `pipeline.json` do armazenamento local — removido automaticamente das
|
|
22
|
+
instalações existentes, junto com as chaves de vídeo do `config.json`
|
|
23
|
+
(`integracoes.moneyprinter`, `short_video_maker`, `openmontage`,
|
|
24
|
+
`youtube_upload`, `uploads`) e o evento de webhook `video.publicado`.
|
|
25
|
+
|
|
26
|
+
### Adicionado
|
|
27
|
+
|
|
28
|
+
- Migração automática de configuração: chaves de vídeo de versões antigas são
|
|
29
|
+
limpas na primeira execução da nova versão.
|
|
30
|
+
- Card "Status da Conexão" do Painel passa a mostrar LLM e OpenOutreach.
|
|
31
|
+
|
|
32
|
+
### Alterado
|
|
33
|
+
|
|
34
|
+
- Backend de desenvolvimento (uvicorn) agora usa a porta **8010** — a 8000 costuma
|
|
35
|
+
ser usada por outros serviços locais (ex.: PandaNews), o que causava conflito.
|
|
36
|
+
|
|
37
|
+
## [0.1.00] — 2026-10-23
|
|
38
|
+
|
|
39
|
+
### Adicionado
|
|
40
|
+
|
|
41
|
+
- **Versão inicial do LeadMaster** — SaaS local de prospecção e automação de conteúdo,
|
|
42
|
+
instalável via `npx @danhachuel/leadmaster` e 100% offline (sem nuvem).
|
|
43
|
+
- **Launcher NPM** (`scripts/cli.mjs`): comandos `install`, `start`, `doctor`, `version`,
|
|
44
|
+
`help`; **comando único auto-suficiente** (estilo Thunderbolt) — `npx
|
|
45
|
+
@danhachuel/leadmaster` sem argumentos verifica e prepara tudo sozinho antes de subir;
|
|
46
|
+
**instalação idempotente** (dependências Python e Node só são instaladas/atualizadas
|
|
47
|
+
quando realmente faltam; interface só é reconstruída quando o build não existe ou
|
|
48
|
+
falha por deps desatualizadas); resolução da pasta de dados (`--home` /
|
|
49
|
+
`LEADMASTER_HOME`, padrão `Documents/REPOSITORIO/LeadMaster`); instalação automática
|
|
50
|
+
das dependências Python; porta padrão da UI/API **5055** com auto-incremento (5056,
|
|
51
|
+
5057, ...) quando ocupada; deteção de instância já ativa; abertura automática do
|
|
52
|
+
navegador; encerramento limpo.
|
|
53
|
+
- **Workflow de publicação** (`.github/workflows/publish-npm.yml`): build do frontend,
|
|
54
|
+
validação de metadados, smoke-test do launcher, publicação via `dist-tag next`,
|
|
55
|
+
verificação de tarball e promoção a `latest` por tag `v*`.
|
|
56
|
+
- **Backend FastAPI** (`backend/`):
|
|
57
|
+
- Persistência local em JSON (`storage/leads.json`, `campaigns.json`, `pipeline.json`,
|
|
58
|
+
`config.json`) com escrita atômica e seeds automáticas.
|
|
59
|
+
- CRUD de leads com importação em massa, agente de CRM (sugestão de movimentação e
|
|
60
|
+
enriquecimento via BrasilAPI/LLM).
|
|
61
|
+
- Campanhas de disparo com execução em background, contadores e log por lead.
|
|
62
|
+
- Motor do pipeline de vídeos faceless com 5 etapas (roteiro, thumbnail/título,
|
|
63
|
+
produção wide 16:9, produção shorts 9:16, upload), controle
|
|
64
|
+
iniciar/pausar/parar/repetir, logs por etapa e agendamento de publicação
|
|
65
|
+
(não listado → 1h → público).
|
|
66
|
+
- Integrações: MoneyPrinterTurbo (localhost:8080), Short-Video-Maker (localhost:3123),
|
|
67
|
+
OpenMontage (CLI), upload YouTube com limite de 50/dia e fallback
|
|
68
|
+
`youtube-uploader-mcp`, LLM OpenAI-compatível, OpenOutreach, pipecat.
|
|
69
|
+
- Extratores de leads: Google Maps (`gosom/google-maps-scraper`), Instagram
|
|
70
|
+
(`lucasoal/igmapper`), CNPJ (BrasilAPI + provedor de busca por CNAE/UF +
|
|
71
|
+
`D4Vinci/Scrapling`).
|
|
72
|
+
- Endpoints de diagnóstico (`/api/health`, `/api/doctor`), métricas do painel e
|
|
73
|
+
serving estático da UI construída (modo `npx`).
|
|
74
|
+
- **Frontend React 18 + Vite + TypeScript + Tailwind** (`frontend/`):
|
|
75
|
+
- UI fiel ao protótipo `LeadMaster v1.0` (sidebar `#1e1b4b`, cards brancos, gradientes
|
|
76
|
+
indigo/purple, Font Awesome) com as 7 Leis de UX aplicadas; **favicon de urso** nas
|
|
77
|
+
guias dos navegadores (`frontend/public/favicon.svg`).
|
|
78
|
+
- Páginas: Painel (métricas + gráficos Chart.js), Leads (CRUD + progresso de
|
|
79
|
+
preenchimento), CRM Kanban (`@dnd-kit` — Leads/Em contato/Proposta/Fechado),
|
|
80
|
+
Disparos (campanhas), Extratores (Google Maps/Instagram/CNPJ), Pipeline de vídeos,
|
|
81
|
+
Agentes (disparo/atendimento), Ferramentas (configuração de chaves e caminhos),
|
|
82
|
+
Webhook e Suporte (diagnóstico).
|
|
83
|
+
- Componentes: Sidebar, Header, MetricCard, ProgressBar, Accordion, Modal, Toast,
|
|
84
|
+
StatusBadge, gráficos (barras horizontais e doughnut) e Kanban com drag-and-drop.
|
|
85
|
+
- **Documentação**: `README.md` (instalação, arquitetura, integrações), `AGENTS.md`
|
|
86
|
+
(regras de projeto, versionamento `MAJOR.MINOR.PATCH` com patch de dois dígitos,
|
|
87
|
+
release via GitHub Actions, push obrigatório, CHANGELOG obrigatório) e `LICENSE` (MIT).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Pariente Hachuel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# LeadMaster
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**SaaS local de prospecção.** CRM, extratores de leads e agentes de IA — instalável via
|
|
4
|
+
`npx`, 100% local, sem nuvem e sem banco de dados externo.
|
|
5
|
+
|
|
6
|
+
  
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Instalação e uso
|
|
11
|
+
|
|
12
|
+
Requisitos: **Node 18+** e **Python 3.11+** no PATH.
|
|
13
|
+
|
|
14
|
+
```cmd
|
|
15
|
+
:: CMD / MobaXterm (Windows)
|
|
16
|
+
npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0 install
|
|
17
|
+
npx.cmd --yes --prefer-online @danhachuel/leadmaster@0.1.0
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Linux / macOS / PowerShell
|
|
22
|
+
npx --yes @danhachuel/leadmaster@0.1.0 install
|
|
23
|
+
npx --yes @danhachuel/leadmaster@0.1.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `install` prepara a pasta de dados e instala as dependências Python.
|
|
27
|
+
- Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e abre o
|
|
28
|
+
navegador automaticamente. Se a porta 5055 estiver ocupada, o LeadMaster sobe
|
|
29
|
+
automaticamente na próxima livre (5056, 5057, ...). Pressione `Ctrl+C` para encerrar.
|
|
30
|
+
- Flags úteis: `--home=<caminho>` (pasta de dados), `--port=<numero>`,
|
|
31
|
+
`doctor` (diagnóstico), `help`.
|
|
32
|
+
|
|
33
|
+
A pasta de dados padrão é `Documents\REPOSITORIO\LeadMaster` (sobrescreva com
|
|
34
|
+
`--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas e
|
|
35
|
+
configuração — ficam em `<home>\storage` como arquivos JSON.
|
|
36
|
+
|
|
37
|
+
### Modo desenvolvimento (a partir do repositório)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install # dependências da raiz (concurrently)
|
|
41
|
+
npm run dev # sobe backend (uvicorn :8010, reload) + frontend (Vite :5055)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
O Vite dev server sobe a UI de desenvolvimento em `http://localhost:5055` (com
|
|
45
|
+
auto-incremento para 5056, 5057... se a porta estiver ocupada) e faz proxy de `/api`
|
|
46
|
+
para `http://127.0.0.1:8010` (backend de desenvolvimento).
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Módulos
|
|
51
|
+
|
|
52
|
+
| Módulo | O que faz |
|
|
53
|
+
|---|---|
|
|
54
|
+
| **Painel** | Status da conexão, taxa de entrega, taxa de erro, total de leads; gráfico de barras (leads por etapa do CRM) e doughnut (campanhas: enviadas × erros). |
|
|
55
|
+
| **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
|
|
56
|
+
| **CRM Kanban** | Drag-and-drop (@dnd-kit) nas colunas *Leads → Em contato → Proposta → Fechado*; o agente de IA sugere movimentações e enriquece os cards. |
|
|
57
|
+
| **Extratores** | **Google Maps** (palavra-chave + localização, via `gosom/google-maps-scraper`), **Instagram** (URL do perfil, via `lucasoal/igmapper`) e **CNPJ** (BrasilAPI + busca por CNAE/UF + `D4Vinci/Scrapling`). Toda extração gera pré-visualização antes de importar. |
|
|
58
|
+
| **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
|
|
59
|
+
| **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
|
|
60
|
+
| **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Arquitetura
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
npx @danhachuel/leadmaster
|
|
68
|
+
└─ scripts/cli.mjs # instala deps Python, sobe uvicorn, abre navegador
|
|
69
|
+
└─ backend/main.py # FastAPI: API + serving da UI construída (frontend/dist)
|
|
70
|
+
├─ services/storage.py # JSON local (leads, campanhas, config)
|
|
71
|
+
├─ services/extractors.py # Maps / Instagram / CNPJ
|
|
72
|
+
├─ services/crm_agent.py # sugestões + enriquecimento
|
|
73
|
+
├─ services/agents.py # disparo (OpenOutreach) + atendimento
|
|
74
|
+
└─ services/llm.py # LLM OpenAI-compatível
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **Portas**: LeadMaster UI/API `:5055` (auto-incrementa: 5056, 5057, ... se ocupada) ·
|
|
78
|
+
backend de desenvolvimento `:8000`.
|
|
79
|
+
- **Degradación graciosa**: se um serviço externo não estiver configurado/rodando, a ação
|
|
80
|
+
falha com mensagem acionável — nada de falsos sucessos.
|
|
81
|
+
|
|
82
|
+
### Serviços externos (opcionais, todos locais)
|
|
83
|
+
|
|
84
|
+
| Serviço | Como usar no LeadMaster |
|
|
85
|
+
|---|---|
|
|
86
|
+
| [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) | Informe o caminho do binário em *Ferramentas → Extratores*. |
|
|
87
|
+
| [lucasoal/igmapper](https://github.com/lucasoal/igmapper) | Informe o caminho em *Ferramentas → Extratores*. |
|
|
88
|
+
| BrasilAPI / OpenCNPJ / Scrapling | Consulta por CNPJ e busca por CNAE/UF (configuráveis). |
|
|
89
|
+
| [eracle/OpenOutreach](https://github.com/eracle/OpenOutreach) | Agente de disparo. |
|
|
90
|
+
| [pipecat-ai/pipecat](https://github.com/pipecat-ai/pipecat) | Atendimento por voz. |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## UX
|
|
95
|
+
|
|
96
|
+
A interface replica o protótipo `LeadMaster v1.0` (sidebar escura `#1e1b4b`, cards
|
|
97
|
+
brancos, gradientes indigo/purple) e aplica as **7 Leis de Usabilidade**:
|
|
98
|
+
|
|
99
|
+
1. **Fitts** — CTAs grandes ("Nova Campanha", "Salvar").
|
|
100
|
+
2. **Hick** — configurações avançadas em accordions.
|
|
101
|
+
3. **Zeigarnik** — barras de progresso (preenchimento de leads e campanhas).
|
|
102
|
+
4. **Jakob** — consistência com o modelo mental do protótipo (menu lateral fixo).
|
|
103
|
+
5. **Gradiente de Meta** — "Faltam 2 campos para o lead ficar completo".
|
|
104
|
+
6. **von Restorff** — botão principal com gradiente sobre fundo neutro.
|
|
105
|
+
7. **Miller** — métricas agrupadas em cards (chunking).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Versionamento e release
|
|
110
|
+
|
|
111
|
+
- Etiqueta visual: `MAJOR.MINOR.PATCH` com **patch de dois dígitos** (`0.1.00` → `0.1.99`
|
|
112
|
+
→ `0.2.00`; `0.9.99` → `1.0.00`). O NPM normaliza para a forma canônica (`0.1.0`).
|
|
113
|
+
- Publicação **sempre** via GitHub Actions (`.github/workflows/publish-npm.yml`):
|
|
114
|
+
dispatch manual com `dist-tag next` → push da tag `vX.Y.ZZ` → promoção a `latest`.
|
|
115
|
+
- Toda versão precisa de entrada no `CHANGELOG.md`. Regras completas em `AGENTS.md`.
|
|
116
|
+
|
|
117
|
+
## Licença
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE) © Dan Hachuel
|
package/backend/main.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""LeadMaster — API FastAPI (100% local).
|
|
2
|
+
|
|
3
|
+
Rotas da aplicação + serving estático da UI construída (frontend/dist) para o
|
|
4
|
+
modo `npx`, onde um único servidor entrega painel e API na mesma porta.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, HTTPException
|
|
16
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
17
|
+
from fastapi.responses import FileResponse
|
|
18
|
+
from fastapi.staticfiles import StaticFiles
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from services import agents, crm_agent, extractors, llm, storage
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
FRONTEND_DIST = REPO_ROOT / "frontend" / "dist"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _versao() -> str:
|
|
28
|
+
env = os.environ.get("LEADMASTER_VERSION")
|
|
29
|
+
if env:
|
|
30
|
+
return env
|
|
31
|
+
try:
|
|
32
|
+
return json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8"))["version"]
|
|
33
|
+
except Exception:
|
|
34
|
+
return "dev"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
VERSION = _versao()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def lifespan(_app: FastAPI):
|
|
42
|
+
storage.ensure_seeds()
|
|
43
|
+
yield
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
app = FastAPI(title="LeadMaster API", version=VERSION, lifespan=lifespan)
|
|
47
|
+
|
|
48
|
+
app.add_middleware(
|
|
49
|
+
CORSMiddleware,
|
|
50
|
+
# A UI local (npx) roda same-origin; em modo dev o Vite sobe em 5055 e pode
|
|
51
|
+
# auto-incrementar (5056, 5057...) quando a porta está ocupada — por isso
|
|
52
|
+
# aceitamos qualquer porta em localhost/127.0.0.1.
|
|
53
|
+
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
|
|
54
|
+
allow_methods=["*"],
|
|
55
|
+
allow_headers=["*"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Models
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class LeadIn(BaseModel):
|
|
64
|
+
nome: str = Field(min_length=1)
|
|
65
|
+
telefone: str = ""
|
|
66
|
+
email: str = ""
|
|
67
|
+
fonte: str = "Manual"
|
|
68
|
+
etapa: str = "leads"
|
|
69
|
+
cnpj: str = ""
|
|
70
|
+
empresa: str = ""
|
|
71
|
+
endereco: str = ""
|
|
72
|
+
cidade: str = ""
|
|
73
|
+
observacoes: str = ""
|
|
74
|
+
score: int | None = None
|
|
75
|
+
tags: list[str] = []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LeadsImportIn(BaseModel):
|
|
79
|
+
leads: list[dict]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CampaignIn(BaseModel):
|
|
83
|
+
nome: str = Field(min_length=1)
|
|
84
|
+
canal: str = "texto"
|
|
85
|
+
template: str = "texto"
|
|
86
|
+
lead_ids: list[str] = []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GoogleMapsIn(BaseModel):
|
|
90
|
+
palavra_chave: str = Field(min_length=2)
|
|
91
|
+
localizacao: str = Field(min_length=2)
|
|
92
|
+
max_resultados: int = 100
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InstagramIn(BaseModel):
|
|
96
|
+
profile_url: str = Field(min_length=6)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CnpjIn(BaseModel):
|
|
100
|
+
cnpj: str = Field(min_length=8)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CnpjSearchIn(BaseModel):
|
|
104
|
+
cnae: str = Field(min_length=2)
|
|
105
|
+
uf: str = Field(min_length=2)
|
|
106
|
+
quantidade: int = 20
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ChatIn(BaseModel):
|
|
110
|
+
mensagens: list[dict]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TemplateTestIn(BaseModel):
|
|
114
|
+
template: str
|
|
115
|
+
lead_id: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class WebhookTestIn(BaseModel):
|
|
119
|
+
url: str = Field(min_length=8)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class IntegracaoTestIn(BaseModel):
|
|
123
|
+
url: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Sistema
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
@app.get("/api/health")
|
|
131
|
+
def health():
|
|
132
|
+
return {
|
|
133
|
+
"status": "ok",
|
|
134
|
+
"versao": VERSION,
|
|
135
|
+
"home": str(storage.get_home()),
|
|
136
|
+
"storage": str(storage.storage_dir()),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.get("/api/doctor")
|
|
141
|
+
def doctor():
|
|
142
|
+
integracoes = {
|
|
143
|
+
"llm_configurado": llm.configurado(),
|
|
144
|
+
"openoutreach": agents.disparo_configurado(),
|
|
145
|
+
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
"ok": True,
|
|
149
|
+
"versao": VERSION,
|
|
150
|
+
"python": platform.python_version(),
|
|
151
|
+
"sistema": platform.platform(),
|
|
152
|
+
"dist_frontend": FRONTEND_DIST.exists(),
|
|
153
|
+
"storage": str(storage.storage_dir()),
|
|
154
|
+
"integracoes": integracoes,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.post("/api/shutdown")
|
|
159
|
+
def shutdown():
|
|
160
|
+
threading.Timer(0.6, os._exit, [0]).start()
|
|
161
|
+
return {"ok": True, "mensagem": "Servidor local encerrando em instantes."}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Métricas (Painel)
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@app.get("/api/metrics")
|
|
169
|
+
def metrics():
|
|
170
|
+
leads = storage.list_leads()
|
|
171
|
+
campaigns = storage.list_campaigns()
|
|
172
|
+
enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
|
|
173
|
+
erros = sum(int(c.get("erros", 0)) for c in campaigns)
|
|
174
|
+
total_envios = enviadas + erros
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"conexao": {
|
|
178
|
+
"geral": True, # o backend local responde => conectado
|
|
179
|
+
"llm": llm.configurado(),
|
|
180
|
+
"openoutreach": agents.disparo_configurado(),
|
|
181
|
+
},
|
|
182
|
+
"taxa_entrega": round(enviadas / total_envios * 100, 1) if total_envios else 100.0,
|
|
183
|
+
"taxa_erro": round(erros / total_envios * 100, 1) if total_envios else 0.0,
|
|
184
|
+
"total_enviadas": enviadas,
|
|
185
|
+
"total_erros": erros,
|
|
186
|
+
"leads_total": len(leads),
|
|
187
|
+
"leads_por_etapa": {
|
|
188
|
+
etapa: sum(1 for l in leads if l.get("etapa") == etapa)
|
|
189
|
+
for etapa in ("leads", "em_contato", "proposta", "fechado")
|
|
190
|
+
},
|
|
191
|
+
"campanhas": {"enviadas": enviadas, "erros": erros, "total": len(campaigns)},
|
|
192
|
+
"perfil": storage.get_config().get("perfil", {}),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Leads
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _validar_etapa(etapa: str) -> str:
|
|
201
|
+
if etapa not in ("leads", "em_contato", "proposta", "fechado"):
|
|
202
|
+
raise HTTPException(400, "Etapa inválida. Use: leads, em_contato, proposta ou fechado.")
|
|
203
|
+
return etapa
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.get("/api/leads")
|
|
207
|
+
def listar_leads():
|
|
208
|
+
return storage.list_leads()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.post("/api/leads", status_code=201)
|
|
212
|
+
def criar_lead(lead_in: LeadIn):
|
|
213
|
+
lead = lead_in.model_dump()
|
|
214
|
+
lead["id"] = storage._novo_id()
|
|
215
|
+
lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
|
|
216
|
+
lead["score"] = lead.get("score") if lead.get("score") is not None else crm_agent.calcular_completude(lead)
|
|
217
|
+
storage.add_lead(lead)
|
|
218
|
+
agents.disparar_webhook("lead.criado", {"lead_id": lead["id"], "nome": lead["nome"]})
|
|
219
|
+
return lead
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.patch("/api/leads/{lead_id}")
|
|
223
|
+
def atualizar_lead(lead_id: str, patch: dict):
|
|
224
|
+
if patch.get("etapa"):
|
|
225
|
+
_validar_etapa(patch["etapa"])
|
|
226
|
+
atualizado = storage.update_lead(lead_id, patch)
|
|
227
|
+
if not atualizado:
|
|
228
|
+
raise HTTPException(404, "Lead não encontrado.")
|
|
229
|
+
if patch.get("etapa"):
|
|
230
|
+
agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": patch["etapa"]})
|
|
231
|
+
return atualizado
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.delete("/api/leads/{lead_id}")
|
|
235
|
+
def remover_lead(lead_id: str):
|
|
236
|
+
if not storage.delete_lead(lead_id):
|
|
237
|
+
raise HTTPException(404, "Lead não encontrado.")
|
|
238
|
+
return {"ok": True}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.post("/api/leads/import")
|
|
242
|
+
def importar_leads(dados: LeadsImportIn):
|
|
243
|
+
for lead in dados.leads:
|
|
244
|
+
lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
|
|
245
|
+
return storage.import_leads(dados.leads)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.post("/api/leads/{lead_id}/enrich")
|
|
249
|
+
def enriquecer_lead(lead_id: str):
|
|
250
|
+
resultado = crm_agent.enriquecer_lead(lead_id)
|
|
251
|
+
if not resultado.get("ok"):
|
|
252
|
+
raise HTTPException(404, resultado.get("mensagem", "Lead não encontrado."))
|
|
253
|
+
return resultado
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# CRM (agente)
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
@app.get("/api/crm/sugestoes")
|
|
261
|
+
def sugestoes_crm():
|
|
262
|
+
return crm_agent.sugerir_movimentacoes()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.post("/api/crm/sugestoes/aplicar")
|
|
266
|
+
def aplicar_sugestao(dados: dict):
|
|
267
|
+
lead_id, para = dados.get("lead_id"), dados.get("para")
|
|
268
|
+
if not lead_id or not para:
|
|
269
|
+
raise HTTPException(400, "Informe lead_id e para.")
|
|
270
|
+
atualizado = storage.update_lead(lead_id, {"etapa": _validar_etapa(para)})
|
|
271
|
+
if not atualizado:
|
|
272
|
+
raise HTTPException(404, "Lead não encontrado.")
|
|
273
|
+
agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": para, "via": "agente_crm"})
|
|
274
|
+
return {"ok": True, "lead": atualizado}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Campanhas / Disparos
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
@app.get("/api/campaigns")
|
|
282
|
+
def listar_campanhas():
|
|
283
|
+
return storage.list_campaigns()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.post("/api/campaigns", status_code=201)
|
|
287
|
+
def criar_campanha(campaign_in: CampaignIn):
|
|
288
|
+
campaign = campaign_in.model_dump()
|
|
289
|
+
campaign["id"] = storage._novo_id()
|
|
290
|
+
campaign.update({"status": "rascunho", "total": len(campaign["lead_ids"]), "enviadas": 0, "erros": 0, "eventos": [], "criado_em": storage.now_iso()})
|
|
291
|
+
storage.upsert_campaign(campaign)
|
|
292
|
+
return campaign
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.post("/api/campaigns/{campaign_id}/start")
|
|
296
|
+
def iniciar_campanha(campaign_id: str):
|
|
297
|
+
resultado = agents.iniciar_campanha(campaign_id)
|
|
298
|
+
if not resultado.get("ok"):
|
|
299
|
+
raise HTTPException(400, resultado["mensagem"])
|
|
300
|
+
return resultado
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.post("/api/campaigns/{campaign_id}/pause")
|
|
304
|
+
def pausar_campanha(campaign_id: str):
|
|
305
|
+
return agents.pausar_campanha(campaign_id)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.post("/api/campaigns/{campaign_id}/stop")
|
|
309
|
+
def parar_campanha(campaign_id: str):
|
|
310
|
+
return agents.parar_campanha(campaign_id)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.delete("/api/campaigns/{campaign_id}")
|
|
314
|
+
def remover_campanha(campaign_id: str):
|
|
315
|
+
if not storage.delete_campaign(campaign_id):
|
|
316
|
+
raise HTTPException(404, "Campanha não encontrada.")
|
|
317
|
+
return {"ok": True}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Extratores
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
@app.post("/api/extract/google-maps")
|
|
325
|
+
def extrair_google_maps(dados: GoogleMapsIn):
|
|
326
|
+
try:
|
|
327
|
+
return extractors.extrair_google_maps(dados.palavra_chave, dados.localizacao, dados.max_resultados)
|
|
328
|
+
except extractors.ExtratorNaoConfigurado as e:
|
|
329
|
+
raise HTTPException(400, str(e))
|
|
330
|
+
except extractors.ExtratorErro as e:
|
|
331
|
+
raise HTTPException(502, str(e))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.post("/api/extract/instagram")
|
|
335
|
+
def extrair_instagram(dados: InstagramIn):
|
|
336
|
+
try:
|
|
337
|
+
return extractors.extrair_instagram(dados.profile_url)
|
|
338
|
+
except extractors.ExtratorNaoConfigurado as e:
|
|
339
|
+
raise HTTPException(400, str(e))
|
|
340
|
+
except extractors.ExtratorErro as e:
|
|
341
|
+
raise HTTPException(502, str(e))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.post("/api/extract/cnpj")
|
|
345
|
+
def consultar_cnpj(dados: CnpjIn):
|
|
346
|
+
try:
|
|
347
|
+
return extractors.consulta_cnpj(dados.cnpj)
|
|
348
|
+
except extractors.ExtratorErro as e:
|
|
349
|
+
raise HTTPException(502, str(e))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.post("/api/extract/cnpj/search")
|
|
353
|
+
def buscar_cnpj(dados: CnpjSearchIn):
|
|
354
|
+
try:
|
|
355
|
+
return extractors.buscar_cnpj_cnae(dados.cnae, dados.uf, dados.quantidade)
|
|
356
|
+
except extractors.ExtratorErro as e:
|
|
357
|
+
raise HTTPException(502, str(e))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Configuração e integrações
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
@app.get("/api/config")
|
|
365
|
+
def obter_config():
|
|
366
|
+
return storage.redact_config(storage.get_config())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.put("/api/config")
|
|
370
|
+
def salvar_config(patch: dict):
|
|
371
|
+
storage.update_config(patch)
|
|
372
|
+
return storage.redact_config(storage.get_config())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.post("/api/config/test/llm")
|
|
376
|
+
def testar_llm():
|
|
377
|
+
return llm.testar_conexao()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.post("/api/config/test/integracao")
|
|
381
|
+
def testar_integracao(dados: IntegracaoTestIn):
|
|
382
|
+
import httpx
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
r = httpx.get(dados.url, timeout=4)
|
|
386
|
+
return {"ok": True, "status_http": r.status_code}
|
|
387
|
+
except httpx.HTTPError as e:
|
|
388
|
+
return {"ok": False, "mensagem": f"Sem resposta de {dados.url}: {e}"}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.get("/api/integrations/status")
|
|
392
|
+
def status_integracoes():
|
|
393
|
+
return {
|
|
394
|
+
"llm": llm.configurado(),
|
|
395
|
+
"openoutreach": agents.disparo_configurado(),
|
|
396
|
+
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Agentes
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
@app.get("/api/agents/templates")
|
|
405
|
+
def obter_templates():
|
|
406
|
+
return agents.obter_templates()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.put("/api/agents/templates")
|
|
410
|
+
def salvar_templates(patch: dict):
|
|
411
|
+
return agents.salvar_templates(patch)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@app.post("/api/agents/templates/test")
|
|
415
|
+
def testar_template(dados: TemplateTestIn):
|
|
416
|
+
return agents.testar_template(dados.template, dados.lead_id)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@app.post("/api/agents/attendance/chat")
|
|
420
|
+
def atendimento_chat(dados: ChatIn):
|
|
421
|
+
return agents.atendimento_chat(dados.mensagens)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Webhook
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
@app.get("/api/webhook")
|
|
429
|
+
def obter_webhook():
|
|
430
|
+
return storage.get_config().get("webhook", {})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@app.put("/api/webhook")
|
|
434
|
+
def salvar_webhook(patch: dict):
|
|
435
|
+
config = storage.get_config()
|
|
436
|
+
webhook = config.get("webhook", {})
|
|
437
|
+
webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
|
|
438
|
+
storage.update_config({"webhook": webhook})
|
|
439
|
+
return webhook
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.post("/api/webhook/test")
|
|
443
|
+
def testar_webhook(dados: WebhookTestIn):
|
|
444
|
+
return agents.testar_webhook(dados.url)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# UI estática (modo npx / produção)
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
if FRONTEND_DIST.exists():
|
|
452
|
+
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
|
453
|
+
|
|
454
|
+
@app.get("/", include_in_schema=False)
|
|
455
|
+
def index():
|
|
456
|
+
return FileResponse(FRONTEND_DIST / "index.html")
|
|
457
|
+
|
|
458
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
459
|
+
def spa(full_path: str):
|
|
460
|
+
# Rotas de API inexistentes devolvem 404 JSON (não a página do SPA).
|
|
461
|
+
if full_path.startswith("api/") or full_path == "api":
|
|
462
|
+
raise HTTPException(404, f"Rota /{full_path} não existe.")
|
|
463
|
+
alvo = FRONTEND_DIST / full_path
|
|
464
|
+
if full_path and alvo.is_file():
|
|
465
|
+
return FileResponse(alvo)
|
|
466
|
+
return FileResponse(FRONTEND_DIST / "index.html")
|