@danhachuel/prodmaker 0.1.1 → 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.
- package/CHANGELOG.md +35 -0
- package/MANUAL-INSTALACAO.md +6 -0
- package/THIRD-PARTY-NOTICES.md +8 -0
- package/app/api/__init__.py +1 -0
- package/app/api/infoprodutos.py +141 -0
- package/app/api/llm.py +52 -0
- package/app/main.py +50 -87
- package/app/services/__init__.py +1 -0
- package/app/services/database.py +61 -0
- package/app/services/gamma.py +64 -0
- package/app/services/llm.py +183 -0
- package/app/services/pdf.py +37 -0
- package/app/services/store.py +60 -0
- package/frontend/dist/assets/index-BMQemvVJ.js +67 -0
- package/frontend/dist/assets/index-DZBtvZhA.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/requirements.txt +7 -1
- package/scripts/cli.mjs +7 -6
- package/seed/prompts/analise-produto.md +22 -0
- package/frontend/dist/assets/index-COSPz2F9.css +0 -1
- package/frontend/dist/assets/index-CoHrpz8h.js +0 -67
|
@@ -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
|