@danhachuel/leadmaster 0.1.0 → 0.1.2
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 +52 -0
- package/README.md +15 -24
- package/backend/main.py +26 -62
- package/backend/services/llm.py +5 -125
- package/backend/services/storage.py +48 -68
- package/frontend/dist/assets/index-0xtgAs3t.js +90 -0
- package/frontend/dist/assets/{index-eswbuT3S.css → index-pSbjfRli.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +4 -6
- package/scripts/cli.mjs +8 -17
- package/backend/services/moneyprinter.py +0 -102
- package/backend/services/openmontage.py +0 -62
- package/backend/services/pipeline.py +0 -515
- package/backend/services/short_video_maker.py +0 -72
- package/backend/services/youtube_upload.py +0 -163
- package/frontend/dist/assets/index-CANBU7DH.js +0 -93
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,58 @@ O formato é baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.
|
|
|
6
6
|
A etiqueta visual de versão usa patch de dois dígitos (ex.: `0.1.00`); o registry NPM
|
|
7
7
|
normaliza para a forma canônica (ex.: `0.1.0`).
|
|
8
8
|
|
|
9
|
+
## [0.1.02] — 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Corrigido
|
|
12
|
+
|
|
13
|
+
- **Poluição do terminal por logs de polling** (`GET /api/metrics`):
|
|
14
|
+
- Launcher (`scripts/cli.mjs`) e `npm run dev` iniciam o uvicorn com
|
|
15
|
+
`--no-access-log`.
|
|
16
|
+
- Filtro granular de logging no backend silencia o access-log **apenas** de
|
|
17
|
+
`/api/metrics` — erros, exceções e os demais endpoints continuam logados
|
|
18
|
+
normalmente (vale também para execuções manuais do uvicorn).
|
|
19
|
+
- Painel migrado para **TanStack Query** (`@tanstack/react-query`,
|
|
20
|
+
`refetchInterval` de 10s): ciclo de vida e cleanup automáticos, pausa
|
|
21
|
+
quando a aba fica ociosa e retoma ao focar; indicador "atualiza a cada
|
|
22
|
+
10s" visível nos blocos de gráficos.
|
|
23
|
+
- Polling de campanhas (Disparos) também via TanStack Query: sonda a cada
|
|
24
|
+
4s apenas enquanto existe campanha em andamento, sem `setInterval` manual.
|
|
25
|
+
- Verificado: nenhum outro endpoint é alvo de polling — `/api/config` e
|
|
26
|
+
demais chamadas são carregamentos pontuais no início da página.
|
|
27
|
+
|
|
28
|
+
### Alterado
|
|
29
|
+
|
|
30
|
+
- **O sistema nunca abre o navegador.** Removida a abertura automática de janela do
|
|
31
|
+
launcher — o LeadMaster apenas imprime a URL no terminal e quem abre é o usuário.
|
|
32
|
+
|
|
33
|
+
## [0.1.01] — 2026-10-23
|
|
34
|
+
|
|
35
|
+
### Removido
|
|
36
|
+
|
|
37
|
+
- **Pipeline de vídeos faceless** — o LeadMaster agora é 100% focado em prospecção
|
|
38
|
+
(CRM, extratores de leads e agentes de IA). Removidos:
|
|
39
|
+
- Aba lateral "Pipeline de Vídeos", página e rota `/pipeline`.
|
|
40
|
+
- Integrações com MoneyPrinterTurbo, Short-Video-Maker, OpenMontage e upload para
|
|
41
|
+
YouTube: serviços backend, endpoints `/api/pipeline`, cards e testes em
|
|
42
|
+
Ferramentas, menções no Painel, tutorial do header, FAQ do Suporte e
|
|
43
|
+
diagnóstico.
|
|
44
|
+
- Motor do pipeline, agendador de publicação e contadores diários de upload.
|
|
45
|
+
- `pipeline.json` do armazenamento local — removido automaticamente das
|
|
46
|
+
instalações existentes, junto com as chaves de vídeo do `config.json`
|
|
47
|
+
(`integracoes.moneyprinter`, `short_video_maker`, `openmontage`,
|
|
48
|
+
`youtube_upload`, `uploads`) e o evento de webhook `video.publicado`.
|
|
49
|
+
|
|
50
|
+
### Adicionado
|
|
51
|
+
|
|
52
|
+
- Migração automática de configuração: chaves de vídeo de versões antigas são
|
|
53
|
+
limpas na primeira execução da nova versão.
|
|
54
|
+
- Card "Status da Conexão" do Painel passa a mostrar LLM e OpenOutreach.
|
|
55
|
+
|
|
56
|
+
### Alterado
|
|
57
|
+
|
|
58
|
+
- Backend de desenvolvimento (uvicorn) agora usa a porta **8010** — a 8000 costuma
|
|
59
|
+
ser usada por outros serviços locais (ex.: PandaNews), o que causava conflito.
|
|
60
|
+
|
|
9
61
|
## [0.1.00] — 2026-10-23
|
|
10
62
|
|
|
11
63
|
### Adicionado
|
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# LeadMaster
|
|
2
2
|
|
|
3
|
-
**SaaS local de prospecção
|
|
4
|
-
IA e um pipeline completo de geração de vídeos faceless (YouTube e Shorts) — instalável via
|
|
3
|
+
**SaaS local de prospecção.** CRM, extratores de leads e agentes de IA — instalável via
|
|
5
4
|
`npx`, 100% local, sem nuvem e sem banco de dados externo.
|
|
6
5
|
|
|
7
6
|
  
|
|
@@ -25,26 +24,27 @@ npx --yes @danhachuel/leadmaster@0.1.0
|
|
|
25
24
|
```
|
|
26
25
|
|
|
27
26
|
- `install` prepara a pasta de dados e instala as dependências Python.
|
|
28
|
-
- Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e
|
|
29
|
-
|
|
27
|
+
- Sem argumentos, o launcher sobe o servidor local (FastAPI na porta **5055**) e
|
|
28
|
+
**imprime a URL no terminal** — o LeadMaster **nunca abre janelas**: quem abre o
|
|
29
|
+
navegador é você. Se a porta 5055 estiver ocupada, o LeadMaster sobe
|
|
30
30
|
automaticamente na próxima livre (5056, 5057, ...). Pressione `Ctrl+C` para encerrar.
|
|
31
31
|
- Flags úteis: `--home=<caminho>` (pasta de dados), `--port=<numero>`,
|
|
32
32
|
`doctor` (diagnóstico), `help`.
|
|
33
33
|
|
|
34
34
|
A pasta de dados padrão é `Documents\REPOSITORIO\LeadMaster` (sobrescreva com
|
|
35
|
-
`--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas
|
|
35
|
+
`--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas e
|
|
36
36
|
configuração — ficam em `<home>\storage` como arquivos JSON.
|
|
37
37
|
|
|
38
38
|
### Modo desenvolvimento (a partir do repositório)
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
npm install # dependências da raiz (concurrently)
|
|
42
|
-
npm run dev # sobe backend (uvicorn :
|
|
42
|
+
npm run dev # sobe backend (uvicorn :8010, reload) + frontend (Vite :5055)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
O Vite dev server sobe a UI de desenvolvimento em `http://localhost:5055` (com
|
|
46
46
|
auto-incremento para 5056, 5057... se a porta estiver ocupada) e faz proxy de `/api`
|
|
47
|
-
para `http://127.0.0.1:
|
|
47
|
+
para `http://127.0.0.1:8010` (backend de desenvolvimento).
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
@@ -56,7 +56,6 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
|
|
|
56
56
|
| **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
|
|
57
57
|
| **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. |
|
|
58
58
|
| **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. |
|
|
59
|
-
| **Pipeline de vídeos** | Fluxo de 5 etapas com Iniciar/Pausar/Parar/Repetir: **Roteiro** (7–9k caracteres via LLM) → **Thumbnail/Título** → **Produção Wide 16:9** (MoneyPrinterTurbo/OpenMontage) → **Produção Shorts 9:16** (Short-Video-Maker) → **Upload** (não listado → aguarda 1h → público; até 50/dia, com fallback `youtube-uploader-mcp`). |
|
|
60
59
|
| **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
|
|
61
60
|
| **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
|
|
62
61
|
| **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
|
|
@@ -67,14 +66,9 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
|
|
|
67
66
|
|
|
68
67
|
```
|
|
69
68
|
npx @danhachuel/leadmaster
|
|
70
|
-
└─ scripts/cli.mjs # instala deps Python, sobe uvicorn, abre navegador
|
|
69
|
+
└─ scripts/cli.mjs # instala deps Python, sobe uvicorn, imprime a URL (nunca abre o navegador)
|
|
71
70
|
└─ backend/main.py # FastAPI: API + serving da UI construída (frontend/dist)
|
|
72
|
-
├─ services/storage.py # JSON local (leads, campanhas,
|
|
73
|
-
├─ services/pipeline.py # motor das 5 etapas (threads + checkpoints)
|
|
74
|
-
├─ services/moneyprinter.py # POST localhost:8080/api/v1/videos
|
|
75
|
-
├─ services/short_video_maker.py # POST localhost:3123/api/short-video
|
|
76
|
-
├─ services/openmontage.py # CLI local
|
|
77
|
-
├─ services/youtube_upload.py # upload + agendamento não listado → público
|
|
71
|
+
├─ services/storage.py # JSON local (leads, campanhas, config)
|
|
78
72
|
├─ services/extractors.py # Maps / Instagram / CNPJ
|
|
79
73
|
├─ services/crm_agent.py # sugestões + enriquecimento
|
|
80
74
|
├─ services/agents.py # disparo (OpenOutreach) + atendimento
|
|
@@ -82,17 +76,14 @@ npx @danhachuel/leadmaster
|
|
|
82
76
|
```
|
|
83
77
|
|
|
84
78
|
- **Portas**: LeadMaster UI/API `:5055` (auto-incrementa: 5056, 5057, ... se ocupada) ·
|
|
85
|
-
backend de desenvolvimento `:8000
|
|
86
|
-
- **Degradación graciosa**: se um serviço externo não estiver rodando, a
|
|
87
|
-
mensagem acionável
|
|
79
|
+
backend de desenvolvimento `:8000`.
|
|
80
|
+
- **Degradación graciosa**: se um serviço externo não estiver configurado/rodando, a ação
|
|
81
|
+
falha com mensagem acionável — nada de falsos sucessos.
|
|
88
82
|
|
|
89
83
|
### Serviços externos (opcionais, todos locais)
|
|
90
84
|
|
|
91
85
|
| Serviço | Como usar no LeadMaster |
|
|
92
86
|
|---|---|
|
|
93
|
-
| [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo) | Suba em `http://localhost:8080` e configure em *Ferramentas*. |
|
|
94
|
-
| [Short-Video-Maker](https://github.com/lucasoal/short-video-maker) | Suba em `http://localhost:3123`. |
|
|
95
|
-
| [OpenMontage](https://github.com/openmotional/OpenMontage) | Informe o caminho do CLI em *Ferramentas*. |
|
|
96
87
|
| [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) | Informe o caminho do binário em *Ferramentas → Extratores*. |
|
|
97
88
|
| [lucasoal/igmapper](https://github.com/lucasoal/igmapper) | Informe o caminho em *Ferramentas → Extratores*. |
|
|
98
89
|
| BrasilAPI / OpenCNPJ / Scrapling | Consulta por CNPJ e busca por CNAE/UF (configuráveis). |
|
|
@@ -106,11 +97,11 @@ npx @danhachuel/leadmaster
|
|
|
106
97
|
A interface replica o protótipo `LeadMaster v1.0` (sidebar escura `#1e1b4b`, cards
|
|
107
98
|
brancos, gradientes indigo/purple) e aplica as **7 Leis de Usabilidade**:
|
|
108
99
|
|
|
109
|
-
1. **Fitts** — CTAs grandes ("
|
|
100
|
+
1. **Fitts** — CTAs grandes ("Nova Campanha", "Salvar").
|
|
110
101
|
2. **Hick** — configurações avançadas em accordions.
|
|
111
|
-
3. **Zeigarnik** — barras de progresso (
|
|
102
|
+
3. **Zeigarnik** — barras de progresso (preenchimento de leads e campanhas).
|
|
112
103
|
4. **Jakob** — consistência com o modelo mental do protótipo (menu lateral fixo).
|
|
113
|
-
5. **Gradiente de Meta** — "Faltam 2
|
|
104
|
+
5. **Gradiente de Meta** — "Faltam 2 campos para o lead ficar completo".
|
|
114
105
|
6. **von Restorff** — botão principal com gradiente sobre fundo neutro.
|
|
115
106
|
7. **Miller** — métricas agrupadas em cards (chunking).
|
|
116
107
|
|
package/backend/main.py
CHANGED
|
@@ -5,6 +5,7 @@ modo `npx`, onde um único servidor entrega painel e API na mesma porta.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
9
10
|
import platform
|
|
10
11
|
import sys
|
|
@@ -18,7 +19,7 @@ from fastapi.responses import FileResponse
|
|
|
18
19
|
from fastapi.staticfiles import StaticFiles
|
|
19
20
|
from pydantic import BaseModel, Field
|
|
20
21
|
|
|
21
|
-
from services import agents, crm_agent, extractors, llm,
|
|
22
|
+
from services import agents, crm_agent, extractors, llm, storage
|
|
22
23
|
|
|
23
24
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
24
25
|
FRONTEND_DIST = REPO_ROOT / "frontend" / "dist"
|
|
@@ -35,14 +36,31 @@ def _versao() -> str:
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
VERSION = _versao()
|
|
38
|
-
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Logs de acesso
|
|
43
|
+
#
|
|
44
|
+
# O endpoint /api/metrics é consultado por polling do Painel e inundava o
|
|
45
|
+
# terminal. O filtro abaixo silencia APENAS esse endpoint no access-log —
|
|
46
|
+
# erros, exceções e os demais endpoints continuam sendo logados normalmente.
|
|
47
|
+
# (O launcher e o "npm run dev" também passam --no-access-log ao uvicorn.)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
class _FiltroAcessoPolling(logging.Filter):
|
|
51
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
return "/api/metrics" not in record.getMessage()
|
|
54
|
+
except Exception:
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
logging.getLogger("uvicorn.access").addFilter(_FiltroAcessoPolling())
|
|
39
59
|
|
|
40
60
|
|
|
41
61
|
@asynccontextmanager
|
|
42
62
|
async def lifespan(_app: FastAPI):
|
|
43
63
|
storage.ensure_seeds()
|
|
44
|
-
engine.recuperar()
|
|
45
|
-
engine.iniciar_agendador()
|
|
46
64
|
yield
|
|
47
65
|
|
|
48
66
|
|
|
@@ -89,19 +107,6 @@ class CampaignIn(BaseModel):
|
|
|
89
107
|
lead_ids: list[str] = []
|
|
90
108
|
|
|
91
109
|
|
|
92
|
-
class JobIn(BaseModel):
|
|
93
|
-
tema: str = Field(min_length=1)
|
|
94
|
-
titulo: str = ""
|
|
95
|
-
tom: str = "informativo"
|
|
96
|
-
duracao_min: int = 8
|
|
97
|
-
keyword_seo: str = ""
|
|
98
|
-
formatos: list[str] = ["wide", "shorts"]
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
class AcaoIn(BaseModel):
|
|
102
|
-
acao: str
|
|
103
|
-
|
|
104
|
-
|
|
105
110
|
class GoogleMapsIn(BaseModel):
|
|
106
111
|
palavra_chave: str = Field(min_length=2)
|
|
107
112
|
localizacao: str = Field(min_length=2)
|
|
@@ -156,12 +161,9 @@ def health():
|
|
|
156
161
|
@app.get("/api/doctor")
|
|
157
162
|
def doctor():
|
|
158
163
|
integracoes = {
|
|
159
|
-
"moneyprinter": moneyprinter.verificar(),
|
|
160
|
-
"short_video_maker": short_video_maker.verificar(),
|
|
161
|
-
"uploader_youtube": youtube_upload.verificar(),
|
|
162
164
|
"llm_configurado": llm.configurado(),
|
|
163
|
-
"openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
|
|
164
165
|
"openoutreach": agents.disparo_configurado(),
|
|
166
|
+
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
165
167
|
}
|
|
166
168
|
return {
|
|
167
169
|
"ok": True,
|
|
@@ -170,7 +172,6 @@ def doctor():
|
|
|
170
172
|
"sistema": platform.platform(),
|
|
171
173
|
"dist_frontend": FRONTEND_DIST.exists(),
|
|
172
174
|
"storage": str(storage.storage_dir()),
|
|
173
|
-
"uploads_hoje": youtube_upload.contagem_hoje(),
|
|
174
175
|
"integracoes": integracoes,
|
|
175
176
|
}
|
|
176
177
|
|
|
@@ -192,14 +193,10 @@ def metrics():
|
|
|
192
193
|
enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
|
|
193
194
|
erros = sum(int(c.get("erros", 0)) for c in campaigns)
|
|
194
195
|
total_envios = enviadas + erros
|
|
195
|
-
jobs = storage.list_jobs()
|
|
196
196
|
|
|
197
197
|
return {
|
|
198
198
|
"conexao": {
|
|
199
199
|
"geral": True, # o backend local responde => conectado
|
|
200
|
-
"moneyprinter": moneyprinter.verificar(),
|
|
201
|
-
"short_video_maker": short_video_maker.verificar(),
|
|
202
|
-
"uploader_youtube": youtube_upload.verificar(),
|
|
203
200
|
"llm": llm.configurado(),
|
|
204
201
|
"openoutreach": agents.disparo_configurado(),
|
|
205
202
|
},
|
|
@@ -213,10 +210,6 @@ def metrics():
|
|
|
213
210
|
for etapa in ("leads", "em_contato", "proposta", "fechado")
|
|
214
211
|
},
|
|
215
212
|
"campanhas": {"enviadas": enviadas, "erros": erros, "total": len(campaigns)},
|
|
216
|
-
"pipeline": {
|
|
217
|
-
"ativos": sum(1 for j in jobs if j.get("status") in ("em_andamento", "pausado")),
|
|
218
|
-
"concluidos": sum(1 for j in jobs if j.get("status") == "concluido"),
|
|
219
|
-
},
|
|
220
213
|
"perfil": storage.get_config().get("perfil", {}),
|
|
221
214
|
}
|
|
222
215
|
|
|
@@ -345,34 +338,6 @@ def remover_campanha(campaign_id: str):
|
|
|
345
338
|
return {"ok": True}
|
|
346
339
|
|
|
347
340
|
|
|
348
|
-
# ---------------------------------------------------------------------------
|
|
349
|
-
# Pipeline de vídeos
|
|
350
|
-
# ---------------------------------------------------------------------------
|
|
351
|
-
|
|
352
|
-
@app.get("/api/pipeline")
|
|
353
|
-
def listar_pipeline():
|
|
354
|
-
return engine.listar()
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
@app.post("/api/pipeline", status_code=201)
|
|
358
|
-
def criar_job(job_in: JobIn):
|
|
359
|
-
try:
|
|
360
|
-
return engine.criar_job(job_in.model_dump())
|
|
361
|
-
except ValueError as e:
|
|
362
|
-
raise HTTPException(400, str(e))
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
@app.post("/api/pipeline/{job_id}/actions")
|
|
366
|
-
def agir_pipeline(job_id: str, acao_in: AcaoIn):
|
|
367
|
-
try:
|
|
368
|
-
resultado = engine.agir(job_id, acao_in.acao)
|
|
369
|
-
except ValueError as e:
|
|
370
|
-
raise HTTPException(400, str(e))
|
|
371
|
-
if not resultado.get("ok"):
|
|
372
|
-
raise HTTPException(400, resultado.get("mensagem", "Ação não permitida."))
|
|
373
|
-
return resultado
|
|
374
|
-
|
|
375
|
-
|
|
376
341
|
# ---------------------------------------------------------------------------
|
|
377
342
|
# Extratores
|
|
378
343
|
# ---------------------------------------------------------------------------
|
|
@@ -447,11 +412,7 @@ def testar_integracao(dados: IntegracaoTestIn):
|
|
|
447
412
|
@app.get("/api/integrations/status")
|
|
448
413
|
def status_integracoes():
|
|
449
414
|
return {
|
|
450
|
-
"moneyprinter": moneyprinter.verificar(),
|
|
451
|
-
"short_video_maker": short_video_maker.verificar(),
|
|
452
|
-
"uploader_youtube": youtube_upload.verificar(),
|
|
453
415
|
"llm": llm.configurado(),
|
|
454
|
-
"openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
|
|
455
416
|
"openoutreach": agents.disparo_configurado(),
|
|
456
417
|
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
457
418
|
}
|
|
@@ -517,6 +478,9 @@ if FRONTEND_DIST.exists():
|
|
|
517
478
|
|
|
518
479
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
519
480
|
def spa(full_path: str):
|
|
481
|
+
# Rotas de API inexistentes devolvem 404 JSON (não a página do SPA).
|
|
482
|
+
if full_path.startswith("api/") or full_path == "api":
|
|
483
|
+
raise HTTPException(404, f"Rota /{full_path} não existe.")
|
|
520
484
|
alvo = FRONTEND_DIST / full_path
|
|
521
485
|
if full_path and alvo.is_file():
|
|
522
486
|
return FileResponse(alvo)
|
package/backend/services/llm.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""Cliente LLM (API OpenAI-compatível)
|
|
1
|
+
"""Cliente LLM (API OpenAI-compatível) para os agentes do LeadMaster.
|
|
2
2
|
|
|
3
3
|
A configuração fica em `config.json → llm` (provedor, base_url, api_key, modelo).
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
uma
|
|
4
|
+
Usado pelo agente de CRM (enriquecimento/sugestões) e pelo agente de atendimento.
|
|
5
|
+
Se o LLM não estiver configurado, as heurísticas locais assumem e a mensagem
|
|
6
|
+
sempre informa o que fazer — nunca fingimos uma resposta de IA.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
@@ -28,7 +28,7 @@ def configurado() -> bool:
|
|
|
28
28
|
return bool(c.get("api_key") and c.get("base_url"))
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def chat(mensagens: list, temperature: float = 0.7, max_tokens: int =
|
|
31
|
+
def chat(mensagens: list, temperature: float = 0.7, max_tokens: int = 2000) -> str:
|
|
32
32
|
c = _conf()
|
|
33
33
|
if not configurado():
|
|
34
34
|
raise LLMNaoConfigurado(
|
|
@@ -67,123 +67,3 @@ def testar_conexao() -> dict:
|
|
|
67
67
|
return {"ok": False, "mensagem": str(e)}
|
|
68
68
|
except LLMErro as e:
|
|
69
69
|
return {"ok": False, "mensagem": str(e)}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# ---------------------------------------------------------------------------
|
|
73
|
-
# Roteiro (7k–9k caracteres)
|
|
74
|
-
# ---------------------------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
def gerar_roteiro(tema: str, titulo: str = "", tom: str = "informativo", duracao_min: int = 8) -> dict:
|
|
77
|
-
"""Gera o roteiro do vídeo faceless. Retorna {roteiro, origem, aviso}."""
|
|
78
|
-
if configurado():
|
|
79
|
-
sistema = (
|
|
80
|
-
"Você é um roteirista profissional de vídeos faceless para YouTube, "
|
|
81
|
-
"especialista em retenção. Escreva em português do Brasil."
|
|
82
|
-
)
|
|
83
|
-
usuario = (
|
|
84
|
-
f"Escreva um roteiro completo para um vídeo faceless de YouTube sobre: {tema}.\n"
|
|
85
|
-
f"Título de trabalho: {titulo or '(crie um título)'}\n"
|
|
86
|
-
f"Tom: {tom}. Duração estimada: {duracao_min} minutos.\n"
|
|
87
|
-
"REQUISITOS:\n"
|
|
88
|
-
"- Entre 7000 e 9000 caracteres de texto corrido (sem marcadores de cena).\n"
|
|
89
|
-
"- Comece com um gancho de 2 frases que prende na primeira.\n"
|
|
90
|
-
"- Use seções lógicas, exemplos concretos e números.\n"
|
|
91
|
-
"- Termine com CTA para inscrever-se no canal.\n"
|
|
92
|
-
"- Apenas o texto do roteiro, sem títulos em markdown."
|
|
93
|
-
)
|
|
94
|
-
try:
|
|
95
|
-
texto = chat(
|
|
96
|
-
[{"role": "system", "content": sistema}, {"role": "user", "content": usuario}],
|
|
97
|
-
temperature=0.8,
|
|
98
|
-
max_tokens=6000,
|
|
99
|
-
)
|
|
100
|
-
return {"roteiro": texto.strip(), "origem": "llm", "aviso": ""}
|
|
101
|
-
except LLMErro as e:
|
|
102
|
-
raise # configurado mas falhou: etapa deve falhar com mensagem acionável
|
|
103
|
-
return {
|
|
104
|
-
"roteiro": _roteiro_local(tema, titulo, tom, duracao_min),
|
|
105
|
-
"origem": "local",
|
|
106
|
-
"aviso": "LLM não configurado — roteiro gerado por modelo local (rascunho). Configure o LLM em Ferramentas para roteiros completos de 7k–9k caracteres.",
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _roteiro_local(tema: str, titulo: str, tom: str, duracao_min: int) -> str:
|
|
111
|
-
"""Rascunho estruturado, honesto sobre sua origem local."""
|
|
112
|
-
titulo = titulo or f"{tema}: o guia definitivo"
|
|
113
|
-
blocos = max(3, duracao_min)
|
|
114
|
-
linhas = [
|
|
115
|
-
f"[GANCHO] {titulo}. Nos próximos {duracao_min} minutos você vai entender exatamente "
|
|
116
|
-
f"como {tema.lower()} funciona na prática — e por que quase todo mundo erra nisso. Fica até o final.",
|
|
117
|
-
"",
|
|
118
|
-
"[INTRODUÇÃO]",
|
|
119
|
-
f"Antes de qualquer coisa: o que é {tema.lower()}? É a diferença entre agir no escuro "
|
|
120
|
-
"e agir com método. Neste vídeo eu vou te mostrar o passo a passo completo.",
|
|
121
|
-
"",
|
|
122
|
-
]
|
|
123
|
-
for i in range(1, blocos + 1):
|
|
124
|
-
linhas += [
|
|
125
|
-
f"[PARTE {i} — PONTO-CHAVE {i}]",
|
|
126
|
-
f"Primeiro, o contexto: a maior parte das pessoas que tenta {tema.lower()} falha porque "
|
|
127
|
-
"pula a etapa de preparação. Não faça isso. Comece pelo diagnóstico simples: onde você "
|
|
128
|
-
"está hoje e qual é o resultado mínimo que já mudaria seu jogo.",
|
|
129
|
-
f"Segundo, a execução: escolha UMA estratégia de {tema.lower()} e execute-a por 14 dias "
|
|
130
|
-
"sem mudar de rota. Registre os números. O que não é medido não melhora.",
|
|
131
|
-
f"Terceiro, o erro comum da parte {i}: fazer mais do mesmo esperando resultado diferente. "
|
|
132
|
-
"Ajuste uma variável por vez e observe.",
|
|
133
|
-
"",
|
|
134
|
-
]
|
|
135
|
-
linhas += [
|
|
136
|
-
"[ENCERRAMENTO E CTA]",
|
|
137
|
-
"Se esse vídeo te ajudou, se inscreve no canal e ativa o sininho — toda semana tem "
|
|
138
|
-
f"conteúdo novo sobre {tema.lower()}. Comenta aqui qual etapa você vai aplicar primeiro. Até o próximo!",
|
|
139
|
-
"",
|
|
140
|
-
"(Rascunho gerado localmente pelo LeadMaster, sem LLM. Configure seu LLM em "
|
|
141
|
-
"Ferramentas para gerar roteiros completos de 7.000 a 9.000 caracteres.)",
|
|
142
|
-
]
|
|
143
|
-
return "\n".join(linhas)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def gerar_titulo_e_thumbnail(tema: str, roteiro: str) -> dict:
|
|
147
|
-
"""Gera título otimizado + prompt de thumbnail. Retorna {titulo, titulo_alternativo, thumbnail_prompt, origem}."""
|
|
148
|
-
if configurado():
|
|
149
|
-
try:
|
|
150
|
-
resposta = chat(
|
|
151
|
-
[
|
|
152
|
-
{
|
|
153
|
-
"role": "system",
|
|
154
|
-
"content": (
|
|
155
|
-
"Você é especialista em SEO de YouTube. Responda APENAS com JSON válido, "
|
|
156
|
-
'sem markdown, no formato: {"titulo": "...", "titulo_alternativo": "...", '
|
|
157
|
-
'"thumbnail_prompt": "..."}. Títulos com até 60 caracteres, thumbnail_prompt '
|
|
158
|
-
"em português, descrevendo imagem sem texto legível."
|
|
159
|
-
),
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
"role": "user",
|
|
163
|
-
"content": f"Tema do vídeo: {tema}\n\nRoteiro (início):\n{roteiro[:1500]}",
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
temperature=0.6,
|
|
167
|
-
max_tokens=500,
|
|
168
|
-
)
|
|
169
|
-
import json as _json
|
|
170
|
-
|
|
171
|
-
inicio = resposta.find("{")
|
|
172
|
-
dados = _json.loads(resposta[inicio:] if inicio >= 0 else resposta)
|
|
173
|
-
return {
|
|
174
|
-
"titulo": dados.get("titulo", ""),
|
|
175
|
-
"titulo_alternativo": dados.get("titulo_alternativo", ""),
|
|
176
|
-
"thumbnail_prompt": dados.get("thumbnail_prompt", ""),
|
|
177
|
-
"origem": "llm",
|
|
178
|
-
}
|
|
179
|
-
except (LLMErro, ValueError, KeyError):
|
|
180
|
-
pass # cai no fallback local abaixo
|
|
181
|
-
return {
|
|
182
|
-
"titulo": f"{tema}: o guia definitivo (passo a passo)",
|
|
183
|
-
"titulo_alternativo": f"Como dominar {tema} em pouco tempo",
|
|
184
|
-
"thumbnail_prompt": (
|
|
185
|
-
f"Imagem 16:9 vibrante sobre {tema}, estilo minimalista de alto contraste, "
|
|
186
|
-
"pessoa em silhueta olhando para resultado crescente, cores indigo e roxo, sem texto."
|
|
187
|
-
),
|
|
188
|
-
"origem": "local",
|
|
189
|
-
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Toda a escrita é atômica (arquivo temporário + replace) e protegida por um lock
|
|
4
4
|
global, para permitir o uso concorrente pelas rotas da API e pelos workers em
|
|
5
|
-
background (
|
|
5
|
+
background (campanhas de disparo).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
@@ -46,18 +46,6 @@ def storage_dir() -> Path:
|
|
|
46
46
|
return d
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def roteiros_dir() -> Path:
|
|
50
|
-
d = get_home() / "roteiros"
|
|
51
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
52
|
-
return d
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def videos_dir() -> Path:
|
|
56
|
-
d = get_home() / "videos"
|
|
57
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
58
|
-
return d
|
|
59
|
-
|
|
60
|
-
|
|
61
49
|
def now_iso() -> str:
|
|
62
50
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
63
51
|
|
|
@@ -116,20 +104,7 @@ DEFAULT_CONFIG = {
|
|
|
116
104
|
"modelo": "gpt-4o-mini",
|
|
117
105
|
},
|
|
118
106
|
"integracoes": {
|
|
119
|
-
"
|
|
120
|
-
"short_video_maker": {"url": "http://localhost:3123"},
|
|
121
|
-
"openmontage": {"cli_path": ""},
|
|
122
|
-
"youtube_upload": {
|
|
123
|
-
"url": "",
|
|
124
|
-
"api_key": "",
|
|
125
|
-
"upload_path": "/api/upload",
|
|
126
|
-
"publicar_path": "/api/videos/{video_id}/publicar",
|
|
127
|
-
"modo": "nao_listado_1h_publico",
|
|
128
|
-
"limite_diario": 50,
|
|
129
|
-
"espera_publicacao_horas": 1,
|
|
130
|
-
"uploader_mcp_path": "",
|
|
131
|
-
},
|
|
132
|
-
"openoutreach": {"url": "", "send_path": "/api/send"},
|
|
107
|
+
"openoutreach": {"url": "", "enabled": False, "send_path": "/api/send"},
|
|
133
108
|
"pipecat": {"url": ""},
|
|
134
109
|
},
|
|
135
110
|
"extratores": {
|
|
@@ -147,7 +122,7 @@ DEFAULT_CONFIG = {
|
|
|
147
122
|
"templates": {
|
|
148
123
|
"texto": (
|
|
149
124
|
"Olá {{primeiro_nome}}! Sou da {{minha_empresa}} e ajudo empresas como "
|
|
150
|
-
"{{empresa}} a crescerem com
|
|
125
|
+
"{{empresa}} a crescerem com prospecção automatizada. "
|
|
151
126
|
"Posso te enviar uma proposta rápida?"
|
|
152
127
|
),
|
|
153
128
|
"audio": "Olá {{primeiro_nome}}, tudo bem? Falo em nome da {{minha_empresa}}...",
|
|
@@ -159,11 +134,20 @@ DEFAULT_CONFIG = {
|
|
|
159
134
|
},
|
|
160
135
|
"webhook": {
|
|
161
136
|
"url": "",
|
|
162
|
-
"eventos": ["lead.criado", "lead.etapa", "campanha.concluida"
|
|
137
|
+
"eventos": ["lead.criado", "lead.etapa", "campanha.concluida"],
|
|
163
138
|
},
|
|
164
|
-
"uploads": {"data": "", "contador": 0},
|
|
165
139
|
}
|
|
166
140
|
|
|
141
|
+
# Chaves de versões antigas (o pipeline de vídeos foi retirado do produto) —
|
|
142
|
+
# são removidas automaticamente da configuração local na primeira leitura.
|
|
143
|
+
_CAMINHOS_REMOVIDOS = [
|
|
144
|
+
("integracoes", "moneyprinter"),
|
|
145
|
+
("integracoes", "short_video_maker"),
|
|
146
|
+
("integracoes", "openmontage"),
|
|
147
|
+
("integracoes", "youtube_upload"),
|
|
148
|
+
("uploads",),
|
|
149
|
+
]
|
|
150
|
+
|
|
167
151
|
|
|
168
152
|
def _deep_merge(base: dict, override: dict) -> dict:
|
|
169
153
|
out = dict(base)
|
|
@@ -188,8 +172,41 @@ def _deep_merge_preserve_secrets(base: dict, override: dict) -> dict:
|
|
|
188
172
|
return out
|
|
189
173
|
|
|
190
174
|
|
|
175
|
+
def _limpar_deprecated(config: dict) -> dict:
|
|
176
|
+
"""Migração: remove chaves de versões antigas (pipeline de vídeos)."""
|
|
177
|
+
mudou = False
|
|
178
|
+
for caminho in _CAMINHOS_REMOVIDOS:
|
|
179
|
+
secao = config
|
|
180
|
+
for chave in caminho[:-1]:
|
|
181
|
+
secao = secao.get(chave) if isinstance(secao, dict) else None
|
|
182
|
+
if not isinstance(secao, dict):
|
|
183
|
+
break
|
|
184
|
+
else:
|
|
185
|
+
ultimo = caminho[-1]
|
|
186
|
+
if isinstance(secao, dict) and ultimo in secao:
|
|
187
|
+
secao.pop(ultimo)
|
|
188
|
+
mudou = True
|
|
189
|
+
# Evento de webhook do recurso removido.
|
|
190
|
+
webhook = config.get("webhook")
|
|
191
|
+
if isinstance(webhook, dict) and isinstance(webhook.get("eventos"), list):
|
|
192
|
+
eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
|
|
193
|
+
if eventos != webhook["eventos"]:
|
|
194
|
+
webhook["eventos"] = eventos
|
|
195
|
+
mudou = True
|
|
196
|
+
# Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
|
|
197
|
+
pipeline_path = storage_dir() / PIPELINE_FILE
|
|
198
|
+
if pipeline_path.exists():
|
|
199
|
+
try:
|
|
200
|
+
pipeline_path.unlink()
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
if mudou:
|
|
204
|
+
_write(CONFIG_FILE, config)
|
|
205
|
+
return config
|
|
206
|
+
|
|
207
|
+
|
|
191
208
|
def get_config() -> dict:
|
|
192
|
-
return _deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {}))
|
|
209
|
+
return _limpar_deprecated(_deep_merge(DEFAULT_CONFIG, _read(CONFIG_FILE, {})))
|
|
193
210
|
|
|
194
211
|
|
|
195
212
|
def update_config(patch: dict) -> dict:
|
|
@@ -332,41 +349,6 @@ def delete_campaign(campaign_id: str) -> bool:
|
|
|
332
349
|
return True
|
|
333
350
|
|
|
334
351
|
|
|
335
|
-
# ---------------------------------------------------------------------------
|
|
336
|
-
# Pipeline
|
|
337
|
-
# ---------------------------------------------------------------------------
|
|
338
|
-
|
|
339
|
-
def list_jobs() -> list:
|
|
340
|
-
data = _read(PIPELINE_FILE, {"jobs": []})
|
|
341
|
-
if isinstance(data, list): # tolerância a formato antigo
|
|
342
|
-
data = {"jobs": data}
|
|
343
|
-
return data.get("jobs", [])
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def save_jobs(jobs: list) -> None:
|
|
347
|
-
_write(PIPELINE_FILE, {"jobs": jobs})
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def get_job(job_id: str) -> dict | None:
|
|
351
|
-
for j in list_jobs():
|
|
352
|
-
if j.get("id") == job_id:
|
|
353
|
-
return j
|
|
354
|
-
return None
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def upsert_job(job: dict) -> dict:
|
|
358
|
-
with LOCK:
|
|
359
|
-
jobs = list_jobs()
|
|
360
|
-
for i, j in enumerate(jobs):
|
|
361
|
-
if j.get("id") == job["id"]:
|
|
362
|
-
jobs[i] = job
|
|
363
|
-
break
|
|
364
|
-
else:
|
|
365
|
-
jobs.append(job)
|
|
366
|
-
save_jobs(jobs)
|
|
367
|
-
return job
|
|
368
|
-
|
|
369
|
-
|
|
370
352
|
# ---------------------------------------------------------------------------
|
|
371
353
|
# Seeds (primeira execução)
|
|
372
354
|
# ---------------------------------------------------------------------------
|
|
@@ -446,8 +428,6 @@ def ensure_seeds() -> None:
|
|
|
446
428
|
_write(LEADS_FILE, _seed_leads())
|
|
447
429
|
if not (d / CAMPAIGNS_FILE).exists():
|
|
448
430
|
_write(CAMPAIGNS_FILE, _seed_campaigns())
|
|
449
|
-
if not (d / PIPELINE_FILE).exists():
|
|
450
|
-
_write(PIPELINE_FILE, {"jobs": []})
|
|
451
431
|
if not (d / CONFIG_FILE).exists():
|
|
452
432
|
_write(CONFIG_FILE, DEFAULT_CONFIG)
|
|
453
433
|
|