@danhachuel/prodmaker 0.1.2 → 0.1.3

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,183 @@
1
+ """Cliente LLM unificado — pool NVIDIA NIM (compatível OpenAI) + Google Gemini.
2
+
3
+ Regras do pool:
4
+ - Provedores ativos com API key, ordenados por prioridade (1 = primeiro).
5
+ - Failover SOMENTE em erro transitório (quota, timeout, transporte, 429/5xx).
6
+ - Erro de credencial/payload é registrado com attribution e NÃO é mascarado por outro provider.
7
+ - Importações preguiçosas: o servidor sobe mesmo sem as dependências de LLM instaladas
8
+ (o erro claro aparece apenas no momento da ação que precisa do LLM).
9
+ """
10
+ import json
11
+ import re
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .store import get_settings
16
+
17
+ SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
18
+
19
+
20
+ class LLMError(Exception):
21
+ def __init__(self, message: str, provider: str | None = None, transient: bool = False):
22
+ super().__init__(message)
23
+ self.provider = provider
24
+ self.transient = transient
25
+
26
+
27
+ def list_providers(active_only: bool = True) -> list[dict]:
28
+ settings = get_settings()
29
+ providers = (settings.get("llm") or {}).get("providers") or []
30
+ if active_only:
31
+ providers = [p for p in providers if p.get("active") and (p.get("api_key") or "").strip()]
32
+ return sorted(providers, key=lambda p: int(p.get("priority") or 99))
33
+
34
+
35
+ def find_provider(provider_id: str) -> dict | None:
36
+ for provider in list_providers(active_only=False):
37
+ if provider.get("id") == provider_id:
38
+ return provider
39
+ return None
40
+
41
+
42
+ def _classify_openai_error(exc: Exception) -> bool:
43
+ """True = transitório (pode tentar o próximo provider)."""
44
+ status = getattr(exc, "status_code", None)
45
+ if status in (400, 401, 403, 404, 410):
46
+ return False
47
+ try:
48
+ from openai import AuthenticationError, BadRequestError, NotFoundError
49
+
50
+ if isinstance(exc, (AuthenticationError, BadRequestError, NotFoundError)):
51
+ return False
52
+ except Exception:
53
+ pass
54
+ return True
55
+
56
+
57
+ def _classify_gemini_error(exc: Exception) -> bool:
58
+ code = getattr(exc, "code", None)
59
+ if code is None:
60
+ code = getattr(getattr(exc, "status", None), "value", None)
61
+ if code in (400, 401, 403, 404, 410):
62
+ return False
63
+ return True
64
+
65
+
66
+ def _call_openai_compat(provider: dict, prompt: str, system: str | None, max_tokens: int) -> str:
67
+ from openai import OpenAI
68
+
69
+ client = OpenAI(
70
+ base_url=provider.get("base_url") or "https://integrate.api.nvidia.com/v1",
71
+ api_key=provider["api_key"],
72
+ timeout=90.0,
73
+ )
74
+ messages = []
75
+ if system:
76
+ messages.append({"role": "system", "content": system})
77
+ messages.append({"role": "user", "content": prompt})
78
+ response = client.chat.completions.create(
79
+ model=provider.get("model") or "",
80
+ messages=messages,
81
+ max_tokens=max_tokens,
82
+ temperature=0.7,
83
+ )
84
+ return response.choices[0].message.content or ""
85
+
86
+
87
+ def _call_gemini(provider: dict, prompt: str, system: str | None, max_tokens: int) -> str:
88
+ from google import genai
89
+
90
+ client = genai.Client(api_key=provider["api_key"])
91
+ contents = f"{system}\n\n---\n\n{prompt}" if system else prompt
92
+ response = client.models.generate_content(
93
+ model=provider.get("model") or "gemini-2.5-flash",
94
+ contents=contents,
95
+ )
96
+ return response.text or ""
97
+
98
+
99
+ def call_provider(provider: dict, prompt: str, system: str | None = None, max_tokens: int = 4000) -> str:
100
+ kind = provider.get("kind") or provider.get("id")
101
+ try:
102
+ if kind == "gemini":
103
+ return _call_gemini(provider, prompt, system, max_tokens)
104
+ return _call_openai_compat(provider, prompt, system, max_tokens)
105
+ except Exception as exc: # noqa: BLE001 — classificado abaixo
106
+ if kind == "gemini":
107
+ transient = _classify_gemini_error(exc)
108
+ else:
109
+ transient = _classify_openai_error(exc)
110
+ raise LLMError(
111
+ f"[{provider.get('id')}] {type(exc).__name__}: {exc}",
112
+ provider=provider.get("id"),
113
+ transient=transient,
114
+ ) from exc
115
+
116
+
117
+ def generate(prompt: str, system: str | None = None, max_tokens: int = 4000) -> dict:
118
+ """Gera texto usando o pool de provedores ativos, com failover transitório."""
119
+ providers = list_providers()
120
+ if not providers:
121
+ raise LLMError(
122
+ "Nenhum provedor LLM ativo com API key. Configure NVIDIA NIM ou Gemini em Chaves de API."
123
+ )
124
+ transient_errors = []
125
+ for provider in providers:
126
+ try:
127
+ text = call_provider(provider, prompt, system, max_tokens)
128
+ return {"text": text, "provider": provider.get("id"), "model": provider.get("model")}
129
+ except LLMError as exc:
130
+ if not exc.transient:
131
+ raise
132
+ transient_errors.append(str(exc))
133
+ raise LLMError(
134
+ "Todos os provedores ativos falharam (erros transitórios): " + " | ".join(transient_errors),
135
+ transient=True,
136
+ )
137
+
138
+
139
+ def test_provider(provider_id: str) -> dict:
140
+ provider = find_provider(provider_id)
141
+ if not provider:
142
+ raise LLMError(f"Provedor '{provider_id}' não encontrado nas configurações.")
143
+ if not (provider.get("api_key") or "").strip():
144
+ raise LLMError(f"Provedor '{provider_id}' sem API key configurada.")
145
+ started = time.time()
146
+ text = call_provider(provider, "Responda apenas com a palavra: OK", max_tokens=16)
147
+ latency = int((time.time() - started) * 1000)
148
+ return {
149
+ "ok": True,
150
+ "provider": provider_id,
151
+ "model": provider.get("model"),
152
+ "latency_ms": latency,
153
+ "resposta": (text or "").strip()[:200],
154
+ }
155
+
156
+
157
+ def _parse_json_safely(text: str) -> dict:
158
+ cleaned = re.sub(r"```(?:json)?", "", text).strip()
159
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
160
+ if not match:
161
+ raise LLMError("A resposta do LLM não contém JSON válido.")
162
+ return json.loads(match.group(0))
163
+
164
+
165
+ def analyze_product(produto: dict) -> dict:
166
+ """Análise de produto por IA (plano de ataque) — usa seed/prompts/analise-produto.md."""
167
+ template_path = SEED_DIR / "prompts" / "analise-produto.md"
168
+ template = template_path.read_text(encoding="utf-8") if template_path.exists() else ""
169
+ prompt = (
170
+ template.replace("{NOME}", produto.get("nome") or "")
171
+ .replace("{PLATAFORMA}", produto.get("plataforma") or "")
172
+ .replace("{NICHO}", produto.get("nicho") or "")
173
+ .replace("{PRECO}", str(produto.get("preco") or "—"))
174
+ .replace("{COMISSAO}", str(produto.get("comissao") or "—"))
175
+ .replace("{LINK}", produto.get("link_afiliado") or "")
176
+ )
177
+ result = generate(
178
+ prompt,
179
+ system="Você é um estrategista sênior de marketing de performance e afiliados. Responda SOMENTE com JSON válido, sem markdown e sem comentários.",
180
+ max_tokens=2000,
181
+ )
182
+ parsed = _parse_json_safely(result["text"])
183
+ return {"analise": parsed, "provider": result["provider"]}
@@ -0,0 +1,37 @@
1
+ """Geração de PDF — Markdown -> HTML (estilo A4) -> PDF via xhtml2pdf (puro Python)."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import markdown
6
+
7
+ EMOJI_RE = re.compile("[\U00010000-\U0010FFFF]")
8
+
9
+ A4_CSS = """
10
+ <style>
11
+ @page { size: A4; margin: 2cm; }
12
+ body { font-family: Helvetica; font-size: 11pt; color: #1e293b; line-height: 1.5; }
13
+ h1 { color: #7C3AED; font-size: 20pt; margin-bottom: 6pt; }
14
+ h2 { color: #6D28D9; font-size: 14pt; margin-top: 16pt; }
15
+ h3 { color: #4C1D95; font-size: 12pt; }
16
+ table { border-collapse: collapse; width: 100%; margin-top: 8pt; }
17
+ th, td { border: 1px solid #cbd5e1; padding: 6pt; font-size: 10pt; }
18
+ th { background-color: #ede9fe; }
19
+ blockquote { color: #64748b; }
20
+ li { margin-bottom: 3pt; }
21
+ </style>
22
+ """
23
+
24
+
25
+ def markdown_para_pdf(md_text: str, output_path: Path) -> Path:
26
+ """Converte Markdown em PDF A4. Retorna o caminho do PDF gerado."""
27
+ from xhtml2pdf import pisa
28
+
29
+ clean = EMOJI_RE.sub("", md_text or "")
30
+ body = markdown.markdown(clean, extensions=["extra", "toc"])
31
+ html = f"<html><head><meta charset='utf-8'/>{A4_CSS}</head><body>{body}</body></html>"
32
+ output_path.parent.mkdir(parents=True, exist_ok=True)
33
+ with open(output_path, "wb") as handle:
34
+ status = pisa.CreatePDF(html, dest=handle, encoding="utf-8")
35
+ if status.err:
36
+ raise RuntimeError("Falha ao gerar o PDF (xhtml2pdf).")
37
+ return output_path
@@ -0,0 +1,60 @@
1
+ """Storage e settings locais (compartilhados entre rotas e serviços)."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import HTTPException
7
+
8
+ PRODMAKER_HOME = Path(os.environ.get("PRODMAKER_HOME", str(Path.home() / "ProdMaker")))
9
+ STORAGE = PRODMAKER_HOME / "storage"
10
+ STATE = STORAGE / "state"
11
+
12
+ DEFAULT_SETTINGS = {
13
+ "schema": 1,
14
+ "port": 8188,
15
+ "language": "pt-BR",
16
+ "theme": "light",
17
+ "api_keys": {},
18
+ "llm": {"providers": []},
19
+ }
20
+
21
+
22
+ def ensure_state_dir() -> None:
23
+ STATE.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def get_settings() -> dict:
27
+ settings_path = STATE / "settings.json"
28
+ if not settings_path.exists():
29
+ return json.loads(json.dumps(DEFAULT_SETTINGS))
30
+ try:
31
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
32
+ merged = json.loads(json.dumps(DEFAULT_SETTINGS))
33
+ merged.update(data)
34
+ if not isinstance(merged.get("llm"), dict):
35
+ merged["llm"] = {"providers": []}
36
+ if not isinstance(merged.get("api_keys"), dict):
37
+ merged["api_keys"] = {}
38
+ return merged
39
+ except json.JSONDecodeError:
40
+ raise HTTPException(
41
+ status_code=500,
42
+ detail="settings.json corrompido — leitura protegida. Restaure o arquivo manualmente.",
43
+ )
44
+
45
+
46
+ def save_settings(data: dict) -> dict:
47
+ ensure_state_dir()
48
+ settings_path = STATE / "settings.json"
49
+ if settings_path.exists():
50
+ try:
51
+ json.loads(settings_path.read_text(encoding="utf-8"))
52
+ except json.JSONDecodeError:
53
+ backup = STATE / "settings.corrupt.json"
54
+ backup.write_text(settings_path.read_text(encoding="utf-8", errors="replace"), encoding="utf-8")
55
+ raise HTTPException(
56
+ status_code=500,
57
+ detail="settings.json corrompido — cópia preservada em settings.corrupt.json.",
58
+ )
59
+ settings_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
60
+ return data