@danhachuel/leadmaster 0.1.0 → 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 +28 -0
- package/README.md +11 -21
- package/backend/main.py +5 -62
- package/backend/services/llm.py +5 -125
- package/backend/services/storage.py +48 -68
- package/frontend/dist/assets/{index-eswbuT3S.css → index-CFQHp6yf.css} +1 -1
- package/frontend/dist/assets/index-CPq53JWx.js +90 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +4 -6
- package/scripts/cli.mjs +1 -1
- 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,34 @@ 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.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
|
+
|
|
9
37
|
## [0.1.00] — 2026-10-23
|
|
10
38
|
|
|
11
39
|
### 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
|
  
|
|
@@ -32,19 +31,19 @@ npx --yes @danhachuel/leadmaster@0.1.0
|
|
|
32
31
|
`doctor` (diagnóstico), `help`.
|
|
33
32
|
|
|
34
33
|
A pasta de dados padrão é `Documents\REPOSITORIO\LeadMaster` (sobrescreva com
|
|
35
|
-
`--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas
|
|
34
|
+
`--home` ou `LEADMASTER_HOME`). Todos os dados — leads, campanhas e
|
|
36
35
|
configuração — ficam em `<home>\storage` como arquivos JSON.
|
|
37
36
|
|
|
38
37
|
### Modo desenvolvimento (a partir do repositório)
|
|
39
38
|
|
|
40
39
|
```bash
|
|
41
40
|
npm install # dependências da raiz (concurrently)
|
|
42
|
-
npm run dev # sobe backend (uvicorn :
|
|
41
|
+
npm run dev # sobe backend (uvicorn :8010, reload) + frontend (Vite :5055)
|
|
43
42
|
```
|
|
44
43
|
|
|
45
44
|
O Vite dev server sobe a UI de desenvolvimento em `http://localhost:5055` (com
|
|
46
45
|
auto-incremento para 5056, 5057... se a porta estiver ocupada) e faz proxy de `/api`
|
|
47
|
-
para `http://127.0.0.1:
|
|
46
|
+
para `http://127.0.0.1:8010` (backend de desenvolvimento).
|
|
48
47
|
|
|
49
48
|
---
|
|
50
49
|
|
|
@@ -56,7 +55,6 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
|
|
|
56
55
|
| **Leads** | CRUD completo, importação em massa a partir dos extratores, barra de progresso de preenchimento, enriquecimento por IA. |
|
|
57
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. |
|
|
58
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. |
|
|
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
58
|
| **Disparos** | Campanhas de disparo (texto/áudio) com templates configuráveis e execução via `eracle/OpenOutreach`. |
|
|
61
59
|
| **Agentes** | Agente de disparo (prompts/templates) e agente de atendimento (texto via LLM ou voz via `pipecat-ai/pipecat`). |
|
|
62
60
|
| **Ferramentas** | Chaves de API, URLs das integrações locais, testes de conexão — tudo salvo em `config.json` local. |
|
|
@@ -69,12 +67,7 @@ para `http://127.0.0.1:8000` (backend de desenvolvimento).
|
|
|
69
67
|
npx @danhachuel/leadmaster
|
|
70
68
|
└─ scripts/cli.mjs # instala deps Python, sobe uvicorn, abre navegador
|
|
71
69
|
└─ 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
|
|
70
|
+
├─ services/storage.py # JSON local (leads, campanhas, config)
|
|
78
71
|
├─ services/extractors.py # Maps / Instagram / CNPJ
|
|
79
72
|
├─ services/crm_agent.py # sugestões + enriquecimento
|
|
80
73
|
├─ services/agents.py # disparo (OpenOutreach) + atendimento
|
|
@@ -82,17 +75,14 @@ npx @danhachuel/leadmaster
|
|
|
82
75
|
```
|
|
83
76
|
|
|
84
77
|
- **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
|
|
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.
|
|
88
81
|
|
|
89
82
|
### Serviços externos (opcionais, todos locais)
|
|
90
83
|
|
|
91
84
|
| Serviço | Como usar no LeadMaster |
|
|
92
85
|
|---|---|
|
|
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
86
|
| [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scraper) | Informe o caminho do binário em *Ferramentas → Extratores*. |
|
|
97
87
|
| [lucasoal/igmapper](https://github.com/lucasoal/igmapper) | Informe o caminho em *Ferramentas → Extratores*. |
|
|
98
88
|
| BrasilAPI / OpenCNPJ / Scrapling | Consulta por CNPJ e busca por CNAE/UF (configuráveis). |
|
|
@@ -106,11 +96,11 @@ npx @danhachuel/leadmaster
|
|
|
106
96
|
A interface replica o protótipo `LeadMaster v1.0` (sidebar escura `#1e1b4b`, cards
|
|
107
97
|
brancos, gradientes indigo/purple) e aplica as **7 Leis de Usabilidade**:
|
|
108
98
|
|
|
109
|
-
1. **Fitts** — CTAs grandes ("
|
|
99
|
+
1. **Fitts** — CTAs grandes ("Nova Campanha", "Salvar").
|
|
110
100
|
2. **Hick** — configurações avançadas em accordions.
|
|
111
|
-
3. **Zeigarnik** — barras de progresso (
|
|
101
|
+
3. **Zeigarnik** — barras de progresso (preenchimento de leads e campanhas).
|
|
112
102
|
4. **Jakob** — consistência com o modelo mental do protótipo (menu lateral fixo).
|
|
113
|
-
5. **Gradiente de Meta** — "Faltam 2
|
|
103
|
+
5. **Gradiente de Meta** — "Faltam 2 campos para o lead ficar completo".
|
|
114
104
|
6. **von Restorff** — botão principal com gradiente sobre fundo neutro.
|
|
115
105
|
7. **Miller** — métricas agrupadas em cards (chunking).
|
|
116
106
|
|
package/backend/main.py
CHANGED
|
@@ -18,7 +18,7 @@ from fastapi.responses import FileResponse
|
|
|
18
18
|
from fastapi.staticfiles import StaticFiles
|
|
19
19
|
from pydantic import BaseModel, Field
|
|
20
20
|
|
|
21
|
-
from services import agents, crm_agent, extractors, llm,
|
|
21
|
+
from services import agents, crm_agent, extractors, llm, storage
|
|
22
22
|
|
|
23
23
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
24
24
|
FRONTEND_DIST = REPO_ROOT / "frontend" / "dist"
|
|
@@ -35,14 +35,11 @@ def _versao() -> str:
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
VERSION = _versao()
|
|
38
|
-
engine = pipeline.PipelineEngine()
|
|
39
38
|
|
|
40
39
|
|
|
41
40
|
@asynccontextmanager
|
|
42
41
|
async def lifespan(_app: FastAPI):
|
|
43
42
|
storage.ensure_seeds()
|
|
44
|
-
engine.recuperar()
|
|
45
|
-
engine.iniciar_agendador()
|
|
46
43
|
yield
|
|
47
44
|
|
|
48
45
|
|
|
@@ -89,19 +86,6 @@ class CampaignIn(BaseModel):
|
|
|
89
86
|
lead_ids: list[str] = []
|
|
90
87
|
|
|
91
88
|
|
|
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
89
|
class GoogleMapsIn(BaseModel):
|
|
106
90
|
palavra_chave: str = Field(min_length=2)
|
|
107
91
|
localizacao: str = Field(min_length=2)
|
|
@@ -156,12 +140,9 @@ def health():
|
|
|
156
140
|
@app.get("/api/doctor")
|
|
157
141
|
def doctor():
|
|
158
142
|
integracoes = {
|
|
159
|
-
"moneyprinter": moneyprinter.verificar(),
|
|
160
|
-
"short_video_maker": short_video_maker.verificar(),
|
|
161
|
-
"uploader_youtube": youtube_upload.verificar(),
|
|
162
143
|
"llm_configurado": llm.configurado(),
|
|
163
|
-
"openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
|
|
164
144
|
"openoutreach": agents.disparo_configurado(),
|
|
145
|
+
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
165
146
|
}
|
|
166
147
|
return {
|
|
167
148
|
"ok": True,
|
|
@@ -170,7 +151,6 @@ def doctor():
|
|
|
170
151
|
"sistema": platform.platform(),
|
|
171
152
|
"dist_frontend": FRONTEND_DIST.exists(),
|
|
172
153
|
"storage": str(storage.storage_dir()),
|
|
173
|
-
"uploads_hoje": youtube_upload.contagem_hoje(),
|
|
174
154
|
"integracoes": integracoes,
|
|
175
155
|
}
|
|
176
156
|
|
|
@@ -192,14 +172,10 @@ def metrics():
|
|
|
192
172
|
enviadas = sum(int(c.get("enviadas", 0)) for c in campaigns)
|
|
193
173
|
erros = sum(int(c.get("erros", 0)) for c in campaigns)
|
|
194
174
|
total_envios = enviadas + erros
|
|
195
|
-
jobs = storage.list_jobs()
|
|
196
175
|
|
|
197
176
|
return {
|
|
198
177
|
"conexao": {
|
|
199
178
|
"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
179
|
"llm": llm.configurado(),
|
|
204
180
|
"openoutreach": agents.disparo_configurado(),
|
|
205
181
|
},
|
|
@@ -213,10 +189,6 @@ def metrics():
|
|
|
213
189
|
for etapa in ("leads", "em_contato", "proposta", "fechado")
|
|
214
190
|
},
|
|
215
191
|
"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
192
|
"perfil": storage.get_config().get("perfil", {}),
|
|
221
193
|
}
|
|
222
194
|
|
|
@@ -345,34 +317,6 @@ def remover_campanha(campaign_id: str):
|
|
|
345
317
|
return {"ok": True}
|
|
346
318
|
|
|
347
319
|
|
|
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
320
|
# ---------------------------------------------------------------------------
|
|
377
321
|
# Extratores
|
|
378
322
|
# ---------------------------------------------------------------------------
|
|
@@ -447,11 +391,7 @@ def testar_integracao(dados: IntegracaoTestIn):
|
|
|
447
391
|
@app.get("/api/integrations/status")
|
|
448
392
|
def status_integracoes():
|
|
449
393
|
return {
|
|
450
|
-
"moneyprinter": moneyprinter.verificar(),
|
|
451
|
-
"short_video_maker": short_video_maker.verificar(),
|
|
452
|
-
"uploader_youtube": youtube_upload.verificar(),
|
|
453
394
|
"llm": llm.configurado(),
|
|
454
|
-
"openmontage": bool(storage.get_config()["integracoes"]["openmontage"].get("cli_path")),
|
|
455
395
|
"openoutreach": agents.disparo_configurado(),
|
|
456
396
|
"pipecat": bool(storage.get_config()["integracoes"]["pipecat"].get("url")),
|
|
457
397
|
}
|
|
@@ -517,6 +457,9 @@ if FRONTEND_DIST.exists():
|
|
|
517
457
|
|
|
518
458
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
519
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.")
|
|
520
463
|
alvo = FRONTEND_DIST / full_path
|
|
521
464
|
if full_path and alvo.is_file():
|
|
522
465
|
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
|
|