@danhachuel/prodmaker 0.1.2 → 0.1.4

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,36 @@
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.04 — 2026-10-25
7
+
8
+ - Favicon 🚀 (SVG) nas guias do navegador: app (SPA), login e páginas `/legal`.
9
+
10
+ ## 0.1.03 — 2026-10-25
11
+
12
+ - **Parte 3 — Motor LLM + Chaves de API + Análise de Produto por IA + Studio de PDF.**
13
+ - **Motor LLM (`app/services/llm.py`):** pool NVIDIA NIM (compatível OpenAI) + Google Gemini
14
+ com prioridade (1 = primeiro) e failover apenas em erros transitórios (quota, timeout,
15
+ transporte, 429/5xx); erro de credencial/payload registrado com attribution e nunca
16
+ mascarado. Importações preguiçosas — o servidor sobe mesmo sem deps de LLM instaladas.
17
+ - **Chaves de API funcional:** página com cards (AdsX api-grid) para NVIDIA, Gemini, Gamma,
18
+ GitHub, Cloudflare, Telegram, Pinterest, Google Ads, Meta, TikTok e Canva (em breve);
19
+ modais por card; LLMs com modelo/prioridade/ativo + **Testar chamada** (read-only, mostra
20
+ latência e modelo); demais integrações com campos por serviço salvos no storage local.
21
+ - **Análise de produto por IA:** ao **✓ Aprovar**, se houver LLM ativo o produto recebe o
22
+ plano de ataque (resumo, ângulos de copy, palavras-chave, negativas, brief da presell,
23
+ público) via `seed/prompts/analise-produto.md`; modal 🧠 na listagem e regeneração via 🔬.
24
+ Sem LLM configurado a aprovação funciona normalmente (sem travas).
25
+ - **Studio (Criar Infoproduto):** formulário completo (modo, formato, título, nicho, dor,
26
+ avatar, resultado, prompt) → Modo Rápido (LLM → Markdown → HTML A4 → PDF via xhtml2pdf,
27
+ sem dependências de sistema) e Modo Premium (Gamma AI: `POST /v1.0/generations` com
28
+ `exportAs: "pdf"`, polling e download); resultado com download direto do PDF.
29
+ - **Biblioteca de Infoprodutos:** tabela (criado, título, formato, modo) com download de PDF,
30
+ visualização do Markdown e exclusão.
31
+ - Backend reorganizado em módulos: `services/store.py` (settings), `services/database.py`
32
+ (SQLite + migração: coluna `analysis` em produtos + tabela `infoprodutos`),
33
+ `services/llm.py`, `services/pdf.py`, `services/gamma.py`, `api/llm.py`, `api/infoprodutos.py`.
34
+ - requirements.txt: + openai, google-genai, markdown, xhtml2pdf.
35
+
6
36
  ## 0.1.02 — 2026-10-25
7
37
 
8
38
  - Launcher: caixa da URL em ASCII puro (evita mojibake de caracteres Unicode no CMD/Windows).
@@ -10,6 +10,14 @@ Esta lista cresce a cada fase conforme novas integrações são ativadas.
10
10
  - **Pydantic** — MIT License — https://github.com/pydantic/pydantic
11
11
  - **Node.js** — MIT License — https://nodejs.org
12
12
 
13
+ ## Runtime (Parte 3)
14
+
15
+ - **openai (SDK Python)** — Apache-2.0 — https://github.com/openai/openai-python (usado no endpoint compatível com OpenAI da NVIDIA NIM)
16
+ - **google-genai (SDK oficial Google Gemini)** — Apache-2.0 — https://github.com/googleapis/python-genai
17
+ - **Markdown** — BSD-3-Clause — https://github.com/Python-Markdown/markdown
18
+ - **xhtml2pdf** (inclui ReportLab) — MIT/Apache-2.0 — https://github.com/xhtml2pdf/xhtml2pdf
19
+ - **httpx** (transitivo do openai; usado pelo cliente Gamma) — BSD-3-Clause — https://github.com/encode/httpx
20
+
13
21
  ## Integrações planejadas (fases futuras)
14
22
 
15
23
  - **google-ads-mcp-rw** — Google Ads MCP (write) — https://pypi.org/project/google-ads-mcp-rw
@@ -0,0 +1 @@
1
+ # Torna `app.api` um pacote Python.
@@ -0,0 +1,141 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+
7
+ from ..services.database import get_db, now_str, row_to_dict
8
+ from ..services.llm import LLMError, generate
9
+ from ..services.pdf import markdown_para_pdf
10
+ from ..services.store import STORAGE
11
+ from ..services import gamma as gamma_service
12
+
13
+ router = APIRouter(prefix="/api/infoprodutos", tags=["infoprodutos"])
14
+
15
+ SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
16
+
17
+
18
+ class InfoprodutoBody(BaseModel):
19
+ titulo: str
20
+ prompt: str
21
+ formato: str = "E-book"
22
+ nicho: str = ""
23
+ dor: str = ""
24
+ avatar: str = ""
25
+ resultado: str = ""
26
+ modo: str = "rapido" # rapido (LLM+PDF local) | gamma (API premium)
27
+
28
+
29
+ def _build_prompt(body: InfoprodutoBody) -> str:
30
+ template_path = SEED_DIR / "prompts" / "infoproduto.md"
31
+ template = template_path.read_text(encoding="utf-8") if template_path.exists() else ""
32
+ return (
33
+ template.replace("{FORMATO}", body.formato)
34
+ .replace("{NICHO}", body.nicho)
35
+ .replace("{DOR}", body.dor)
36
+ .replace("{AVATAR}", body.avatar)
37
+ .replace("{RESULTADO}", body.resultado)
38
+ + f"\n\n**Título desejado:** {body.titulo}\n**Instrução adicional do usuário:** {body.prompt}\n"
39
+ )
40
+
41
+
42
+ @router.post("")
43
+ def create_infoproduto(body: InfoprodutoBody):
44
+ if not body.titulo.strip():
45
+ raise HTTPException(status_code=400, detail="Informe o título do infoproduto.")
46
+ timestamp = now_str()
47
+ with get_db() as conn:
48
+ cursor = conn.execute(
49
+ "INSERT INTO infoprodutos (titulo, formato, nicho, modo, provider, created_at) VALUES (?, ?, ?, ?, '', ?)",
50
+ (body.titulo.strip(), body.formato, body.nicho.strip(), body.modo, timestamp),
51
+ )
52
+ record_id = cursor.lastrowid
53
+
54
+ folder = STORAGE / "produtos" / str(record_id)
55
+ try:
56
+ if body.modo == "gamma":
57
+ from ..services.store import get_settings
58
+
59
+ gamma_keys = (get_settings().get("api_keys") or {}).get("gamma") or {}
60
+ api_key = (gamma_keys.get("api_key") or "").strip()
61
+ base_url = (gamma_keys.get("base_url") or "").strip()
62
+ if not api_key:
63
+ raise LLMError("Modo Premium (Gamma) requer a API key da Gamma — configure em Chaves de API.")
64
+ pdf_bytes = gamma_service.gerar_pdf_gamma(api_key, base_url, body.titulo.strip(), _build_prompt(body))
65
+ folder.mkdir(parents=True, exist_ok=True)
66
+ pdf_path = folder / "infoproduto.pdf"
67
+ pdf_path.write_bytes(pdf_bytes)
68
+ md_path = None
69
+ provider = "gamma"
70
+ else:
71
+ result = generate(
72
+ _build_prompt(body),
73
+ system="Você é um especialista em criação de infoprodutos low-ticket. Gere conteúdo prático e direto em Markdown.",
74
+ max_tokens=8000,
75
+ )
76
+ md_text = result["text"]
77
+ folder.mkdir(parents=True, exist_ok=True)
78
+ md_path = folder / "infoproduto.md"
79
+ md_path.write_text(md_text, encoding="utf-8")
80
+ pdf_path = markdown_para_pdf(md_text, folder / "infoproduto.pdf")
81
+ provider = result["provider"]
82
+
83
+ rel_pdf = pdf_path.relative_to(STORAGE).as_posix()
84
+ rel_md = md_path.relative_to(STORAGE).as_posix() if md_path else ""
85
+ with get_db() as conn:
86
+ conn.execute(
87
+ "UPDATE infoprodutos SET provider = ?, pdf_path = ?, md_path = ? WHERE id = ?",
88
+ (provider, rel_pdf, rel_md, record_id),
89
+ )
90
+ row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
91
+ return row_to_dict(row)
92
+ except (LLMError, gamma_service.GammaError, RuntimeError) as exc:
93
+ with get_db() as conn:
94
+ conn.execute("DELETE FROM infoprodutos WHERE id = ?", (record_id,))
95
+ raise HTTPException(status_code=400, detail=str(exc))
96
+
97
+
98
+ @router.get("")
99
+ def list_infoprodutos():
100
+ with get_db() as conn:
101
+ rows = conn.execute("SELECT * FROM infoprodutos ORDER BY id DESC").fetchall()
102
+ return [row_to_dict(row) for row in rows]
103
+
104
+
105
+ @router.get("/{infoproduto_id}/download")
106
+ def download_infoproduto(infoproduto_id: int):
107
+ row = _get_or_404(infoproduto_id)
108
+ pdf_path = STORAGE / row["pdf_path"]
109
+ if not row["pdf_path"] or not pdf_path.exists():
110
+ raise HTTPException(status_code=404, detail="PDF não encontrado.")
111
+ return FileResponse(pdf_path, filename=f"{row['titulo']}.pdf", media_type="application/pdf")
112
+
113
+
114
+ @router.get("/{infoproduto_id}/markdown")
115
+ def infoproduto_markdown(infoproduto_id: int):
116
+ row = _get_or_404(infoproduto_id)
117
+ md_path = STORAGE / row["md_path"] if row["md_path"] else None
118
+ if not md_path or not md_path.exists():
119
+ raise HTTPException(status_code=404, detail="Markdown não disponível para este infoproduto.")
120
+ return {"markdown": md_path.read_text(encoding="utf-8")}
121
+
122
+
123
+ @router.delete("/{infoproduto_id}")
124
+ def delete_infoproduto(infoproduto_id: int):
125
+ row = _get_or_404(infoproduto_id)
126
+ for rel in (row["pdf_path"], row["md_path"]):
127
+ if rel:
128
+ path = STORAGE / rel
129
+ if path.exists():
130
+ path.unlink(missing_ok=True)
131
+ with get_db() as conn:
132
+ conn.execute("DELETE FROM infoprodutos WHERE id = ?", (infoproduto_id,))
133
+ return {"ok": True}
134
+
135
+
136
+ def _get_or_404(infoproduto_id: int):
137
+ with get_db() as conn:
138
+ row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (infoproduto_id,)).fetchone()
139
+ if not row:
140
+ raise HTTPException(status_code=404, detail="Infoproduto não encontrado.")
141
+ return row
package/app/api/llm.py ADDED
@@ -0,0 +1,52 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from ..services.llm import LLMError, generate, list_providers, test_provider
5
+ from ..services.store import get_settings, save_settings
6
+
7
+ router = APIRouter(prefix="/api/llm", tags=["llm"])
8
+
9
+
10
+ class TestBody(BaseModel):
11
+ id: str
12
+
13
+
14
+ class GenerateBody(BaseModel):
15
+ prompt: str
16
+ system: str | None = None
17
+ max_tokens: int = 4000
18
+
19
+
20
+ @router.get("/providers")
21
+ def providers():
22
+ """Provedores LLM salvos (cartões de Chaves de API)."""
23
+ return list_providers(active_only=False)
24
+
25
+
26
+ @router.put("/providers")
27
+ def save_providers(payload: dict):
28
+ """Salva a lista de cartões de provedor LLM (substitui settings.llm.providers)."""
29
+ if not isinstance(payload, dict) or not isinstance(payload.get("providers"), list):
30
+ raise HTTPException(status_code=400, detail="Envie { 'providers': [ ... ] }.")
31
+ settings = get_settings()
32
+ settings["llm"] = {"providers": payload["providers"]}
33
+ save_settings(settings)
34
+ return {"ok": True, "providers": settings["llm"]["providers"]}
35
+
36
+
37
+ @router.post("/test")
38
+ def test(body: TestBody):
39
+ """Teste de chamada read-only — valida credencial/modelo no momento da ação."""
40
+ try:
41
+ return test_provider(body.id)
42
+ except LLMError as exc:
43
+ raise HTTPException(status_code=400, detail=str(exc))
44
+
45
+
46
+ @router.post("/generate")
47
+ def generate_text(body: GenerateBody):
48
+ """Geração pelo pool com failover — usada por Produtos, Studio, AdsPages etc."""
49
+ try:
50
+ return generate(body.prompt, system=body.system, max_tokens=body.max_tokens)
51
+ except LLMError as exc:
52
+ raise HTTPException(status_code=400, detail=str(exc))
package/app/main.py CHANGED
@@ -3,7 +3,6 @@ import hashlib
3
3
  import hmac
4
4
  import json
5
5
  import os
6
- import sqlite3
7
6
  import time
8
7
  from pathlib import Path
9
8
 
@@ -12,50 +11,30 @@ from fastapi.responses import FileResponse, HTMLResponse
12
11
  from fastapi.staticfiles import StaticFiles
13
12
  from pydantic import BaseModel
14
13
 
15
- APP_VERSION = "0.1.02"
14
+ from .services.database import ensure_db, get_db, now_str, row_to_dict
15
+ from .services.llm import LLMError, analyze_product, list_providers
16
+ from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_settings, save_settings
17
+
18
+ APP_VERSION = "0.1.04"
16
19
  COOKIE_NAME = "prodmaker_session"
17
20
  SESSION_DAYS = 30
18
21
 
19
- PRODMAKER_HOME = Path(os.environ.get("PRODMAKER_HOME", str(Path.home() / "ProdMaker")))
20
- STORAGE = PRODMAKER_HOME / "storage"
21
- STATE = STORAGE / "state"
22
22
  DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
23
23
  LEGAL_DIR = Path(__file__).resolve().parent.parent / "legal"
24
24
 
25
25
  app = FastAPI(title="ProdMaker", version=APP_VERSION)
26
26
 
27
+ from .api import infoprodutos as infoprodutos_router # noqa: E402
28
+ from .api import llm as llm_router # noqa: E402
27
29
 
28
- # ---------------------------------------------------------------------------
29
- # Storage helpers (leitura protegida: nunca sobrescrever dados corrompidos)
30
- # ---------------------------------------------------------------------------
31
-
32
-
33
- def ensure_state_dir() -> None:
34
- STATE.mkdir(parents=True, exist_ok=True)
35
-
36
-
37
- def get_settings() -> dict:
38
- settings_path = STATE / "settings.json"
39
- if not settings_path.exists():
40
- return {"schema": 1, "port": 8188, "language": "pt-BR", "theme": "light", "api_keys": {}, "llm": {"providers": []}}
41
- try:
42
- return json.loads(settings_path.read_text(encoding="utf-8"))
43
- except json.JSONDecodeError:
44
- raise HTTPException(status_code=500, detail="settings.json corrompido — leitura protegida. Restaure o arquivo manualmente.")
30
+ app.include_router(llm_router.router)
31
+ app.include_router(infoprodutos_router.router)
45
32
 
33
+ ensure_db()
46
34
 
47
- def save_settings(data: dict) -> dict:
48
- ensure_state_dir()
49
- settings_path = STATE / "settings.json"
50
- if settings_path.exists():
51
- try:
52
- json.loads(settings_path.read_text(encoding="utf-8"))
53
- except json.JSONDecodeError:
54
- backup = STATE / "settings.corrupt.json"
55
- backup.write_text(settings_path.read_text(encoding="utf-8", errors="replace"), encoding="utf-8")
56
- raise HTTPException(status_code=500, detail="settings.json corrompido — cópia preservada em settings.corrupt.json.")
57
- settings_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
58
- return data
35
+ # ---------------------------------------------------------------------------
36
+ # Auth local (v1: validação simulada; estrutura pronta para validação remota)
37
+ # ---------------------------------------------------------------------------
59
38
 
60
39
 
61
40
  def get_secret() -> bytes:
@@ -66,54 +45,6 @@ def get_secret() -> bytes:
66
45
  return secret_path.read_bytes()
67
46
 
68
47
 
69
- # ---------------------------------------------------------------------------
70
- # Banco de dados local (SQLite via stdlib)
71
- # ---------------------------------------------------------------------------
72
-
73
-
74
- def get_db():
75
- ensure_state_dir()
76
- conn = sqlite3.connect(STATE / "prodmaker.db")
77
- conn.row_factory = sqlite3.Row
78
- return conn
79
-
80
-
81
- def ensure_db() -> None:
82
- with get_db() as conn:
83
- conn.execute(
84
- """
85
- CREATE TABLE IF NOT EXISTS produtos (
86
- id INTEGER PRIMARY KEY AUTOINCREMENT,
87
- nome TEXT NOT NULL,
88
- plataforma TEXT NOT NULL DEFAULT 'Outro',
89
- link_afiliado TEXT NOT NULL DEFAULT '',
90
- comissao REAL NOT NULL DEFAULT 0,
91
- preco REAL NOT NULL DEFAULT 0,
92
- nicho TEXT NOT NULL DEFAULT '',
93
- status TEXT NOT NULL DEFAULT 'em_analise',
94
- created_at TEXT NOT NULL,
95
- updated_at TEXT NOT NULL
96
- )
97
- """
98
- )
99
-
100
-
101
- ensure_db()
102
-
103
-
104
- def now_str() -> str:
105
- return time.strftime("%Y-%m-%d %H:%M:%S")
106
-
107
-
108
- def row_to_dict(row) -> dict:
109
- return {key: row[key] for key in row.keys()}
110
-
111
-
112
- # ---------------------------------------------------------------------------
113
- # Auth local (v1: validação simulada; estrutura pronta para validação remota)
114
- # ---------------------------------------------------------------------------
115
-
116
-
117
48
  def make_token(email: str) -> str:
118
49
  payload = base64.urlsafe_b64encode(json.dumps({"email": email, "exp": int(time.time()) + SESSION_DAYS * 86400}).encode()).decode()
119
50
  signature = hmac.new(get_secret(), payload.encode(), hashlib.sha256).hexdigest()
@@ -180,7 +111,7 @@ def auth_session(request: Request):
180
111
 
181
112
 
182
113
  # ---------------------------------------------------------------------------
183
- # Produtos (Seus Produtos — em análise / aprovados)
114
+ # Produtos (Seus Produtos — em análise / aprovados, com análise por IA)
184
115
  # ---------------------------------------------------------------------------
185
116
 
186
117
 
@@ -241,12 +172,45 @@ def delete_produto(produto_id: int):
241
172
 
242
173
  @app.post("/api/produtos/{produto_id}/aprovar")
243
174
  def aprovar_produto(produto_id: int):
175
+ """Aprova o produto e, se houver LLM ativo, gera a análise (plano de ataque).
176
+
177
+ Sem LLM configurado a aprovação acontece normalmente (sem travas de pré-registo);
178
+ a análise fica pendente e pode ser gerada depois via /api/produtos/{id}/analisar.
179
+ """
244
180
  with get_db() as conn:
245
181
  cursor = conn.execute("UPDATE produtos SET status = 'aprovado', updated_at = ? WHERE id = ?", (now_str(), produto_id))
246
182
  if cursor.rowcount == 0:
247
183
  raise HTTPException(status_code=404, detail="Produto não encontrado.")
248
184
  row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
249
- return row_to_dict(row)
185
+ produto = row_to_dict(row)
186
+ analysis = None
187
+ if list_providers():
188
+ try:
189
+ analysis = analyze_product(produto)
190
+ with get_db() as conn:
191
+ conn.execute("UPDATE produtos SET analysis = ? WHERE id = ?", (json.dumps(analysis, ensure_ascii=False), produto_id))
192
+ produto["analysis"] = analysis
193
+ except LLMError as exc:
194
+ produto["analysis_error"] = str(exc)
195
+ return produto
196
+
197
+
198
+ @app.post("/api/produtos/{produto_id}/analisar")
199
+ def analisar_produto(produto_id: int):
200
+ """Gera (ou regenera) a análise de IA de um produto aprovado."""
201
+ with get_db() as conn:
202
+ row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
203
+ if not row:
204
+ raise HTTPException(status_code=404, detail="Produto não encontrado.")
205
+ produto = row_to_dict(row)
206
+ try:
207
+ analysis = analyze_product(produto)
208
+ except LLMError as exc:
209
+ raise HTTPException(status_code=400, detail=str(exc))
210
+ with get_db() as conn:
211
+ conn.execute("UPDATE produtos SET analysis = ?, updated_at = ? WHERE id = ?", (json.dumps(analysis, ensure_ascii=False), now_str(), produto_id))
212
+ produto["analysis"] = analysis
213
+ return produto
250
214
 
251
215
 
252
216
  # ---------------------------------------------------------------------------
@@ -261,7 +225,7 @@ def health():
261
225
 
262
226
  @app.get("/api/version")
263
227
  def version():
264
- return {"version": APP_VERSION, "canonical": "0.1.2"}
228
+ return {"version": APP_VERSION, "canonical": "0.1.4"}
265
229
 
266
230
 
267
231
  @app.get("/api/settings")
@@ -304,8 +268,7 @@ def _spa_index():
304
268
  .box{max-width:520px;padding:40px;text-align:center}h1{color:#7C3AED}.muted{color:#64748b}</style></head>
305
269
  <body><div class="box"><h1>🚀 ProdMaker</h1>
306
270
  <p>O backend está rodando.</p>
307
- <p class="muted">O frontend (React + Vite + Tailwind) será publicado na Parte 2.</p>
308
- <p class="muted">API: <code>/api/health</code> · Legal: <code>/legal/</code></p></div></body></html>"""
271
+ <p class="muted">Frontend build pendente — rode <code>npm run build</code> em frontend/.</p></div></body></html>"""
309
272
  )
310
273
 
311
274
 
@@ -0,0 +1 @@
1
+ # Torna `app.services` um pacote Python.
@@ -0,0 +1,61 @@
1
+ """Banco SQLite local (stdlib) — helpers compartilhados."""
2
+ import sqlite3
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .store import STATE
7
+
8
+ DB_PATH = STATE / "prodmaker.db"
9
+
10
+
11
+ def get_db():
12
+ STATE.mkdir(parents=True, exist_ok=True)
13
+ conn = sqlite3.connect(DB_PATH)
14
+ conn.row_factory = sqlite3.Row
15
+ return conn
16
+
17
+
18
+ def ensure_db() -> None:
19
+ with get_db() as conn:
20
+ conn.execute(
21
+ """
22
+ CREATE TABLE IF NOT EXISTS produtos (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ nome TEXT NOT NULL,
25
+ plataforma TEXT NOT NULL DEFAULT 'Outro',
26
+ link_afiliado TEXT NOT NULL DEFAULT '',
27
+ comissao REAL NOT NULL DEFAULT 0,
28
+ preco REAL NOT NULL DEFAULT 0,
29
+ nicho TEXT NOT NULL DEFAULT '',
30
+ status TEXT NOT NULL DEFAULT 'em_analise',
31
+ created_at TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL
33
+ )
34
+ """
35
+ )
36
+ conn.execute(
37
+ """
38
+ CREATE TABLE IF NOT EXISTS infoprodutos (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ titulo TEXT NOT NULL,
41
+ formato TEXT NOT NULL DEFAULT 'E-book',
42
+ nicho TEXT NOT NULL DEFAULT '',
43
+ modo TEXT NOT NULL DEFAULT 'rapido',
44
+ provider TEXT NOT NULL DEFAULT '',
45
+ pdf_path TEXT NOT NULL DEFAULT '',
46
+ md_path TEXT NOT NULL DEFAULT '',
47
+ created_at TEXT NOT NULL
48
+ )
49
+ """
50
+ )
51
+ columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
52
+ if "analysis" not in columns:
53
+ conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
54
+
55
+
56
+ def now_str() -> str:
57
+ return time.strftime("%Y-%m-%d %H:%M:%S")
58
+
59
+
60
+ def row_to_dict(row) -> dict:
61
+ return {key: row[key] for key in row.keys()}
@@ -0,0 +1,64 @@
1
+ """Cliente Gamma AI (modo Premium) — melhor esforço conforme contrato do prompt mestre:
2
+ POST {base}/v1.0/generations (exportAs: "pdf") -> polling -> download do PDF.
3
+ Base URL configurável em Chaves de API (padrão https://api.gamma.app).
4
+ """
5
+ import time
6
+
7
+ import httpx
8
+
9
+
10
+ class GammaError(Exception):
11
+ pass
12
+
13
+
14
+ def _headers(api_key: str) -> dict:
15
+ return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
16
+
17
+
18
+ def gerar_pdf_gamma(api_key: str, base_url: str, titulo: str, prompt: str, total_timeout: float = 600.0) -> bytes:
19
+ base = (base_url or "https://api.app.gamma").rstrip("/")
20
+ started = time.time()
21
+ with httpx.Client(timeout=60.0) as client:
22
+ try:
23
+ response = client.post(
24
+ f"{base}/v1.0/generations",
25
+ headers=_headers(api_key),
26
+ json={"title": titulo, "prompt": prompt, "exportAs": "pdf"},
27
+ )
28
+ except httpx.HTTPError as exc:
29
+ raise GammaError(f"Falha de conexão com a Gamma API: {exc}") from exc
30
+ if response.status_code in (401, 403):
31
+ raise GammaError("Gamma API rejeitou a chave (401/403). Confira a API key em Chaves de API.")
32
+ if response.status_code != 200:
33
+ raise GammaError(f"Gamma API respondeu {response.status_code}: {response.text[:300]}")
34
+ data = response.json()
35
+ generation_id = data.get("id") or data.get("generationId") or (data.get("data") or {}).get("id")
36
+ if not generation_id:
37
+ raise GammaError(f"Resposta inesperada da Gamma API (sem id): {str(data)[:300]}")
38
+
39
+ while time.time() - started < total_timeout:
40
+ try:
41
+ poll = client.get(f"{base}/v1.0/generations/{generation_id}", headers=_headers(api_key))
42
+ except httpx.HTTPError as exc:
43
+ raise GammaError(f"Falha de conexão no polling da Gamma API: {exc}") from exc
44
+ if poll.status_code != 200:
45
+ raise GammaError(f"Polling Gamma API respondeu {poll.status_code}: {poll.text[:300]}")
46
+ info = poll.json()
47
+ status = str(info.get("status") or (info.get("data") or {}).get("status") or "").lower()
48
+ if status in ("ready", "completed", "succeeded", "done"):
49
+ pdf_url = (
50
+ info.get("pdfUrl")
51
+ or info.get("pdf_url")
52
+ or (info.get("data") or {}).get("pdfUrl")
53
+ or info.get("exportUrl")
54
+ )
55
+ if not pdf_url:
56
+ raise GammaError(f"Geração concluída sem URL de PDF: {str(info)[:300]}")
57
+ file_response = client.get(pdf_url)
58
+ if file_response.status_code != 200:
59
+ raise GammaError(f"Download do PDF falhou ({file_response.status_code}).")
60
+ return file_response.content
61
+ if status in ("failed", "error", "cancelled"):
62
+ raise GammaError(f"Geração Gamma falhou: {str(info)[:300]}")
63
+ time.sleep(5)
64
+ raise GammaError("Tempo limite aguardando a Gamma API concluir a geração.")