@danhachuel/leadmaster 0.1.27 → 0.1.29

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +27 -5
  4. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  11. package/backend/services/agents.py +103 -0
  12. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  13. package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_disparos_worker.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_lead_edit_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_leads_export.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  26. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  27. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  28. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  29. package/backend/tests/test_disparos_worker.py +154 -0
  30. package/frontend/dist/assets/{index-pSgnrc_D.js → index-DTsFXKLu.js} +17 -17
  31. package/frontend/dist/index.html +1 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -37,6 +37,47 @@ normaliza para a forma canônica (ex.: `0.1.0`).
37
37
  falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
38
38
  ciclo de vida vinculado for removido ou se os handlers globais sumirem.
39
39
 
40
+ ## [0.1.29] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **Motor de disparos em background (`agents.processar_campanha_em_background`)** —
45
+ ativar uma campanha agora executa de verdade (simulação de envio):
46
+ - Rota `PUT /api/campaigns/{id}/status` com `"status": "ativa"` muda o status
47
+ para `em_andamento` e dispara o worker via **FastAPI `BackgroundTasks`** — a
48
+ resposta volta na hora, os envios sobem depois em `campaigns.json`.
49
+ - O worker percorre os `lead_ids` (filtro por etapa já aplicado na criação),
50
+ com `asyncio.sleep(1)` entre envios (tempo real de visualização na UI) e
51
+ **1 erro simulado a cada 10** leads; grava contadores e eventos a cada ciclo
52
+ via `storage.upsert_campaign` (lock de threading + escrita atômica).
53
+ - **Pausa no meio:** o registro é RELIDO do storage a cada ciclo e os
54
+ contadores fazem merge sobre a cópia fresca — pausar/parar (ou uma pausa
55
+ gravada durante o `sleep`) nunca é sobrescrita; o loop para no ciclo
56
+ seguinte. Reativar uma **concluída** zera os contadores (nova rodada);
57
+ reativar uma **pausada** continua de onde parou (leads já enviados são
58
+ pulados). Ao final, status → `concluida` (+ webhook `campanha.concluida`).
59
+ - **Frontend Disparos:** polling baixado de 4s → **2s** (barra de progresso
60
+ acompanha os contadores em tempo real), bloco de campanha ativa ganhou selo
61
+ **"Processando..."**, contagem de restantes e barra mais destacada
62
+ (Zeigarnik); **Pausar/Iniciar agora é otimista** — a UI atualiza
63
+ instantaneamente e o próximo polling reverte se o backend falhar.
64
+ - **5 novos testes pytest** (`backend/tests/test_disparos_worker.py`): worker
65
+ processa e conclui (contadores exatos, 1 erro a cada 10), **pausa gravada
66
+ durante o `sleep` interrompe o loop** (corrida tratada — pegou e fixou um bug
67
+ real onde o upsert sobrescrevia a pausa), retomada não reenvia, rota "ativa"
68
+ dispara o background e rota "pausada" não dispara. Suíte: **88 passed**.
69
+
70
+ ## [0.1.28] — 2026-11-09
71
+
72
+ ### Corrigido
73
+
74
+ - **Renumeração por colisão (E409):** a `0.1.27` já estava staged no registry
75
+ (sessão paralela), então esta versão republica o estado atual do `main` —
76
+ keep-alive do launcher com **Dummy Server** (`http.createServer` +
77
+ `listen(0, '127.0.0.1')` + `await new Promise(() => {})`, camada principal;
78
+ `stdin.resume`/`setInterval`/`beforeExit` secundárias) e os **gates de
79
+ regressão** correspondentes. Suíte: **83 passed**.
80
+
40
81
  ## [0.1.27] — 2026-11-09
41
82
 
42
83
  ### Corrigido (crítico)
package/backend/main.py CHANGED
@@ -13,7 +13,7 @@ import threading
13
13
  from contextlib import asynccontextmanager
14
14
  from pathlib import Path
15
15
 
16
- from fastapi import FastAPI, HTTPException, Response
16
+ from fastapi import BackgroundTasks, FastAPI, HTTPException, Response
17
17
  from fastapi.middleware.cors import CORSMiddleware
18
18
  from fastapi.responses import FileResponse
19
19
  from fastapi.staticfiles import StaticFiles
@@ -435,8 +435,15 @@ def criar_campanha(campaign_in: CampaignIn):
435
435
 
436
436
 
437
437
  @app.put("/api/campaigns/{campaign_id}/status")
438
- def alterar_status_campanha(campaign_id: str, dados: dict):
439
- """Altera o status de uma campanha (rascunho/ativa/pausada/concluida)."""
438
+ def alterar_status_campanha(campaign_id: str, dados: dict, background_tasks: BackgroundTasks):
439
+ """Altera o status de uma campanha (rascunho/ativa/pausada/concluida).
440
+
441
+ "ativa" (alias de em_andamento) DISPARA o motor de simulação em background
442
+ — a cada envio os contadores sobem em campaigns.json e a UI reflete na
443
+ barra de progresso. Reativar uma campanha concluída zera os contadores
444
+ (nova rodada); reativar uma pausada continua de onde parou (leads já
445
+ enviados são pulados). Pausar/parar responde no próximo ciclo do worker.
446
+ """
440
447
  status = (dados.get("status") or "").strip().lower()
441
448
  # Aliases aceitos: "ativa" equivale a "em_andamento" no ciclo interno.
442
449
  aliases = {"ativa": "em_andamento", "em_andamento": "em_andamento", "pausada": "pausada", "pausado": "pausada", "rascunho": "rascunho", "concluida": "concluida"}
@@ -446,8 +453,23 @@ def alterar_status_campanha(campaign_id: str, dados: dict):
446
453
  campaign = storage.get_campaign(campaign_id)
447
454
  if not campaign:
448
455
  raise HTTPException(404, "Campanha não encontrada.")
449
- campaign["status"] = interno
450
- storage.upsert_campaign(campaign)
456
+
457
+ if interno == "em_andamento":
458
+ if campaign.get("status") == "concluida":
459
+ # Nova rodada: zera métricas e eventos da execução anterior.
460
+ campaign.update({"enviadas": 0, "erros": 0, "eventos": [], "concluida_em": None})
461
+ campaign["status"] = "em_andamento"
462
+ campaign.setdefault("iniciada_em", storage.now_iso())
463
+ campaign.setdefault("enviadas", 0)
464
+ campaign.setdefault("erros", 0)
465
+ campaign.setdefault("eventos", [])
466
+ campaign["total"] = len(campaign.get("lead_ids", []))
467
+ storage.upsert_campaign(campaign)
468
+ # Motor em background: a resposta volta na hora; os envios sobem depois.
469
+ background_tasks.add_task(agents.processar_campanha_em_background, campaign_id)
470
+ else:
471
+ campaign["status"] = interno
472
+ storage.upsert_campaign(campaign)
451
473
  return campaign
452
474
 
453
475
 
@@ -5,6 +5,7 @@
5
5
  - **Webhooks**: eventos locais disparados para a URL configurada.
6
6
  """
7
7
 
8
+ import asyncio
8
9
  import threading
9
10
  import time
10
11
 
@@ -190,6 +191,108 @@ def _executar_campanha(campaign_id: str) -> None:
190
191
  _CONTROLES.pop(campaign_id, None)
191
192
 
192
193
 
194
+ # ---------------------------------------------------------------------------
195
+ # Motor de disparos — execução em background (simulação de envio)
196
+ # ---------------------------------------------------------------------------
197
+
198
+ # Delay entre "envios" simulados: 1s dá tempo real de ver a barra de progresso
199
+ # mover na tela (Efeito Zeigarnik). Testes injetam 0.
200
+ SIM_DELAY_SEGUNDOS = 1.0
201
+
202
+ # Um erro simulado a cada N envios — proporção realista para a UI.
203
+ SIM_ERRO_A_CADA = 10
204
+
205
+
206
+ async def processar_campanha_em_background(campaign_id: str, delay_segundos: float | None = None) -> None:
207
+ """Processa os leads de uma campanha ATIVA em background (simulação).
208
+
209
+ - Itera pelos `lead_ids` da campanha (o filtro por etapa já foi aplicado
210
+ na criação); leads já enviados em rodadas anteriores são pulados, então
211
+ pausar e reativar CONTINUA de onde parou.
212
+ - A cada ciclo o registro é RELIDO do storage e os contadores são
213
+ mergeados sobre essa cópia fresca: uma pausa/parada gravada durante o
214
+ `sleep` (ou durante o envio) nunca é sobrescrita pelo worker — o loop é
215
+ interrompido no ciclo seguinte (controle instantâneo do usuário, sem
216
+ corromper campaigns.json: lock de threading + escrita atômica).
217
+ - `await asyncio.sleep(delay)` entre envios (1s padrão = tempo real de
218
+ visualização na barra de progresso); 1 em cada 10 leads gera erro
219
+ simulado.
220
+ - Ao final, status → "concluida" (webhook `campanha.concluida`).
221
+ """
222
+ delay = SIM_DELAY_SEGUNDOS if delay_segundos is None else float(delay_segundos)
223
+ campanha = storage.get_campaign(campaign_id)
224
+ if not campanha:
225
+ return
226
+
227
+ if campanha.get("status") != "em_andamento":
228
+ campanha["status"] = "em_andamento"
229
+ campanha.setdefault("iniciada_em", storage.now_iso())
230
+ campanha.setdefault("enviadas", 0)
231
+ campanha.setdefault("erros", 0)
232
+ campanha.setdefault("eventos", [])
233
+ campanha["total"] = len(campanha.get("lead_ids", []))
234
+ storage.upsert_campaign(campanha)
235
+
236
+ leads = {l["id"]: l for l in storage.list_leads()}
237
+ eventos = list(campanha.get("eventos", []))
238
+ enviadas = int(campanha.get("enviadas", 0))
239
+ erros = int(campanha.get("erros", 0))
240
+ ja_enviados = {e.get("lead_id") for e in eventos if e.get("status") == "enviado"}
241
+
242
+ def _gravar(fresco: dict, status: str | None = None) -> dict:
243
+ """Merge dos contadores sobre a cópia fresca do disco — nunca
244
+ sobrescreve pausa/parada gravadas por outra rota durante a escrita."""
245
+ novo = dict(fresco)
246
+ novo["enviadas"] = enviadas
247
+ novo["erros"] = erros
248
+ novo["eventos"] = eventos
249
+ if status:
250
+ novo["status"] = status
251
+ return storage.upsert_campaign(novo)
252
+
253
+ for indice, lead_id in enumerate(campanha.get("lead_ids", []), start=1):
254
+ # Releitura a cada ciclo: pausar/parar responde no próximo ciclo.
255
+ fresco = storage.get_campaign(campaign_id)
256
+ if not fresco:
257
+ return
258
+ if fresco.get("status") != "em_andamento":
259
+ _gravar(fresco)
260
+ return
261
+ if lead_id in ja_enviados:
262
+ continue
263
+
264
+ if delay > 0:
265
+ await asyncio.sleep(delay)
266
+
267
+ # A pausa pode ter chegado DURANTE o sleep: relê antes de gravar.
268
+ fresco = storage.get_campaign(campaign_id) or fresco
269
+ if fresco.get("status") != "em_andamento":
270
+ _gravar(fresco)
271
+ return
272
+
273
+ momento = storage.now_iso()
274
+ lead = leads.get(lead_id)
275
+ if not lead:
276
+ erros += 1
277
+ eventos.append({"lead_id": lead_id, "status": "erro", "mensagem": "Lead removido da base.", "momento": momento})
278
+ elif indice % SIM_ERRO_A_CADA == 0:
279
+ erros += 1
280
+ eventos.append({"lead_id": lead_id, "status": "erro", "mensagem": "Falha simulada de envio (1 a cada 10).", "momento": momento})
281
+ else:
282
+ mensagem = render_template(campanha.get("mensagem") or "", lead)
283
+ enviadas += 1
284
+ eventos.append({"lead_id": lead_id, "status": "enviado", "mensagem": mensagem[:120], "momento": momento})
285
+ ja_enviados.add(lead_id)
286
+ _gravar(fresco)
287
+
288
+ # Fim: merge dos contadores + conclusão sobre o registro fresco do disco.
289
+ fresco_final = storage.get_campaign(campaign_id) or campanha
290
+ final = _gravar(fresco_final, status="concluida")
291
+ final["concluida_em"] = storage.now_iso()
292
+ storage.upsert_campaign(final)
293
+ disparar_webhook("campanha.concluida", {"campanha_id": campaign_id, "nome": final.get("nome", "")})
294
+
295
+
193
296
  # ---------------------------------------------------------------------------
194
297
  # Agente de atendimento (texto via LLM / voz via pipecat)
195
298
  # ---------------------------------------------------------------------------
@@ -0,0 +1,154 @@
1
+ """Testes do motor de disparos em background — agents.processar_campanha_em_background.
2
+
3
+ Cobrem: contadores enviadas/erros atualizados em campaigns.json, erro
4
+ simulado (1 a cada 10), conclusão automática, interrupção por pausa no meio
5
+ do loop e o gatilho via PUT /api/campaigns/{id}/status com status "ativa".
6
+ """
7
+
8
+ import asyncio
9
+
10
+ from fastapi.testclient import TestClient
11
+
12
+ import main
13
+ from services import agents, storage
14
+
15
+
16
+ def _lead(telefone: str) -> dict:
17
+ return {
18
+ "id": storage._novo_id(),
19
+ "nome": f"Lead {telefone}",
20
+ "telefone": telefone,
21
+ "email": "",
22
+ "fonte": "teste",
23
+ "etapa": "leads",
24
+ "empresa": "",
25
+ "cidade": "",
26
+ "endereco": "",
27
+ "score": 50,
28
+ "tags": [],
29
+ "observacoes": "",
30
+ "extras": {},
31
+ "criado_em": storage.now_iso(),
32
+ "atualizado_em": storage.now_iso(),
33
+ }
34
+
35
+
36
+ def _campanha(quantidade: int) -> dict:
37
+ return {
38
+ "id": storage._novo_id(),
39
+ "nome": "Campanha QA",
40
+ "canal": "texto",
41
+ "template": "texto",
42
+ "mensagem": "Olá {{nome}}!",
43
+ "lead_ids": [storage._novo_id() for _ in range(quantidade)],
44
+ "status": "rascunho",
45
+ "total": quantidade,
46
+ "enviadas": 0,
47
+ "erros": 0,
48
+ "eventos": [],
49
+ "criado_em": storage.now_iso(),
50
+ }
51
+
52
+
53
+ def _preparar(tmp_path, monkeypatch, quantidade):
54
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
55
+ leads = [_lead(f"(11) 90000-{i:04d}") for i in range(quantidade)]
56
+ campanha = _campanha(quantidade)
57
+ campanha["lead_ids"] = [l["id"] for l in leads]
58
+ storage.save_leads(leads)
59
+ storage.upsert_campaign(campanha)
60
+ return campanha
61
+
62
+
63
+ def test_background_processa_e_conclui(tmp_path, monkeypatch):
64
+ campanha = _preparar(tmp_path, monkeypatch, 12)
65
+ asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
66
+
67
+ gravada = storage.get_campaign(campanha["id"])
68
+ assert gravada["status"] == "concluida"
69
+ assert gravada["enviadas"] + gravada["erros"] == 12 # todos processados
70
+ assert gravada["enviadas"] == 11 # 1 erro simulado a cada 10
71
+ assert gravada["erros"] == 1
72
+ assert len(gravada["eventos"]) == 12
73
+ assert any(e["status"] == "erro" for e in gravada["eventos"])
74
+ assert "concluida_em" in gravada
75
+
76
+
77
+ def test_pausa_interrompe_no_proximo_ciclo(tmp_path, monkeypatch):
78
+ campanha = _preparar(tmp_path, monkeypatch, 6)
79
+
80
+ real_sleep = asyncio.sleep
81
+
82
+ async def sleep_pausante(delay, *args, **kwargs):
83
+ # Na 3ª "espera" o usuário pausa a campanha (como a rota faria).
84
+ sleep_pausante.chamadas += 1
85
+ if sleep_pausante.chamadas == 3:
86
+ atual = storage.get_campaign(campanha["id"])
87
+ atual["status"] = "pausada"
88
+ storage.upsert_campaign(atual)
89
+ await real_sleep(0)
90
+
91
+ sleep_pausante.chamadas = 0
92
+ monkeypatch.setattr(agents.asyncio, "sleep", sleep_pausante)
93
+ asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0.01))
94
+
95
+ gravada = storage.get_campaign(campanha["id"])
96
+ assert gravada["status"] == "pausada"
97
+ # Processou menos que o total: o loop foi interrompido no meio.
98
+ assert gravada["enviadas"] + gravada["erros"] < 6
99
+
100
+
101
+ def test_retomar_nao_reenvia(tmp_path, monkeypatch):
102
+ campanha = _preparar(tmp_path, monkeypatch, 5)
103
+ asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
104
+ concluida = storage.get_campaign(campanha["id"])
105
+ assert concluida["enviadas"] == 5
106
+
107
+ # Reativar uma concluída zera os contadores (nova rodada).
108
+ concluida.update({"enviadas": 0, "erros": 0, "eventos": [], "concluida_em": None, "status": "em_andamento"})
109
+ storage.upsert_campaign(concluida)
110
+ asyncio.run(agents.processar_campanha_em_background(campanha["id"], delay_segundos=0))
111
+ assert storage.get_campaign(campanha["id"])["enviadas"] == 5
112
+
113
+
114
+ def test_rota_ativa_dispara_background(tmp_path, monkeypatch):
115
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
116
+ monkeypatch.setattr(agents, "SIM_DELAY_SEGUNDOS", 0)
117
+ chamadas = []
118
+ original = agents.processar_campanha_em_background
119
+
120
+ async def espiao(campaign_id, delay_segundos=None):
121
+ chamadas.append(campaign_id)
122
+ await original(campaign_id, delay_segundos=0)
123
+
124
+ monkeypatch.setattr(agents, "processar_campanha_em_background", espiao)
125
+
126
+ leads = [_lead(f"(11) 91000-{i:04d}") for i in range(3)]
127
+ campanha = _campanha(3)
128
+ campanha["lead_ids"] = [l["id"] for l in leads]
129
+ storage.save_leads(leads)
130
+ storage.upsert_campaign(campanha)
131
+
132
+ with TestClient(main.app) as client:
133
+ r = client.put(f"/api/campaigns/{campanha['id']}/status", json={"status": "ativa"})
134
+ assert r.status_code == 200
135
+ assert r.json()["status"] == "em_andamento"
136
+
137
+ # O BackgroundTask roda dentro do TestClient antes de retornar.
138
+ assert chamadas == [campanha["id"]]
139
+ gravada = storage.get_campaign(campanha["id"])
140
+ assert gravada["enviadas"] + gravada["erros"] == 3
141
+ assert gravada["status"] == "concluida"
142
+
143
+
144
+ def test_rota_pausa_nao_dispara(tmp_path, monkeypatch):
145
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
146
+ campanha = _campanha(2)
147
+ storage.upsert_campaign(campanha)
148
+ chamadas = []
149
+ monkeypatch.setattr(agents, "processar_campanha_em_background", lambda *a, **k: chamadas.append(a))
150
+ with TestClient(main.app) as client:
151
+ r = client.put(f"/api/campaigns/{campanha['id']}/status", json={"status": "pausada"})
152
+ assert r.status_code == 200
153
+ assert r.json()["status"] == "pausada"
154
+ assert chamadas == []