@danhachuel/leadmaster 0.1.19 → 0.1.21

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 (31) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +79 -3
  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 +1 -0
  12. package/backend/services/storage.py +55 -6
  13. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/test_agents_routes.py +82 -0
  26. package/backend/tests/test_webhook_routes.py +83 -0
  27. package/frontend/dist/assets/{index-ib1LXo9v.css → index-Bn20U7qZ.css} +1 -1
  28. package/frontend/dist/assets/index-CC4fz5Tw.js +90 -0
  29. package/frontend/dist/index.html +2 -2
  30. package/package.json +1 -1
  31. package/frontend/dist/assets/index-CXuYUAoQ.js +0 -90
package/CHANGELOG.md CHANGED
@@ -37,6 +37,55 @@ 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.21] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **Módulo de Webhooks revisado conforme especificação:**
45
+ - `GET /api/webhook` agora retorna também **`ultimo_teste`** (registrado a cada
46
+ teste bem-sucedido no `config.json`).
47
+ - `PUT /api/webhook` e `POST /api/webhook/test` **validam a URL**: só
48
+ `http://`/`https://` são aceitos (400 com mensagem acionável).
49
+ - Novo evento **`campanha.iniciada`** — disparado quando uma campanha começa a
50
+ rodar; migração aditiva garante que ele apareça nas configurações existentes.
51
+ - Frontend `frontend/src/pages/Webhook.tsx` reformulado: eventos exibidos
52
+ diretamente e limpos (**Lead Criado**, **Campanha Iniciada**, **Campanha
53
+ Finalizada** — Lei de Hick), **prevenção de erros** (Nielsen): URL fora do
54
+ padrão http(s) bloqueia o salvamento e marca o input com **borda vermelha**,
55
+ botão **"Salvar Configurações"** em gradiente (Fitts) e **"Testar Webhook"**
56
+ em outline contrastante (von Restorff), toast de sucesso/erro no teste e card
57
+ **"Último teste realizado em: [data/hora]"**.
58
+ - **7 novos testes pytest** (`backend/tests/test_webhook_routes.py`): leitura do
59
+ padrão, salvamento de URL/eventos com persistência, URL inválida (400), teste
60
+ com sucesso (mock do `httpx.post`, registra `ultimo_teste`), teste com falha de
61
+ conexão sem quebrar (mock de `httpx.ConnectError`) e URL de teste inválida.
62
+
63
+ ## [0.1.20] — 2026-11-09
64
+
65
+ ### Adicionado
66
+
67
+ - **Módulo de Agentes de IA (controle e configuração):**
68
+ - Backend: `GET /api/agents` (status + prompt de cada agente, e flag
69
+ `llm_configurado`), `PUT /api/agents/{agent_id}/status` (ativo/inativo) e
70
+ `PUT /api/agents/{agent_id}/prompt` — persistência no novo arquivo
71
+ `storage/agents.json` (seed com prompts padrão em pt-BR).
72
+ - **Regra de bloqueio:** ativar o **Agente de Disparo** sem a chave do LLM
73
+ configurada retorna **400** com mensagem acionável ("Configure a API Key
74
+ do LLM em Ferramentas..."); desativar e o Atendimento são sempre
75
+ permitidos.
76
+ - Frontend: `frontend/src/pages/Agentes.tsx` reformulado em **dois cards
77
+ lado a lado** (Disparo / Atendimento): badge de status, **toggle grande**
78
+ (w-16 h-9 — Fitts), **pulso animado verde-esmeralda** e badge esmeralda
79
+ quando ativo (von Restorff + Zeigarnik), textarea do **Prompt do Sistema**
80
+ + botão "Salvar Prompt", toast específico ("Configure a API Key na aba
81
+ Ferramentas primeiro.") quando o bloqueio 400 ocorre, e **accordion** com
82
+ as configurações avançadas (modelo LLM/temperatura; modo texto/voz do
83
+ atendimento) — Lei de Hick.
84
+ - **8 novos testes pytest** (`backend/tests/test_agents_routes.py`): listagem,
85
+ bloqueio do disparo sem LLM (400), ativação do atendimento, ativação do
86
+ disparo com LLM, desativação, status inválido/agente desconhecido (400/404),
87
+ persistência do prompt e erros de prompt.
88
+
40
89
  ## [0.1.19] — 2026-11-09
41
90
 
42
91
  ### Adicionado
package/backend/main.py CHANGED
@@ -552,6 +552,56 @@ def buscar_cnpj(dados: CnpjSearchIn):
552
552
  raise HTTPException(502, str(e))
553
553
 
554
554
 
555
+ # ---------------------------------------------------------------------------
556
+ # Agentes de IA (status + prompt do sistema)
557
+ # ---------------------------------------------------------------------------
558
+
559
+ @app.get("/api/agents")
560
+ def listar_agentes():
561
+ """Status e prompt de cada agente (disparo / atendimento)."""
562
+ agentes = storage.get_agents()
563
+ llm_ok = llm.configurado()
564
+ return {
565
+ "llm_configurado": llm_ok,
566
+ "agentes": {
567
+ agent_id: {
568
+ "status": agentes.get(agent_id, {}).get("status", "inativo"),
569
+ "prompt": agentes.get(agent_id, {}).get("prompt", ""),
570
+ }
571
+ for agent_id in storage.AGENTE_IDS
572
+ },
573
+ }
574
+
575
+
576
+ @app.put("/api/agents/{agent_id}/status")
577
+ def alterar_status_agente(agent_id: str, dados: dict):
578
+ """Liga/desliga um agente. Ativar o de disparo exige LLM configurada."""
579
+ if agent_id not in storage.AGENTE_IDS:
580
+ raise HTTPException(404, f"Agente desconhecido: {agent_id}. Use: disparo ou atendimento.")
581
+ status = (dados.get("status") or "").strip().lower()
582
+ if status not in ("ativo", "inativo"):
583
+ raise HTTPException(400, 'Status inválido. Use: ativo ou inativo.')
584
+ if status == "ativo" and agent_id == "disparo" and not llm.configurado():
585
+ raise HTTPException(
586
+ 400,
587
+ "Configure a API Key do LLM em Ferramentas antes de ativar o Agente de Disparo.",
588
+ )
589
+ agente = storage.save_agent(agent_id, {"status": status})
590
+ return {"ok": True, "agent_id": agent_id, "status": agente["status"]}
591
+
592
+
593
+ @app.put("/api/agents/{agent_id}/prompt")
594
+ def salvar_prompt_agente(agent_id: str, dados: dict):
595
+ """Salva o prompt do sistema do agente em agents.json."""
596
+ if agent_id not in storage.AGENTE_IDS:
597
+ raise HTTPException(404, f"Agente desconhecido: {agent_id}. Use: disparo ou atendimento.")
598
+ prompt = dados.get("prompt")
599
+ if prompt is None:
600
+ raise HTTPException(400, "Informe o prompt do sistema.")
601
+ agente = storage.save_agent(agent_id, {"prompt": str(prompt)})
602
+ return {"ok": True, "agent_id": agent_id, "prompt": agente["prompt"]}
603
+
604
+
555
605
  # ---------------------------------------------------------------------------
556
606
  # Configuração e integrações
557
607
  # ---------------------------------------------------------------------------
@@ -662,21 +712,47 @@ def atendimento_chat(dados: ChatIn):
662
712
 
663
713
  @app.get("/api/webhook")
664
714
  def obter_webhook():
665
- return storage.get_config().get("webhook", {})
715
+ webhook = storage.get_config().get("webhook", {})
716
+ return {
717
+ "url": webhook.get("url", ""),
718
+ "eventos": webhook.get("eventos", []),
719
+ "ultimo_teste": webhook.get("ultimo_teste"),
720
+ }
721
+
722
+
723
+ def _url_valida(url: str) -> bool:
724
+ return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://"))
666
725
 
667
726
 
668
727
  @app.put("/api/webhook")
669
728
  def salvar_webhook(patch: dict):
670
729
  config = storage.get_config()
671
730
  webhook = config.get("webhook", {})
731
+ url = patch.get("url", webhook.get("url", ""))
732
+ if url and not _url_valida(url):
733
+ raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
672
734
  webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
673
735
  storage.update_config({"webhook": webhook})
674
- return webhook
736
+ return {
737
+ "url": webhook.get("url", ""),
738
+ "eventos": webhook.get("eventos", []),
739
+ "ultimo_teste": webhook.get("ultimo_teste"),
740
+ }
675
741
 
676
742
 
677
743
  @app.post("/api/webhook/test")
678
744
  def testar_webhook(dados: WebhookTestIn):
679
- return agents.testar_webhook(dados.url)
745
+ if dados.url and not _url_valida(dados.url):
746
+ raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
747
+ resultado = agents.testar_webhook(dados.url)
748
+ if resultado.get("ok"):
749
+ # Registra o último teste bem-sucedido no config.json.
750
+ config = storage.get_config()
751
+ webhook = config.get("webhook", {})
752
+ webhook["ultimo_teste"] = storage.now_iso()
753
+ storage.update_config({"webhook": webhook})
754
+ resultado["ultimo_teste"] = webhook["ultimo_teste"]
755
+ return resultado
680
756
 
681
757
 
682
758
  # ---------------------------------------------------------------------------
@@ -100,6 +100,7 @@ def iniciar_campanha(campaign_id: str) -> dict:
100
100
  campaign.setdefault("erros", 0)
101
101
  campaign.setdefault("eventos", [])
102
102
  storage.upsert_campaign(campaign)
103
+ disparar_webhook("campanha.iniciada", {"campanha_id": campaign_id, "nome": campaign["nome"]})
103
104
 
104
105
  threading.Thread(target=_executar_campanha, args=(campaign_id,), daemon=True).start()
105
106
  return {"ok": True, "mensagem": "Campanha iniciada."}
@@ -20,6 +20,7 @@ LEADS_FILE = "leads.json"
20
20
  CAMPAIGNS_FILE = "campaigns.json"
21
21
  PIPELINE_FILE = "pipeline.json"
22
22
  CONFIG_FILE = "config.json"
23
+ AGENTS_FILE = "agents.json"
23
24
 
24
25
 
25
26
  # ---------------------------------------------------------------------------
@@ -134,7 +135,7 @@ DEFAULT_CONFIG = {
134
135
  },
135
136
  "webhook": {
136
137
  "url": "",
137
- "eventos": ["lead.criado", "lead.etapa", "campanha.concluida"],
138
+ "eventos": ["lead.criado", "campanha.iniciada", "campanha.concluida"],
138
139
  },
139
140
  }
140
141
 
@@ -188,11 +189,16 @@ def _limpar_deprecated(config: dict) -> dict:
188
189
  mudou = True
189
190
  # Evento de webhook do recurso removido.
190
191
  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
192
+ if isinstance(webhook, dict):
193
+ if isinstance(webhook.get("eventos"), list):
194
+ eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
195
+ # Migração aditiva: o evento "campanha.iniciada" passou a existir
196
+ # (0.1.21) — garantimos que configs antigas também o ofereçam.
197
+ if "campanha.iniciada" not in eventos:
198
+ eventos.append("campanha.iniciada")
199
+ if eventos != webhook["eventos"]:
200
+ webhook["eventos"] = eventos
201
+ mudou = True
196
202
  # Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
197
203
  pipeline_path = storage_dir() / PIPELINE_FILE
198
204
  if pipeline_path.exists():
@@ -352,6 +358,47 @@ def delete_campaign(campaign_id: str) -> bool:
352
358
  return True
353
359
 
354
360
 
361
+ # ---------------------------------------------------------------------------
362
+ # Agentes de IA (status e prompt do sistema)
363
+ # ---------------------------------------------------------------------------
364
+
365
+ DEFAULT_AGENTS = {
366
+ "disparo": {
367
+ "status": "inativo",
368
+ "prompt": (
369
+ "Você é um especialista em prospecção consultiva. Escreva mensagens curtas, "
370
+ "educadas e personalizadas, sempre citando a empresa do lead. Nunca use "
371
+ "linguagem agressiva ou spam."
372
+ ),
373
+ },
374
+ "atendimento": {
375
+ "status": "inativo",
376
+ "prompt": (
377
+ "Você é o assistente de atendimento do LeadMaster. Responda de forma clara e "
378
+ "objetiva em português do Brasil, encaminhe dúvidas comerciais para o "
379
+ "responsável quando necessário."
380
+ ),
381
+ },
382
+ }
383
+
384
+ AGENTE_IDS = ("disparo", "atendimento")
385
+
386
+
387
+ def get_agents() -> dict:
388
+ return _deep_merge(DEFAULT_AGENTS, _read(AGENTS_FILE, {}))
389
+
390
+
391
+ def save_agent(agent_id: str, dados: dict) -> dict:
392
+ """Atualiza campos de um agente (status/prompt) e persiste em agents.json."""
393
+ with LOCK:
394
+ agents = get_agents()
395
+ if agent_id not in agents:
396
+ agents[agent_id] = {"status": "inativo", "prompt": ""}
397
+ agents[agent_id].update({k: v for k, v in dados.items() if k in ("status", "prompt")})
398
+ _write(AGENTS_FILE, agents)
399
+ return agents[agent_id]
400
+
401
+
355
402
  # ---------------------------------------------------------------------------
356
403
  # Seeds (primeira execução)
357
404
  # ---------------------------------------------------------------------------
@@ -433,6 +480,8 @@ def ensure_seeds() -> None:
433
480
  _write(CAMPAIGNS_FILE, _seed_campaigns())
434
481
  if not (d / CONFIG_FILE).exists():
435
482
  _write(CONFIG_FILE, DEFAULT_CONFIG)
483
+ if not (d / AGENTS_FILE).exists():
484
+ _write(AGENTS_FILE, DEFAULT_AGENTS)
436
485
 
437
486
 
438
487
  def limpar_seeds_demo() -> None:
@@ -0,0 +1,82 @@
1
+ """Testes do controle dos Agentes de IA (backend/main.py).
2
+
3
+ GET /api/agents, PUT /api/agents/{id}/status (com bloqueio do disparo sem
4
+ LLM configurada) e PUT /api/agents/{id}/prompt. Home isolado em tmp.
5
+ """
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ import main
11
+
12
+
13
+ @pytest.fixture()
14
+ def client(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
16
+ with TestClient(main.app) as c:
17
+ yield c
18
+
19
+
20
+ def test_listar_agentes(client):
21
+ r = client.get("/api/agents")
22
+ assert r.status_code == 200
23
+ corpo = r.json()
24
+ assert "disparo" in corpo["agentes"] and "atendimento" in corpo["agentes"]
25
+ assert corpo["agentes"]["disparo"]["status"] == "inativo"
26
+ assert corpo["llm_configurado"] is False # seed sem chave de LLM
27
+
28
+
29
+ def test_ativar_disparo_sem_llm_bloqueia(client):
30
+ r = client.put("/api/agents/disparo/status", json={"status": "ativo"})
31
+ assert r.status_code == 400
32
+ assert "API Key" in r.json()["detail"]
33
+ # Continua inativo
34
+ estado = client.get("/api/agents").json()["agentes"]["disparo"]["status"]
35
+ assert estado == "inativo"
36
+
37
+
38
+ def test_ativar_atendimento_permitido_sem_llm(client):
39
+ r = client.put("/api/agents/atendimento/status", json={"status": "ativo"})
40
+ assert r.status_code == 200
41
+ assert r.json()["status"] == "ativo"
42
+ # Persistido em agents.json
43
+ estado = client.get("/api/agents").json()["agentes"]["atendimento"]["status"]
44
+ assert estado == "ativo"
45
+
46
+
47
+ def test_ativar_disparo_com_llm(client):
48
+ # Configura a chave do LLM e ativa o disparo.
49
+ client.put("/api/config", json={"llm": {"api_key": "sk-teste-123"}})
50
+ r = client.put("/api/agents/disparo/status", json={"status": "ativo"})
51
+ assert r.status_code == 200
52
+ assert r.json()["status"] == "ativo"
53
+
54
+
55
+ def test_desativar_agente(client):
56
+ client.put("/api/agents/atendimento/status", json={"status": "ativo"})
57
+ r = client.put("/api/agents/atendimento/status", json={"status": "inativo"})
58
+ assert r.status_code == 200
59
+ assert r.json()["status"] == "inativo"
60
+
61
+
62
+ def test_status_invalido_e_agente_desconhecido(client):
63
+ r = client.put("/api/agents/disparo/status", json={"status": "talvez"})
64
+ assert r.status_code == 400
65
+ r2 = client.put("/api/agents/inexistente/status", json={"status": "ativo"})
66
+ assert r2.status_code == 404
67
+
68
+
69
+ def test_salvar_prompt_persiste(client):
70
+ prompt = "Você é um agente de prospecção especialista em SaaS."
71
+ r = client.put("/api/agents/disparo/prompt", json={"prompt": prompt})
72
+ assert r.status_code == 200
73
+ assert r.json()["prompt"] == prompt
74
+ salvo = client.get("/api/agents").json()["agentes"]["disparo"]["prompt"]
75
+ assert salvo == prompt
76
+
77
+
78
+ def test_salvar_prompt_agente_desconhecido(client):
79
+ r = client.put("/api/agents/inexistente/prompt", json={"prompt": "x"})
80
+ assert r.status_code == 404
81
+ r2 = client.put("/api/agents/disparo/prompt", json={})
82
+ assert r2.status_code == 400
@@ -0,0 +1,83 @@
1
+ """Testes das rotas de Webhook (backend/main.py).
2
+
3
+ GET/PUT /api/webhook (com validação de URL) e POST /api/webhook/test com o
4
+ HTTP externo mockado — o endpoint de teste nunca quebra com URL inválida ou
5
+ indisponível. HOME isolado em diretório temporário.
6
+ """
7
+
8
+ from unittest.mock import patch
9
+
10
+ import httpx
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+
14
+ import main
15
+
16
+
17
+ @pytest.fixture()
18
+ def client(tmp_path, monkeypatch):
19
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
20
+ with TestClient(main.app) as c:
21
+ yield c
22
+
23
+
24
+ def test_obter_padrao(client):
25
+ r = client.get("/api/webhook")
26
+ assert r.status_code == 200
27
+ corpo = r.json()
28
+ assert corpo["url"] == ""
29
+ assert "lead.criado" in corpo["eventos"]
30
+ assert corpo["ultimo_teste"] is None
31
+
32
+
33
+ def test_salvar_url_e_eventos(client):
34
+ r = client.put(
35
+ "/api/webhook",
36
+ json={"url": "https://n8n.exemplo.com/webhook/abc", "eventos": ["lead.criado"]},
37
+ )
38
+ assert r.status_code == 200
39
+ corpo = r.json()
40
+ assert corpo["url"] == "https://n8n.exemplo.com/webhook/abc"
41
+ assert corpo["eventos"] == ["lead.criado"]
42
+ # Persistido no config.json
43
+ salvo = client.get("/api/webhook").json()
44
+ assert salvo["url"] == "https://n8n.exemplo.com/webhook/abc"
45
+
46
+
47
+ def test_salvar_url_invalida_400(client):
48
+ r = client.put("/api/webhook", json={"url": "nao-e-uma-url"})
49
+ assert r.status_code == 400
50
+ assert "http" in r.json()["detail"]
51
+
52
+
53
+ def _resposta_ok(*_args, **_kwargs):
54
+ return httpx.Response(200, json={"ok": True})
55
+
56
+
57
+ def test_testar_webhook_sucesso(client):
58
+ client.put("/api/webhook", json={"url": "https://exemplo.com/hook"})
59
+ with patch.object(main.agents.httpx, "post", side_effect=_resposta_ok):
60
+ r = client.post("/api/webhook/test", json={"url": "https://exemplo.com/hook"})
61
+ assert r.status_code == 200
62
+ corpo = r.json()
63
+ assert corpo["ok"] is True
64
+ assert corpo["status_http"] == 200
65
+ assert corpo["ultimo_teste"] # registrado
66
+
67
+
68
+ def test_testar_webhook_conexao_falha_nao_quebra(client):
69
+ # URL indisponível: retorna erro amigável (não 500).
70
+ with patch.object(main.agents.httpx, "post", side_effect=httpx.ConnectError("sem rede")):
71
+ r = client.post("/api/webhook/test", json={"url": "https://inexistente.invalid/hook"})
72
+ assert r.status_code == 200
73
+ assert r.json()["ok"] is False
74
+ assert "mensagem" in r.json()
75
+
76
+
77
+ def test_testar_webhook_url_invalida_400(client):
78
+ # Passa o min_length do modelo, mas falha no esquema http(s) → 400.
79
+ r = client.post("/api/webhook/test", json={"url": "nao-http-url.com/webhook"})
80
+ assert r.status_code == 400
81
+ # URL curta demais falha na validação do modelo.
82
+ r2 = client.post("/api/webhook/test", json={"url": "xyz"})
83
+ assert r2.status_code == 422