@danhachuel/prodmaker 0.1.8 → 0.1.10

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 CHANGED
@@ -3,6 +3,44 @@
3
3
  Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
4
4
  (o registry NPM normaliza `0.1.00` → `0.1.0`).
5
5
 
6
+ ## 0.1.10 — 2026-10-30
7
+
8
+ - **Parte 8 — AdsBot (Lançamento Assistido) + Dashboard real + Notificações (adsNotify).**
9
+ - **AdsBot:** wizard de 6 etapas (Produto → Página → Publicar → Campanhas → Tracking →
10
+ Conclusão) com progresso salvo no banco e retomável; marcação de etapas concluídas;
11
+ **Relatório de Lançamento** (produto + resumo da análise IA, páginas vinculadas com URLs
12
+ públicas, campanhas com CPA/orçamento e o checklist final do seed).
13
+ - **Dashboard com dados reais:** conversões, receita, comissão, visitas, produtos, páginas,
14
+ campanhas e infoprodutos; banner do lançamento em andamento ("faltam X passos" — efeito
15
+ meta); últimas vendas e últimas páginas. ROAS explicitamente ausente até haver registro
16
+ de investimento (nunca inventado).
17
+ - **Notificações (adsNotify):** toggles por evento (venda recebida, campanha publicada,
18
+ página publicada, infoproduto gerado), canais apprise por URL (Telegram entra
19
+ automaticamente quando Bot Token + Chat ID estão em Chaves de API), teste de envio e
20
+ histórico local com status/erro. Hooks disparados nos fluxos reais — falha de envio
21
+ nunca quebra a operação principal.
22
+ - Novas tabelas: `lancamentos`, `notificacoes`. Novos endpoints: `/api/lancamentos/*`,
23
+ `/api/notificacoes/*`, `/api/dashboard`.
24
+
25
+ ## 0.1.09 — 2026-10-30
26
+
27
+ - **Parte 7 — AdsWords & AdsCreator (palavras-chave e campanhas).**
28
+ - **AdsWords funcional:** geração de palavras-chave + negativas por IA (prompt do seed,
29
+ negativas padrão aplicadas automaticamente), listas por produto (positivas/negativas com
30
+ origem IA/manual), adição manual, remoção e cópia.
31
+ - **Gerador de criativos por IA** (fórmula gancho 3s + promessa + prova + CTA) reutilizado
32
+ pelo AdsCreator (Google/Meta/TikTok).
33
+ - **AdsCreator — construtor de campanhas:** cards por plataforma, formulário que puxa
34
+ produto vinculado (comissão → **CPA alvo 70%** no Google; regra criativos × ½ ticket no
35
+ Meta; teste US$10–20 no TikTok), defaults do seed versionados, salva **rascunho** com
36
+ plano completo (review modal com JSON do plano + palavras + criativos).
37
+ - **Publicação real (best-effort):** Google Ads via cadeia de mutates REST (orçamento →
38
+ campanha PAUSADA → grupo → keywords frase + negativas → anúncio RSA), Meta Ads via
39
+ Marketing API (campanha → conjunto → criativo → anúncio, POSTs simples) e TikTok como
40
+ plano manual guiado. Tudo criado **PAUSADO para revisão**; erros por etapa com attribution;
41
+ status por campanha (rascunho/publicada/erro).
42
+ - Novas tabelas: `palavras`, `campanhas`.
43
+
6
44
  ## 0.1.08 — 2026-10-30
7
45
 
8
46
  - **Parte 6 — Minhas Vendas & Conversões GCLID.**
package/app/api/ads.py ADDED
@@ -0,0 +1,155 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from ..services import adswords, campaigns, publish_ads
5
+ from ..services.database import get_db, row_to_dict
6
+ from ..services.llm import LLMError
7
+
8
+ router = APIRouter(prefix="/api", tags=["ads"])
9
+
10
+
11
+ def _produto_or_none(produto_id: int | None) -> dict:
12
+ if not produto_id:
13
+ return {"id": None, "nome": "Produto", "nicho": "", "plataforma": ""}
14
+ with get_db() as conn:
15
+ row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
16
+ return row_to_dict(row) if row else {"id": produto_id, "nome": "Produto", "nicho": "", "plataforma": ""}
17
+
18
+
19
+ class KeywordsBody(BaseModel):
20
+ produto_id: int | None = None
21
+
22
+
23
+ class PalavraBody(BaseModel):
24
+ produto_id: int | None = None
25
+ tipo: str = "positiva"
26
+ texto: str
27
+ correspondencia: str = "frase"
28
+
29
+
30
+ class CriativosBody(BaseModel):
31
+ produto_id: int | None = None
32
+ quantidade: int = 3
33
+
34
+
35
+ class CampanhaBody(BaseModel):
36
+ produto_id: int | None = None
37
+ plataforma: str = "google"
38
+ nome: str = ""
39
+ orcamento_diario: float | None = None
40
+ quantidade_criativos: int | None = None
41
+ keywords: list = []
42
+ negativas: list = []
43
+ criativos: list = []
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # AdsWords
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ @router.post("/adswords/gerar")
52
+ def adswords_gerar(body: KeywordsBody):
53
+ produto = _produto_or_none(body.produto_id)
54
+ try:
55
+ return adswords.gerar(produto)
56
+ except LLMError as exc:
57
+ raise HTTPException(status_code=400, detail=str(exc))
58
+
59
+
60
+ @router.get("/adswords")
61
+ def adswords_listar(produto_id: int | None = None):
62
+ return adswords.listar(produto_id)
63
+
64
+
65
+ @router.post("/adswords")
66
+ def adswords_adicionar(body: PalavraBody):
67
+ if not body.texto.strip():
68
+ raise HTTPException(status_code=400, detail="Informe a palavra.")
69
+ if body.tipo not in ("positiva", "negativa"):
70
+ raise HTTPException(status_code=400, detail="Tipo inválido.")
71
+ return adswords.adicionar_manual(_produto_or_none(body.produto_id), body.tipo, body.texto, body.correspondencia)
72
+
73
+
74
+ @router.delete("/adswords/{palavra_id}")
75
+ def adswords_remover(palavra_id: int):
76
+ try:
77
+ adswords.remover(palavra_id)
78
+ except LLMError as exc:
79
+ raise HTTPException(status_code=404, detail=str(exc))
80
+ return {"ok": True}
81
+
82
+
83
+ @router.post("/criativos/gerar")
84
+ def criativos_gerar(body: CriativosBody):
85
+ produto = _produto_or_none(body.produto_id)
86
+ try:
87
+ return adswords.gerar_criativos(produto, body.quantidade)
88
+ except LLMError as exc:
89
+ raise HTTPException(status_code=400, detail=str(exc))
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Campanhas (AdsCreator)
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ @router.get("/campanhas")
98
+ def campanhas_listar(produto_id: int | None = None):
99
+ return campaigns.listar(produto_id)
100
+
101
+
102
+ @router.post("/campanhas")
103
+ def campanhas_criar(body: CampanhaBody):
104
+ produto = _produto_or_none(body.produto_id)
105
+ try:
106
+ plano = campaigns.construir(produto, body.plataforma, body.model_dump())
107
+ if body.nome.strip():
108
+ plano["nome"] = body.nome.strip()
109
+ except LLMError as exc:
110
+ raise HTTPException(status_code=400, detail=str(exc))
111
+ return campaigns.salvar(plano)
112
+
113
+
114
+ @router.get("/campanhas/{campanha_id}")
115
+ def campanhas_get(campanha_id: int):
116
+ try:
117
+ return campaigns.get(campanha_id)
118
+ except LLMError as exc:
119
+ raise HTTPException(status_code=404, detail=str(exc))
120
+
121
+
122
+ @router.delete("/campanhas/{campanha_id}")
123
+ def campanhas_remover(campanha_id: int):
124
+ try:
125
+ campaigns.remover(campanha_id)
126
+ except LLMError as exc:
127
+ raise HTTPException(status_code=404, detail=str(exc))
128
+ return {"ok": True}
129
+
130
+
131
+ @router.post("/campanhas/{campanha_id}/publicar")
132
+ def campanhas_publicar(campanha_id: int):
133
+ try:
134
+ campanha = campaigns.get(campanha_id)
135
+ except LLMError as exc:
136
+ raise HTTPException(status_code=404, detail=str(exc))
137
+ if campanha["status"] == "publicada":
138
+ raise HTTPException(status_code=400, detail="Campanha já publicada.")
139
+ plataforma = campanha["plataforma"]
140
+ try:
141
+ if plataforma == "google":
142
+ resultado = publish_ads.publicar_google(campanha)
143
+ elif plataforma == "meta":
144
+ resultado = publish_ads.publicar_meta(campanha)
145
+ else:
146
+ resultado = {
147
+ "plataforma": "tiktok",
148
+ "etapas": [{"etapa": "manual", "observacao": "TikTok: siga o checklist do plano (formato 9:16 UGC) e crie manualmente no TikTok Ads Manager."}],
149
+ "observacao": "Plano manual — crie manualmente no TikTok Ads Manager.",
150
+ }
151
+ except LLMError as exc:
152
+ campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
153
+ raise HTTPException(status_code=400, detail=str(exc))
154
+ campanha = campaigns.marcar_publicada(campanha_id, resultado)
155
+ return campanha
@@ -0,0 +1,67 @@
1
+ """Dashboard — agregação de dados reais de todos os módulos."""
2
+ from fastapi import APIRouter
3
+
4
+ from ..services.database import get_db
5
+
6
+ router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
7
+
8
+
9
+ @router.get("")
10
+ def dashboard():
11
+ with get_db() as conn:
12
+ produtos = {
13
+ "total": conn.execute("SELECT COUNT(*) AS n FROM produtos").fetchone()["n"],
14
+ "aprovados": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'aprovado'").fetchone()["n"],
15
+ "em_analise": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'em_analise'").fetchone()["n"],
16
+ }
17
+ paginas = {
18
+ "total": conn.execute("SELECT COUNT(*) AS n FROM paginas").fetchone()["n"],
19
+ "publicadas": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status = 'publicada'").fetchone()["n"],
20
+ "rascunho": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status != 'publicada'").fetchone()["n"],
21
+ }
22
+ campanhas = {
23
+ "total": conn.execute("SELECT COUNT(*) AS n FROM campanhas").fetchone()["n"],
24
+ "publicadas": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'publicada'").fetchone()["n"],
25
+ "rascunho": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'rascunho'").fetchone()["n"],
26
+ "erro": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'erro'").fetchone()["n"],
27
+ }
28
+ vendas_row = conn.execute(
29
+ "SELECT COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita, COALESCE(SUM(comissao),0) AS comissao FROM vendas WHERE status = 'aprovada'"
30
+ ).fetchone()
31
+ infoprodutos = {"total": conn.execute("SELECT COUNT(*) AS n FROM infoprodutos").fetchone()["n"]}
32
+ tracking = {
33
+ "visitas": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'pageview'").fetchone()["n"],
34
+ "cliques": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'cta_click'").fetchone()["n"],
35
+ "conversoes_pendentes": conn.execute(
36
+ "SELECT COUNT(*) AS n FROM vendas WHERE status = 'aprovada' AND gclid != '' AND conversao_status != 'enviada'"
37
+ ).fetchone()["n"],
38
+ }
39
+ ultimas_vendas = [
40
+ {key: row[key] for key in row.keys()}
41
+ for row in conn.execute("SELECT id, produto_nome, valor, fonte, status, created_at FROM vendas ORDER BY id DESC LIMIT 5")
42
+ ]
43
+ ultimas_paginas = [
44
+ {key: row[key] for key in row.keys()}
45
+ for row in conn.execute("SELECT id, titulo, tipo, status, created_at FROM paginas ORDER BY id DESC LIMIT 5")
46
+ ]
47
+
48
+ from ..services.lancamentos import atual, progresso
49
+ lancamento_atual = atual()
50
+ progresso_atual = progresso(lancamento_atual) if lancamento_atual else None
51
+
52
+ return {
53
+ "produtos": produtos,
54
+ "paginas": paginas,
55
+ "campanhas": campanhas,
56
+ "infoprodutos": infoprodutos,
57
+ "vendas": {
58
+ "total": vendas_row["n"],
59
+ "receita": round(vendas_row["receita"], 2),
60
+ "comissao": round(vendas_row["comissao"], 2),
61
+ },
62
+ "tracking": tracking,
63
+ "ultimas_vendas": ultimas_vendas,
64
+ "ultimas_paginas": ultimas_paginas,
65
+ "lancamento": lancamento_atual,
66
+ "progresso": progresso_atual,
67
+ }
@@ -88,7 +88,16 @@ def create_infoproduto(body: InfoprodutoBody):
88
88
  (provider, rel_pdf, rel_md, record_id),
89
89
  )
90
90
  row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
91
- return row_to_dict(row)
91
+ record = row_to_dict(row)
92
+
93
+ from ..services.notify import notify_event
94
+
95
+ notify_event(
96
+ "infoproduto_gerado",
97
+ "Infoproduto gerado",
98
+ f"{record['titulo']} ({record['formato']}) — {record['provider']}",
99
+ )
100
+ return record
92
101
  except (LLMError, gamma_service.GammaError, RuntimeError) as exc:
93
102
  with get_db() as conn:
94
103
  conn.execute("DELETE FROM infoprodutos WHERE id = ?", (record_id,))
@@ -0,0 +1,81 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from ..services import lancamentos
5
+ from ..services.llm import LLMError
6
+
7
+ router = APIRouter(prefix="/api/lancamentos", tags=["lancamentos"])
8
+
9
+
10
+ class IniciarBody(BaseModel):
11
+ produto_id: int | None = None
12
+
13
+
14
+ class EtapaBody(BaseModel):
15
+ etapa: int
16
+ dados: dict = {}
17
+
18
+
19
+ class ProdutoBody(BaseModel):
20
+ produto_id: int
21
+
22
+
23
+ @router.get("/etapas")
24
+ def etapas():
25
+ return lancamentos.ETAPAS
26
+
27
+
28
+ @router.get("/atual")
29
+ def lancamento_atual():
30
+ return lancamentos.atual()
31
+
32
+
33
+ @router.get("")
34
+ def listar():
35
+ return lancamentos.listar()
36
+
37
+
38
+ @router.post("")
39
+ def iniciar(body: IniciarBody):
40
+ return lancamentos.iniciar(body.produto_id)
41
+
42
+
43
+ @router.post("/{lancamento_id}/etapa")
44
+ def salvar_etapa(lancamento_id: int, body: EtapaBody):
45
+ try:
46
+ return lancamentos.salvar_etapa(lancamento_id, body.etapa, body.dados)
47
+ except LLMError as exc:
48
+ raise HTTPException(status_code=400, detail=str(exc))
49
+
50
+
51
+ @router.post("/{lancamento_id}/produto")
52
+ def definir_produto(lancamento_id: int, body: ProdutoBody):
53
+ try:
54
+ return lancamentos.definir_produto(lancamento_id, body.produto_id)
55
+ except LLMError as exc:
56
+ raise HTTPException(status_code=404, detail=str(exc))
57
+
58
+
59
+ @router.get("/{lancamento_id}/relatorio")
60
+ def relatorio(lancamento_id: int):
61
+ try:
62
+ return lancamentos.relatorio(lancamento_id)
63
+ except LLMError as exc:
64
+ raise HTTPException(status_code=404, detail=str(exc))
65
+
66
+
67
+ @router.get("/{lancamento_id}/progresso")
68
+ def progresso(lancamento_id: int):
69
+ try:
70
+ record = lancamentos._get_row(lancamento_id)
71
+ except LLMError as exc:
72
+ raise HTTPException(status_code=404, detail=str(exc))
73
+ return lancamentos.progresso(record)
74
+
75
+
76
+ @router.post("/{lancamento_id}/concluir")
77
+ def concluir(lancamento_id: int):
78
+ try:
79
+ return lancamentos.concluir(lancamento_id)
80
+ except LLMError as exc:
81
+ raise HTTPException(status_code=404, detail=str(exc))
@@ -0,0 +1,40 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from ..services import notify
5
+ from ..services.llm import LLMError
6
+
7
+ router = APIRouter(prefix="/api/notificacoes", tags=["notificacoes"])
8
+
9
+
10
+ class ConfigBody(BaseModel):
11
+ eventos: dict = {}
12
+ targets: list = []
13
+
14
+
15
+ class TesteBody(BaseModel):
16
+ titulo: str = "Teste ProdMaker"
17
+ mensagem: str = "Notificação de teste do ProdMaker."
18
+
19
+
20
+ @router.get("")
21
+ def config():
22
+ return notify.listar_config()
23
+
24
+
25
+ @router.put("")
26
+ def salvar(body: ConfigBody):
27
+ return notify.salvar_config(body.eventos, body.targets)
28
+
29
+
30
+ @router.get("/historico")
31
+ def historico():
32
+ return notify.historico()
33
+
34
+
35
+ @router.post("/teste")
36
+ def teste(body: TesteBody):
37
+ try:
38
+ return notify.enviar("teste", body.titulo, body.mensagem, forcar=True)
39
+ except LLMError as exc:
40
+ raise HTTPException(status_code=400, detail=str(exc))
@@ -130,6 +130,14 @@ def publicar(page_id: int):
130
130
  )
131
131
  updated = _get_or_404(page_id)
132
132
  updated["publish_result"] = result
133
+
134
+ from ..services.notify import notify_event
135
+
136
+ notify_event(
137
+ "pagina_publicada",
138
+ "Página publicada",
139
+ f"{updated['titulo']} — {result['pages_url']}",
140
+ )
133
141
  return updated
134
142
 
135
143
 
package/app/main.py CHANGED
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
15
15
  from .services.llm import LLMError, analyze_product, list_providers
16
16
  from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
17
17
 
18
- APP_VERSION = "0.1.08"
18
+ APP_VERSION = "0.1.10"
19
19
  COOKIE_NAME = "prodmaker_session"
20
20
  SESSION_DAYS = 30
21
21
  MASTER_ALIAS = "admin"
@@ -31,6 +31,10 @@ from .api import paginas as paginas_router # noqa: E402
31
31
  from .api import dominios as dominios_router # noqa: E402
32
32
  from .api import tracking as tracking_router # noqa: E402
33
33
  from .api import vendas as vendas_router # noqa: E402
34
+ from .api import ads as ads_router # noqa: E402
35
+ from .api import lancamentos as lancamentos_router # noqa: E402
36
+ from .api import notificacoes as notificacoes_router # noqa: E402
37
+ from .api import dashboard as dashboard_router # noqa: E402
34
38
 
35
39
  app.include_router(llm_router.router)
36
40
  app.include_router(infoprodutos_router.router)
@@ -38,6 +42,10 @@ app.include_router(paginas_router.router)
38
42
  app.include_router(dominios_router.router)
39
43
  app.include_router(tracking_router.router)
40
44
  app.include_router(vendas_router.router)
45
+ app.include_router(ads_router.router)
46
+ app.include_router(lancamentos_router.router)
47
+ app.include_router(notificacoes_router.router)
48
+ app.include_router(dashboard_router.router)
41
49
 
42
50
  ensure_db()
43
51
 
@@ -244,7 +252,7 @@ def health():
244
252
 
245
253
  @app.get("/api/version")
246
254
  def version():
247
- return {"version": APP_VERSION, "canonical": "0.1.8"}
255
+ return {"version": APP_VERSION, "canonical": "0.1.10"}
248
256
 
249
257
 
250
258
  @app.get("/api/settings")
@@ -0,0 +1,158 @@
1
+ """AdsWords — geração de palavras-chave e negativas por IA + listas por produto."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from .database import get_db, now_str, row_to_dict
6
+ from .llm import LLMError, generate
7
+ from .store import STORAGE
8
+
9
+ SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
10
+ SEED_NEGATIVAS = SEED_DIR / "negatives" / "google-words.txt"
11
+
12
+
13
+ def _seed_negativas() -> list[str]:
14
+ if SEED_NEGATIVAS.exists():
15
+ return [line.strip() for line in SEED_NEGATIVAS.read_text(encoding="utf-8").splitlines() if line.strip()]
16
+ return ["gratis", "download", "torrent", "refund", "scams"]
17
+
18
+
19
+ def gerar(produto: dict) -> dict:
20
+ """Gera palavras-chave e negativas com IA (seed/prompts/keywords.md)."""
21
+ template_path = SEED_DIR / "prompts" / "keywords.md"
22
+ template = template_path.read_text(encoding="utf-8") if template_path.exists() else (
23
+ "Para o produto {NOME_DO_PRODUTO} (nicho {NICHO}) liste palavras-chave de compra e negativas em JSON: "
24
+ '{"principais":[{"palavra":"","correspondencia":"frase"}],"negativas":[{"palavra":""}]}'
25
+ )
26
+ prompt = (
27
+ template.replace("{NOME_DO_PRODUTO}", produto.get("nome") or "")
28
+ .replace("{NICHO}", produto.get("nicho") or "")
29
+ .replace("{PLATAFORMA}", produto.get("plataforma") or "")
30
+ )
31
+ result = generate(
32
+ prompt,
33
+ system="Você é um especialista em Google Ads. Responda SOMENTE com JSON válido, sem markdown.",
34
+ max_tokens=2000,
35
+ )
36
+ try:
37
+ parsed = json.loads(_clean_json(result["text"]))
38
+ except Exception as exc: # noqa: BLE001
39
+ raise LLMError(f"Resposta do LLM não trouxe JSON utilizável: {exc}") from exc
40
+
41
+ positivas = []
42
+ for item in parsed.get("principais") or []:
43
+ text = (item.get("palavra") or "").strip()
44
+ if text:
45
+ positivas.append({"texto": text, "correspondencia": item.get("correspondencia") or "frase"})
46
+ negativas_ia = []
47
+ for item in parsed.get("negativas") or []:
48
+ text = (item.get("palavra") or "").strip()
49
+ if text:
50
+ negativas_ia.append({"texto": text, "correspondencia": "exata"})
51
+ for seed_word in _seed_negativas():
52
+ if not any(n["texto"] == seed_word for n in negativas_ia):
53
+ negativas_ia.append({"texto": seed_word, "correspondencia": "exata", "seed": True})
54
+
55
+ salvar(produto, positivas, negativas_ia, origem="ia")
56
+ return {"positivas": positivas, "negativas": negativas_ia, "provider": result["provider"]}
57
+
58
+
59
+ def _clean_json(text: str) -> str:
60
+ import re
61
+
62
+ cleaned = re.sub(r"```(?:json)?", "", text).strip()
63
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
64
+ return match.group(0) if match else cleaned
65
+
66
+
67
+ def salvar(produto: dict, positivas: list, negativas: list, origem: str = "manual") -> None:
68
+ timestamp = now_str()
69
+ with get_db() as conn:
70
+ for item in positivas:
71
+ exists = conn.execute(
72
+ "SELECT id FROM palavras WHERE produto_id = ? AND tipo = 'positiva' AND texto = ?",
73
+ (produto.get("id"), item["texto"]),
74
+ ).fetchone()
75
+ if not exists:
76
+ conn.execute(
77
+ "INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, 'positiva', ?, ?, ?, ?)",
78
+ (produto.get("id"), produto.get("nome") or "", item["texto"], item.get("correspondencia", "frase"), origem, timestamp),
79
+ )
80
+ for item in negativas:
81
+ exists = conn.execute(
82
+ "SELECT id FROM palavras WHERE produto_id = ? AND tipo = 'negativa' AND texto = ?",
83
+ (produto.get("id"), item["texto"]),
84
+ ).fetchone()
85
+ if not exists:
86
+ conn.execute(
87
+ "INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, 'negativa', ?, ?, ?, ?)",
88
+ (produto.get("id"), produto.get("nome") or "", item["texto"], "exata", origem, timestamp),
89
+ )
90
+
91
+
92
+ def listar(produto_id: int | None = None) -> dict:
93
+ query = "SELECT * FROM palavras"
94
+ params: tuple = ()
95
+ if produto_id:
96
+ query += " WHERE produto_id = ?"
97
+ params = (produto_id,)
98
+ query += " ORDER BY id DESC"
99
+ with get_db() as conn:
100
+ rows = conn.execute(query, params).fetchall()
101
+ items = [row_to_dict(row) for row in rows]
102
+ return {
103
+ "positivas": [item for item in items if item["tipo"] == "positiva"],
104
+ "negativas": [item for item in items if item["tipo"] == "negativa"],
105
+ }
106
+
107
+
108
+ def adicionar_manual(produto: dict, tipo: str, texto: str, correspondencia: str = "frase") -> dict:
109
+ timestamp = now_str()
110
+ with get_db() as conn:
111
+ cursor = conn.execute(
112
+ "INSERT INTO palavras (produto_id, produto_nome, tipo, texto, correspondencia, origem, created_at) VALUES (?, ?, ?, ?, ?, 'manual', ?)",
113
+ (produto.get("id"), produto.get("nome") or "", tipo, texto.strip(), correspondencia, timestamp),
114
+ )
115
+ row = conn.execute("SELECT * FROM palavras WHERE id = ?", (cursor.lastrowid,)).fetchone()
116
+ return row_to_dict(row)
117
+
118
+
119
+ def remover(palavra_id: int) -> None:
120
+ with get_db() as conn:
121
+ cursor = conn.execute("DELETE FROM palavras WHERE id = ?", (palavra_id,))
122
+ if cursor.rowcount == 0:
123
+ raise LLMError("Palavra não encontrada.")
124
+
125
+
126
+ def gerar_criativos(produto: dict, quantidade: int = 3) -> dict:
127
+ """Gera roteiros de criativos por IA (seed/prompts/criativos.md)."""
128
+ template_path = SEED_DIR / "prompts" / "criativos.md"
129
+ template = template_path.read_text(encoding="utf-8") if template_path.exists() else (
130
+ 'Gere {QUANTIDADE} roteiros de criativo para {NOME_DO_PRODUTO} no formato '
131
+ '{"criativos":[{"gancho_3s":"","roteiro":"","texto_na_tela":"","cta":""}]}'
132
+ )
133
+ prompt = (
134
+ template.replace("{QUANTIDADE}", str(quantidade))
135
+ .replace("{NOME_DO_PRODUTO}", produto.get("nome") or "")
136
+ .replace("{DOR}", produto.get("nicho") or "")
137
+ )
138
+ result = generate(
139
+ prompt,
140
+ system="Você é um copywriter de direct response para tráfego pago. Responda SOMENTE com JSON válido.",
141
+ max_tokens=3000,
142
+ )
143
+ try:
144
+ parsed = json.loads(_clean_json(result["text"]))
145
+ except Exception as exc: # noqa: BLE001
146
+ raise LLMError(f"Resposta do LLM não trouxe JSON utilizável: {exc}") from exc
147
+ criativos = parsed.get("criativos") or parsed.get("roteiros") or []
148
+ normalizados = [
149
+ {
150
+ "gancho_3s": item.get("gancho_3s") or "",
151
+ "roteiro": item.get("roteiro") or "",
152
+ "texto_na_tela": item.get("texto_na_tela") or "",
153
+ "cta": item.get("cta") or "Clique no link abaixo e comece agora!",
154
+ }
155
+ for item in criativos
156
+ if isinstance(item, dict)
157
+ ]
158
+ return {"criativos": normalizados, "provider": result["provider"]}