@danhachuel/leadmaster 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,235 @@
1
+ """Agentes do LeadMaster:
2
+
3
+ - **Agente de disparo**: executa campanhas de envio (texto/áudio) via OpenOutreach.
4
+ - **Agente de atendimento**: chat por texto (LLM) ou voz (pipecat).
5
+ - **Webhooks**: eventos locais disparados para a URL configurada.
6
+ """
7
+
8
+ import threading
9
+ import time
10
+
11
+ import httpx
12
+
13
+ from . import llm, storage
14
+
15
+
16
+ class AgenteIndisponivel(Exception):
17
+ pass
18
+
19
+
20
+ # Controles por campanha: id → {"pausar": threading.Event, "parar": threading.Event}
21
+ _CONTROLES: dict[str, dict] = {}
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Templates e renderização
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def obter_templates() -> dict:
29
+ agentes = storage.get_config().get("agentes", {})
30
+ return agentes.get("disparo", {}).get("templates", {})
31
+
32
+
33
+ def salvar_templates(patch: dict) -> dict:
34
+ agentes = storage.get_config().get("agentes", {})
35
+ templates = agentes.get("disparo", {}).get("templates", {})
36
+ templates.update(patch)
37
+ storage.update_config({"agentes": {"disparo": {"templates": templates}}})
38
+ return templates
39
+
40
+
41
+ def render_template(texto: str, lead: dict) -> str:
42
+ from .crm_agent import _score # evita import circular no topo
43
+
44
+ minha_empresa = storage.get_config().get("agentes", {}).get("disparo", {}).get("minha_empresa", "LeadMaster")
45
+ primeiro_nome = (lead.get("nome") or "").split()[0] if lead.get("nome") else ""
46
+ variaveis = {
47
+ "nome": lead.get("nome", ""),
48
+ "primeiro_nome": primeiro_nome,
49
+ "empresa": lead.get("empresa") or lead.get("nome", ""),
50
+ "telefone": lead.get("telefone", ""),
51
+ "cidade": lead.get("cidade", ""),
52
+ "minha_empresa": minha_empresa,
53
+ "score": str(lead.get("score") or _score(lead)),
54
+ }
55
+ for chave, valor in variaveis.items():
56
+ texto = texto.replace("{{" + chave + "}}", str(valor))
57
+ return texto
58
+
59
+
60
+ def testar_template(texto: str, lead_id: str | None = None) -> dict:
61
+ lead = None
62
+ for l in storage.list_leads():
63
+ if l.get("id") == lead_id:
64
+ lead = l
65
+ break
66
+ if not lead:
67
+ lead = {"nome": "Maria Exemplo", "empresa": "Empresa X", "cidade": "São Paulo/SP", "telefone": "(11) 90000-0000"}
68
+ return {"renderizado": render_template(texto, lead)}
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Agente de disparo (campanhas via OpenOutreach)
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def disparo_configurado() -> bool:
76
+ o = storage.get_config()["integracoes"]["openoutreach"]
77
+ return bool(o.get("url") and o.get("enabled", True))
78
+
79
+
80
+ def iniciar_campanha(campaign_id: str) -> dict:
81
+ campaign = storage.get_campaign(campaign_id)
82
+ if not campaign:
83
+ return {"ok": False, "mensagem": "Campanha não encontrada."}
84
+ if not disparo_configurado():
85
+ return {
86
+ "ok": False,
87
+ "mensagem": (
88
+ "OpenOutreach não configurado — valide a URL e ative a integração em "
89
+ "Ferramentas → OpenOutreach para poder disparar."
90
+ ),
91
+ }
92
+ if campaign.get("status") == "em_andamento":
93
+ return {"ok": False, "mensagem": "Campanha já está em andamento."}
94
+
95
+ controles = {"pausar": threading.Event(), "parar": threading.Event()}
96
+ _CONTROLES[campaign_id] = controles
97
+ campaign["status"] = "em_andamento"
98
+ campaign["iniciada_em"] = storage.now_iso()
99
+ campaign.setdefault("enviadas", 0)
100
+ campaign.setdefault("erros", 0)
101
+ campaign.setdefault("eventos", [])
102
+ storage.upsert_campaign(campaign)
103
+
104
+ threading.Thread(target=_executar_campanha, args=(campaign_id,), daemon=True).start()
105
+ return {"ok": True, "mensagem": "Campanha iniciada."}
106
+
107
+
108
+ def pausar_campanha(campaign_id: str) -> dict:
109
+ controles = _CONTROLES.get(campaign_id)
110
+ if not controles:
111
+ return {"ok": False, "mensagem": "Campanha não está em execução."}
112
+ controles["pausar"].set()
113
+ return {"ok": True, "mensagem": "Pausa solicitada; concluída após o envio em andamento."}
114
+
115
+
116
+ def parar_campanha(campaign_id: str) -> dict:
117
+ controles = _CONTROLES.get(campaign_id)
118
+ if not controles:
119
+ return {"ok": False, "mensagem": "Campanha não está em execução."}
120
+ controles["parar"].set()
121
+ return {"ok": True, "mensagem": "Parada solicitada."}
122
+
123
+
124
+ def _executar_campanha(campaign_id: str) -> None:
125
+ conf = storage.get_config()
126
+ o = conf["integracoes"]["openoutreach"]
127
+ base = o["url"].rstrip("/")
128
+ send_path = o.get("send_path", "/api/send").lstrip("/")
129
+ delay = int(conf.get("agentes", {}).get("disparo", {}).get("delay_entre_envios_segundos", 3))
130
+ templates = obter_templates()
131
+
132
+ campaign = storage.get_campaign(campaign_id)
133
+ leads = {l["id"]: l for l in storage.list_leads() if l["id"] in set(campaign.get("lead_ids", []))}
134
+ campaign["total"] = len(leads)
135
+
136
+ for lead_id in campaign.get("lead_ids", []):
137
+ controles = _CONTROLES.get(campaign_id)
138
+ if controles and controles["parar"].is_set():
139
+ campaign["status"] = "pausada"
140
+ storage.upsert_campaign(campaign)
141
+ return
142
+ if controles and controles["pausar"].is_set():
143
+ campaign["status"] = "pausada"
144
+ storage.upsert_campaign(campaign)
145
+ return
146
+
147
+ lead = leads.get(lead_id)
148
+ momento = storage.now_iso()
149
+ if not lead:
150
+ campaign["erros"] += 1
151
+ campaign["eventos"].append({"lead_id": lead_id, "status": "erro", "mensagem": "Lead removido da base.", "momento": momento})
152
+ storage.upsert_campaign(campaign)
153
+ continue
154
+
155
+ mensagem = render_template(templates.get(campaign.get("canal", "texto"), templates.get("texto", "")), lead)
156
+ try:
157
+ r = httpx.post(
158
+ f"{base}/{send_path}",
159
+ json={
160
+ "canal": campaign.get("canal", "texto"),
161
+ "destino": lead.get("telefone") or lead.get("email"),
162
+ "mensagem": mensagem,
163
+ "lead_id": lead_id,
164
+ "campanha": campaign["nome"],
165
+ },
166
+ timeout=60,
167
+ )
168
+ ok = r.status_code < 400
169
+ detalhe = "" if ok else f"HTTP {r.status_code}: {r.text[:200]}"
170
+ except httpx.HTTPError as e:
171
+ ok, detalhe = False, f"Falha de conexão com OpenOutreach: {e}"
172
+
173
+ if ok:
174
+ campaign["enviadas"] += 1
175
+ campaign["eventos"].append({"lead_id": lead_id, "status": "enviado", "mensagem": detalhe, "momento": momento})
176
+ else:
177
+ campaign["erros"] += 1
178
+ campaign["eventos"].append({"lead_id": lead_id, "status": "erro", "mensagem": detalhe or "Erro no envio.", "momento": momento})
179
+ storage.upsert_campaign(campaign)
180
+ time.sleep(max(0, delay))
181
+
182
+ campaign["status"] = "concluida"
183
+ campaign["concluida_em"] = storage.now_iso()
184
+ storage.upsert_campaign(campaign)
185
+ disparar_webhook("campanha.concluida", {"campanha_id": campaign_id, "nome": campaign["nome"]})
186
+ _CONTROLES.pop(campaign_id, None)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Agente de atendimento (texto via LLM / voz via pipecat)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def atendimento_chat(mensagens: list) -> dict:
194
+ modo = storage.get_config().get("agentes", {}).get("atendimento", {}).get("modo", "texto")
195
+ if modo == "voz":
196
+ url = storage.get_config()["integracoes"]["pipecat"].get("url", "")
197
+ if not url:
198
+ return {"ok": False, "mensagem": "Modo voz selecionado, mas pipecat não configurado (Ferramentas → Integrações)."}
199
+ try:
200
+ r = httpx.post(url.rstrip("/") + "/chat", json={"messages": mensagens}, timeout=120)
201
+ r.raise_for_status()
202
+ return {"ok": True, "resposta": r.json().get("resposta", "")}
203
+ except httpx.HTTPError as e:
204
+ return {"ok": False, "mensagem": f"pipecat não respondeu ({url}): {e}"}
205
+
206
+ try:
207
+ resposta = llm.chat(mensagens, temperature=0.5, max_tokens=800)
208
+ return {"ok": True, "resposta": resposta}
209
+ except llm.LLMNaoConfigurado as e:
210
+ return {"ok": False, "mensagem": str(e)}
211
+ except llm.LLMErro as e:
212
+ return {"ok": False, "mensagem": str(e)}
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Webhook local
217
+ # ---------------------------------------------------------------------------
218
+
219
+ def disparar_webhook(evento: str, payload: dict) -> None:
220
+ conf = storage.get_config().get("webhook", {})
221
+ url = conf.get("url", "")
222
+ if not url or evento not in conf.get("eventos", []):
223
+ return
224
+ try:
225
+ httpx.post(url, json={"evento": evento, "dados": payload, "momento": storage.now_iso()}, timeout=10)
226
+ except httpx.HTTPError:
227
+ pass # webhook é best-effort; erros não podem travar o fluxo principal
228
+
229
+
230
+ def testar_webhook(url: str) -> dict:
231
+ try:
232
+ r = httpx.post(url, json={"evento": "teste.leadmaster", "dados": {"ok": True}, "momento": storage.now_iso()}, timeout=10)
233
+ return {"ok": r.status_code < 400, "status_http": r.status_code, "resposta": r.text[:300]}
234
+ except httpx.HTTPError as e:
235
+ return {"ok": False, "mensagem": f"Falha de conexão: {e}"}
@@ -0,0 +1,175 @@
1
+ """Agente de CRM: sugere movimentações de etapa e enriquece leads.
2
+
3
+ Funciona 100% local com heurísticas; quando um LLM está configurado, as
4
+ sugestões passam por uma triagem por IA (mais contexto, mais confiança).
5
+ Nunca move o lead automaticamente: o usuário aplica a sugestão.
6
+ """
7
+
8
+ import httpx
9
+
10
+ from . import extractors, llm, storage
11
+
12
+
13
+ def _score(lead: dict) -> int:
14
+ pontos = 0
15
+ if lead.get("telefone"):
16
+ pontos += 20
17
+ if lead.get("email"):
18
+ pontos += 20
19
+ if lead.get("empresa") or lead.get("cnpj"):
20
+ pontos += 15
21
+ if lead.get("cidade") or lead.get("endereco"):
22
+ pontos += 10
23
+ if lead.get("observacoes"):
24
+ pontos += 10
25
+ if lead.get("tags"):
26
+ pontos += 5
27
+ base = int(lead.get("score") or 0)
28
+ return min(100, max(base, pontos))
29
+
30
+
31
+ def sugerir_movimentacoes() -> dict:
32
+ """Heurísticas + (opcional) refinamento por LLM. Retorna {sugestoes: [...]}."""
33
+ leads = storage.list_leads()
34
+ sugestoes = []
35
+
36
+ for lead in leads:
37
+ etapa = lead.get("etapa", "leads")
38
+ score = _score(lead)
39
+ contato_completo = bool(lead.get("telefone")) or bool(lead.get("email"))
40
+
41
+ if etapa == "leads" and contato_completo and score >= 60:
42
+ sugestoes.append(
43
+ {
44
+ "lead_id": lead["id"],
45
+ "lead_nome": lead.get("nome"),
46
+ "de": "leads",
47
+ "para": "em_contato",
48
+ "confianca": min(0.95, score / 100),
49
+ "motivo": "Contato completo e score alto — pronto para o primeiro contato.",
50
+ }
51
+ )
52
+ elif etapa == "em_contato" and lead.get("observacoes", "").strip():
53
+ sugestoes.append(
54
+ {
55
+ "lead_id": lead["id"],
56
+ "lead_nome": lead.get("nome"),
57
+ "de": "em_contato",
58
+ "para": "proposta",
59
+ "confianca": 0.7,
60
+ "motivo": "Já existe histórico de conversa (observações preenchidas) — hora da proposta.",
61
+ }
62
+ )
63
+ elif etapa == "proposta" and score >= 85:
64
+ sugestoes.append(
65
+ {
66
+ "lead_id": lead["id"],
67
+ "lead_nome": lead.get("nome"),
68
+ "de": "proposta",
69
+ "para": "fechado",
70
+ "confianca": 0.8,
71
+ "motivo": "Proposta madura com dados completos — chegou a hora de fechar.",
72
+ }
73
+ )
74
+
75
+ if llm.configurado() and sugestoes:
76
+ try:
77
+ texto_leads = "\n".join(
78
+ f"- {s['lead_nome']}: {s['de']} → {s['para']} (motivo heurístico: {s['motivo']})"
79
+ for s in sugestoes[:15]
80
+ )
81
+ resposta = llm.chat(
82
+ [
83
+ {
84
+ "role": "system",
85
+ "content": (
86
+ "Você é um revisor de CRM. Avalie as sugestões de movimentação de leads. "
87
+ 'Responda APENAS JSON: {"aprovadas": [índices numéricos]} '
88
+ "com os índices (0-based) das sugestões que fazem sentido comercialmente."
89
+ ),
90
+ },
91
+ {"role": "user", "content": texto_leads},
92
+ ],
93
+ temperature=0.2,
94
+ max_tokens=200,
95
+ )
96
+ import json as _json
97
+
98
+ dados = _json.loads(resposta[resposta.find("{") :])
99
+ aprovados = set(dados.get("aprovadas", []))
100
+ for i, s in enumerate(sugestoes):
101
+ if i not in aprovados:
102
+ s["motivo"] += " (IA não endossou — confira antes de aplicar)"
103
+ s["confianca"] *= 0.5
104
+ except Exception:
105
+ pass # IA é refinamento, não requisito — heurísticas já valem
106
+
107
+ return {"sugestoes": sugestoes[:20]}
108
+
109
+
110
+ def enriquecer_lead(lead_id: str) -> dict:
111
+ """Enriquece um lead: CNPJ via BrasilAPI, score recalculado e notas."""
112
+ lead = None
113
+ for l in storage.list_leads():
114
+ if l.get("id") == lead_id:
115
+ lead = l
116
+ break
117
+ if not lead:
118
+ return {"ok": False, "mensagem": "Lead não encontrado."}
119
+
120
+ patch, notas = {}, []
121
+
122
+ if lead.get("cnpj") and not lead.get("empresa"):
123
+ try:
124
+ dados = extractors.consulta_cnpj(lead["cnpj"])
125
+ for campo in ("empresa", "telefone", "endereco", "cidade", "email"):
126
+ if not lead.get(campo) and dados.get(campo):
127
+ patch[campo] = dados[campo]
128
+ notas.append("Dados de CNPJ preenchidos via BrasilAPI.")
129
+ except extractors.ExtratorErro as e:
130
+ notas.append(f"CNPJ não enriquecido: {e}")
131
+
132
+ patch["score"] = _score({**lead, **patch})
133
+
134
+ if llm.configurado():
135
+ try:
136
+ resposta = llm.chat(
137
+ [
138
+ {
139
+ "role": "system",
140
+ "content": (
141
+ "Você é assistente comercial. Responda APENAS JSON: "
142
+ '{"resumo": "uma frase", "tags": ["até 3 tags"]}'
143
+ ),
144
+ },
145
+ {
146
+ "role": "user",
147
+ "content": (
148
+ f"Lead: {lead.get('nome')} | empresa: {lead.get('empresa')} | cidade: "
149
+ f"{lead.get('cidade')} | fonte: {lead.get('fonte')} | observações: "
150
+ f"{lead.get('observacoes')}"
151
+ ),
152
+ },
153
+ ],
154
+ temperature=0.3,
155
+ max_tokens=200,
156
+ )
157
+ import json as _json
158
+
159
+ dados = _json.loads(resposta[resposta.find("{") :])
160
+ if dados.get("resumo"):
161
+ patch["resumo_ia"] = dados["resumo"]
162
+ notas.append("Resumo gerado por IA.")
163
+ if dados.get("tags"):
164
+ patch["tags"] = dados["tags"][:3]
165
+ except Exception:
166
+ notas.append("IA indisponível no momento — enriquecimento apenas com dados locais.")
167
+
168
+ atualizado = storage.update_lead(lead_id, patch)
169
+ return {"ok": True, "lead": atualizado, "notas": notas}
170
+
171
+
172
+ def calcular_completude(lead: dict) -> int:
173
+ campos = ["nome", "telefone", "email", "empresa", "cidade", "observacoes"]
174
+ preenchidos = sum(1 for c in campos if (lead.get(c) or "").strip())
175
+ return int(round(preenchidos / len(campos) * 100))