@danhachuel/pandanews 0.4.4 → 0.4.6
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 +13 -0
- package/backend/app/main.py +1 -1
- package/backend/core/llm_router.py +85 -41
- package/frontend/dist/assets/{index-BLjBdAFC.js → index-BA6xr3Hb.js} +14 -14
- package/frontend/dist/assets/index-tVJXH-iW.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-CUCWGfn9.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,19 @@ Todas as versões publicadas são registradas aqui antes do publish (regra em AG
|
|
|
14
14
|
- **Guard verificado por mutação**: com a flag removida do `pip`, com um `subprocess.run` direto no backend e com `--reload` no uvicorn, o teste falha em todos os casos (exit 1) — não é carimbo de borracha
|
|
15
15
|
- Nota de transparência: a colisão de versão com o 0.4.03 já publicado no registry foi resolvida por renumeração, conforme a regra de sessões simultâneas do AGENTS.md
|
|
16
16
|
|
|
17
|
+
## 0.4.06 (2026-11-08)
|
|
18
|
+
|
|
19
|
+
- Rótulos de exibição do LLM alinhados ao critério de aceitação: o provedor do Google passa a ser gravado/exposto como **`gemini`** em `noticias.llm_provider` (antes o id interno `google`), e os warning de fallback usam os nomines "NVIDIA NIM" e "Gemini" — `Modelo NVIDIA NIM indisponível (Status 404). Acionando fallback para Gemini.`
|
|
20
|
+
- Nenhuma mudança funcional de roteamento — apenas nomenclatura consistente entre banco, log e UI
|
|
21
|
+
|
|
22
|
+
## 0.4.05 (2026-11-08)
|
|
23
|
+
|
|
24
|
+
- **HOTFIX — fallback à prova de falhas no LLM Router** (crítico): roteamento reescrito em `backend/core/llm_router.py`, agora explícito provedor por provedor na ordem configurada. Qualquer erro HTTP (4xx/5xx, incluindo **404/410** da NVIDIA) é interceptado, registrado (`Modelo nvidia indisponível (Status {status}). Acionando fallback para {próximo}.`) e o próximo provedor é acionado imediatamente — a geração não trava mais. A função devolve qual provedor respondeu (`noticias.llm_provider`) e o incidente fica em `backend.log`
|
|
25
|
+
- **HOTFIX — janelas de CMD na instalação**: os spawns de `tar` (extração do Python portátil), `python -m venv` e `pip install` em `scripts/install.js` nasciam sem `windowsHide` no Windows — origem das múltiplas janelas de terminal. Todos agora usam `SILENT_WINDOWS` (CREATE_NO_WINDOW), com `stdio: 'inherit'` (progresso continua visível no console que o usuário já abriu, sem janela extra). `scripts/build.js` (npm) e o helper `backend/core/proc.py` (subprocessos Python) seguem a mesma regra
|
|
26
|
+
- **Teste de conexão real**: `/api/v1/llm/test` dispara POST `/chat/completions` de verdade; 404/410 devolvem estado **`degraded`** com a mensagem "Sua chave da NVIDIA retornou erro 404. O sistema usará o Gemini (fallback) para gerar as notícias."
|
|
27
|
+
- Frontend: badge âmbar `degraded` no resultado do teste + tag vermelha **[Indisponível]** ao lado do select de modelos — destacada mas mantida selecionável
|
|
28
|
+
- Validado em E2E com mock servers: NVIDIA → 404 → Gemini → 200 → notícia gerada com `llm_provider='google'` e logs do incidente em `backend.log`
|
|
29
|
+
|
|
17
30
|
## 0.4.03 (2026-11-08)
|
|
18
31
|
|
|
19
32
|
- **Regra permanente no AGENTS.md**: é PROIBIDO criar/publicar versões que abram janelas de terminal (CMD/PowerShell/console Python) no computador do usuário — todo processo filho deve nascer com `windowsHide: true` + grupo isolado, rodar em background com saída apenas em log e ser encerrado por código; versão que exiba janela de console é inválida (hotfix + CHANGELOG)
|
package/backend/app/main.py
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
"""Router de LLM do PandaNews: fallback ordenado
|
|
1
|
+
"""Router de LLM do PandaNews: fallback ordenado e à prova de falhas.
|
|
2
2
|
|
|
3
|
-
Roteamento
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Roteamento explícito, provedor por provedor, na ordem configurada
|
|
4
|
+
(padrão: NVIDIA NIM → Google Gemini). Qualquer erro HTTP do provedor atual
|
|
5
|
+
(4xx/5xx — incluindo 404/410 de modelos descontinuados) é registrado e o
|
|
6
|
+
próximo provedor é acionado imediatamente. A função devolve (texto, provedor)
|
|
7
|
+
para registrar em noticias.llm_provider.
|
|
7
8
|
"""
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
11
|
+
import logging
|
|
10
12
|
import time
|
|
11
13
|
|
|
12
14
|
import httpx
|
|
15
|
+
from openai import APIStatusError, OpenAI
|
|
13
16
|
|
|
14
17
|
from backend.core.config import DEFAULT_LLM_BASE_URLS, DEFAULT_LLM_MODELS
|
|
15
18
|
|
|
16
19
|
PROVEDORES = ("nvidia", "google")
|
|
17
20
|
|
|
21
|
+
# Rótulos de exibição (banco, logs e UI usam "Gemini", não o id interno "google").
|
|
22
|
+
NOME_EXIBICAO = {"nvidia": "NVIDIA NIM", "google": "Gemini"}
|
|
23
|
+
|
|
24
|
+
# Logger filho do uvicorn.error: mensagens vão para o backend.log (e stderr),
|
|
25
|
+
# com a disciplina de logs do servidor.
|
|
26
|
+
logger = logging.getLogger("uvicorn.error.pandanews")
|
|
27
|
+
|
|
18
28
|
|
|
19
29
|
def get_config(db, chave: str, padrao: str = "") -> str:
|
|
20
30
|
from backend.core.database import Configuracao
|
|
@@ -31,8 +41,65 @@ def provider_config(db, provider: str) -> dict:
|
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
|
|
44
|
+
def _chamar_provedor(db, provider: str, prompt: str, max_tokens: int) -> tuple[str, str]:
|
|
45
|
+
"""Uma chamada de chat completion no provedor. Levanta em caso de erro."""
|
|
46
|
+
cfg = provider_config(db, provider)
|
|
47
|
+
client = OpenAI(api_key=cfg["api_key"], base_url=cfg["base_url"], timeout=60.0)
|
|
48
|
+
resposta = client.chat.completions.create(
|
|
49
|
+
model=cfg["model"],
|
|
50
|
+
messages=[{"role": "user", "content": prompt}],
|
|
51
|
+
max_tokens=max_tokens,
|
|
52
|
+
)
|
|
53
|
+
texto = resposta.choices[0].message.content or ""
|
|
54
|
+
modelo = str(getattr(resposta, "model", "") or cfg["model"])
|
|
55
|
+
return texto, modelo
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
|
|
59
|
+
"""Gera texto com fallback ordenado. Retorna (texto, provedor_que_respondeu).
|
|
60
|
+
|
|
61
|
+
Se um provedor falhar por qualquer erro HTTP, registra o incidente e
|
|
62
|
+
aciona o próximo da lista. Só levanta erro quando TODOS falham.
|
|
63
|
+
"""
|
|
64
|
+
ordem = [p.strip() for p in get_config(db, "llm_fallback_ordem", "nvidia,google").split(",") if p.strip()]
|
|
65
|
+
disponiveis = [p for p in ordem if p in PROVEDORES and provider_config(db, p)["api_key"]]
|
|
66
|
+
if not disponiveis:
|
|
67
|
+
raise RuntimeError("Nenhum provedor de LLM configurado. Adicione uma API key em Configurações.")
|
|
68
|
+
|
|
69
|
+
ultimo_erro: Exception | None = None
|
|
70
|
+
for indice, provider in enumerate(disponiveis):
|
|
71
|
+
proximo = disponiveis[indice + 1] if indice + 1 < len(disponiveis) else None
|
|
72
|
+
try:
|
|
73
|
+
texto, modelo = _chamar_provedor(db, provider, prompt, max_tokens)
|
|
74
|
+
logger.info("LLM: %s respondeu (modelo %s)", NOME_EXIBICAO.get(provider, provider), modelo)
|
|
75
|
+
# Banco/UI rotulam o provedor do Gemini como "gemini" (não o id interno "google")
|
|
76
|
+
return texto, "gemini" if provider == "google" else provider
|
|
77
|
+
except APIStatusError as exc:
|
|
78
|
+
status = exc.status_code
|
|
79
|
+
ultimo_erro = exc
|
|
80
|
+
logger.warning("LLM: %s falhou com HTTP %s (%s)", provider, status, str(exc)[:300])
|
|
81
|
+
if status in (404, 410):
|
|
82
|
+
destino = proximo or "nenhum"
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Modelo %s indisponível (Status %s). Acionando fallback para %s.",
|
|
85
|
+
NOME_EXIBICAO.get(provider, provider),
|
|
86
|
+
status,
|
|
87
|
+
NOME_EXIBICAO.get(destino, destino),
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc: # noqa: BLE001 — timeouts, rede, auth: tenta o próximo
|
|
90
|
+
ultimo_erro = exc
|
|
91
|
+
logger.warning("LLM: %s falhou (%s): %s", provider, type(exc).__name__, str(exc)[:300])
|
|
92
|
+
if proximo:
|
|
93
|
+
continue
|
|
94
|
+
raise RuntimeError(f"Todos os provedores de LLM falharam ({', '.join(disponiveis)}). Último erro: {ultimo_erro}")
|
|
95
|
+
|
|
96
|
+
|
|
34
97
|
def test_provider(db, provider: str) -> dict:
|
|
35
|
-
"""
|
|
98
|
+
"""Chamada real e curta (POST /chat/completions) para validar a chave.
|
|
99
|
+
|
|
100
|
+
404/410 → 'degraded': a chave pode funcionar, mas o modelo endpoint está
|
|
101
|
+
indisponível — o pipeline segue pelo fallback. A UI distingue os estados.
|
|
102
|
+
"""
|
|
36
103
|
if provider not in PROVEDORES:
|
|
37
104
|
return {"ok": False, "detail": f"Provedor desconhecido: {provider}"}
|
|
38
105
|
cfg = provider_config(db, provider)
|
|
@@ -51,41 +118,18 @@ def test_provider(db, provider: str) -> dict:
|
|
|
51
118
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
52
119
|
if resp.status_code == 200:
|
|
53
120
|
return {"ok": True, "status": 200, "latency_ms": elapsed_ms, "model": cfg["model"], "base_url": cfg["base_url"]}
|
|
121
|
+
if resp.status_code in (404, 410):
|
|
122
|
+
return {
|
|
123
|
+
"ok": False,
|
|
124
|
+
"degraded": True,
|
|
125
|
+
"status": resp.status_code,
|
|
126
|
+
"detail": (
|
|
127
|
+
f"Sua chave da NVIDIA retornou erro {resp.status_code}. "
|
|
128
|
+
"O sistema usará o Gemini (fallback) para gerar as notícias."
|
|
129
|
+
if provider == "nvidia"
|
|
130
|
+
else f"Endpoint do Gemini retornou {resp.status_code}. O sistema usará a NVIDIA (primário) para gerar as notícias."
|
|
131
|
+
),
|
|
132
|
+
}
|
|
54
133
|
return {"ok": False, "status": resp.status_code, "detail": resp.text[:400]}
|
|
55
134
|
except Exception as exc: # noqa: BLE001
|
|
56
135
|
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
|
|
60
|
-
"""Gera texto com fallback ordenado. Retorna (texto, provedor_que_respondeu)."""
|
|
61
|
-
from llmswitch import Client, Endpoint, LLMSwitchConfig
|
|
62
|
-
|
|
63
|
-
ordem = [p.strip() for p in get_config(db, "llm_fallback_ordem", "nvidia,google").split(",") if p.strip()]
|
|
64
|
-
endpoints = []
|
|
65
|
-
for provider in ordem:
|
|
66
|
-
if provider not in PROVEDORES:
|
|
67
|
-
continue
|
|
68
|
-
cfg = provider_config(db, provider)
|
|
69
|
-
if cfg["api_key"]:
|
|
70
|
-
endpoints.append(
|
|
71
|
-
Endpoint(provider=provider, base_url=cfg["base_url"], api_key=cfg["api_key"], model=cfg["model"])
|
|
72
|
-
)
|
|
73
|
-
if not endpoints:
|
|
74
|
-
raise RuntimeError("Nenhum provedor de LLM configurado. Adicione uma API key em Configurações.")
|
|
75
|
-
|
|
76
|
-
config = LLMSwitchConfig(alias="pandanews", endpoints=endpoints)
|
|
77
|
-
client = Client(configs=config)
|
|
78
|
-
resp = client.chat.completions.create(
|
|
79
|
-
model="pandanews",
|
|
80
|
-
messages=[{"role": "user", "content": prompt}],
|
|
81
|
-
max_tokens=max_tokens,
|
|
82
|
-
)
|
|
83
|
-
texto = resp.choices[0].message.content or ""
|
|
84
|
-
model = getattr(resp, "model", "") or ""
|
|
85
|
-
if "llama" in model or "nvidia" in model:
|
|
86
|
-
provedor = "nvidia"
|
|
87
|
-
elif "gemini" in model:
|
|
88
|
-
provedor = "google"
|
|
89
|
-
else:
|
|
90
|
-
provedor = endpoints[0].provider if endpoints else "desconhecido"
|
|
91
|
-
return texto, provedor
|