@danhachuel/leadmaster 0.0.0-stage → 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 +87 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/backend/main.py +466 -0
- package/backend/requirements.txt +4 -0
- package/backend/services/__init__.py +1 -0
- package/backend/services/agents.py +235 -0
- package/backend/services/crm_agent.py +175 -0
- package/backend/services/extractors.py +322 -0
- package/backend/services/llm.py +69 -0
- package/backend/services/storage.py +440 -0
- package/frontend/dist/assets/fa-brands-400-D1LuMI3I.ttf +0 -0
- package/frontend/dist/assets/fa-brands-400-D_cYUPeE.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-BjRzuEpd.woff2 +0 -0
- package/frontend/dist/assets/fa-regular-400-DZaxPHgR.ttf +0 -0
- package/frontend/dist/assets/fa-solid-900-CTAAxXor.woff2 +0 -0
- package/frontend/dist/assets/fa-solid-900-D0aA9rwL.ttf +0 -0
- package/frontend/dist/assets/fa-v4compatibility-C9RhG_FT.woff2 +0 -0
- package/frontend/dist/assets/fa-v4compatibility-CCth-dXg.ttf +0 -0
- package/frontend/dist/assets/index-CFQHp6yf.css +5 -0
- package/frontend/dist/assets/index-CPq53JWx.js +90 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/index.html +14 -0
- package/package.json +47 -4
- package/scripts/cli.mjs +512 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Serviços e regras de negócio do LeadMaster.
|
|
@@ -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))
|