@danhachuel/pandanews 0.4.4 → 0.4.5

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
@@ -14,6 +14,14 @@ 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.05 (2026-11-08)
18
+
19
+ - **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`
20
+ - **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
21
+ - **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."
22
+ - Frontend: badge âmbar `degraded` no resultado do teste + tag vermelha **[Indisponível]** ao lado do select de modelos — destacada mas mantida selecionável
23
+ - Validado em E2E com mock servers: NVIDIA → 404 → Gemini → 200 → notícia gerada com `llm_provider='google'` e logs do incidente em `backend.log`
24
+
17
25
  ## 0.4.03 (2026-11-08)
18
26
 
19
27
  - **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)
@@ -31,7 +31,7 @@ async def lifespan(_: FastAPI):
31
31
  scheduler.parar()
32
32
 
33
33
 
34
- app = FastAPI(title="PandaNews API", version="0.4.4", lifespan=lifespan)
34
+ app = FastAPI(title="PandaNews API", version="0.4.5", lifespan=lifespan)
35
35
 
36
36
  app.add_middleware(
37
37
  CORSMiddleware,
@@ -1,20 +1,27 @@
1
- """Router de LLM do PandaNews: fallback ordenado NVIDIA NIM -> Google Gemini.
1
+ """Router de LLM do PandaNews: fallback ordenado e à prova de falhas.
2
2
 
3
- Roteamento por prioridade com llmswitch-client (circuit breaker embutido).
4
- O provedor que responde é devolvido junto com o texto para registrar em
5
- noticias.llm_provider. Teste por provedor via httpx direto no endpoint
6
- chat/completions (compatível com OpenAI) dos dois provedores.
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
+ # Logger filho do uvicorn.error: mensagens vão para o backend.log (e stderr),
22
+ # com a disciplina de logs do servidor.
23
+ logger = logging.getLogger("uvicorn.error.pandanews")
24
+
18
25
 
19
26
  def get_config(db, chave: str, padrao: str = "") -> str:
20
27
  from backend.core.database import Configuracao
@@ -31,8 +38,64 @@ def provider_config(db, provider: str) -> dict:
31
38
  }
32
39
 
33
40
 
41
+ def _chamar_provedor(db, provider: str, prompt: str, max_tokens: int) -> tuple[str, str]:
42
+ """Uma chamada de chat completion no provedor. Levanta em caso de erro."""
43
+ cfg = provider_config(db, provider)
44
+ client = OpenAI(api_key=cfg["api_key"], base_url=cfg["base_url"], timeout=60.0)
45
+ resposta = client.chat.completions.create(
46
+ model=cfg["model"],
47
+ messages=[{"role": "user", "content": prompt}],
48
+ max_tokens=max_tokens,
49
+ )
50
+ texto = resposta.choices[0].message.content or ""
51
+ modelo = str(getattr(resposta, "model", "") or cfg["model"])
52
+ return texto, modelo
53
+
54
+
55
+ def gerar_texto(db, prompt: str, max_tokens: int = 1024) -> tuple[str, str]:
56
+ """Gera texto com fallback ordenado. Retorna (texto, provedor_que_respondeu).
57
+
58
+ Se um provedor falhar por qualquer erro HTTP, registra o incidente e
59
+ aciona o próximo da lista. Só levanta erro quando TODOS falham.
60
+ """
61
+ ordem = [p.strip() for p in get_config(db, "llm_fallback_ordem", "nvidia,google").split(",") if p.strip()]
62
+ disponiveis = [p for p in ordem if p in PROVEDORES and provider_config(db, p)["api_key"]]
63
+ if not disponiveis:
64
+ raise RuntimeError("Nenhum provedor de LLM configurado. Adicione uma API key em Configurações.")
65
+
66
+ ultimo_erro: Exception | None = None
67
+ for indice, provider in enumerate(disponiveis):
68
+ proximo = disponiveis[indice + 1] if indice + 1 < len(disponiveis) else None
69
+ try:
70
+ texto, modelo = _chamar_provedor(db, provider, prompt, max_tokens)
71
+ logger.info("LLM: %s respondeu (modelo %s)", provider, modelo)
72
+ return texto, provider
73
+ except APIStatusError as exc:
74
+ status = exc.status_code
75
+ ultimo_erro = exc
76
+ logger.warning("LLM: %s falhou com HTTP %s (%s)", provider, status, str(exc)[:300])
77
+ if status in (404, 410):
78
+ destino = proximo or "nenhum"
79
+ logger.warning(
80
+ "Modelo %s indisponível (Status %s). Acionando fallback para %s.",
81
+ provider,
82
+ status,
83
+ destino,
84
+ )
85
+ except Exception as exc: # noqa: BLE001 — timeouts, rede, auth: tenta o próximo
86
+ ultimo_erro = exc
87
+ logger.warning("LLM: %s falhou (%s): %s", provider, type(exc).__name__, str(exc)[:300])
88
+ if proximo:
89
+ continue
90
+ raise RuntimeError(f"Todos os provedores de LLM falharam ({', '.join(disponiveis)}). Último erro: {ultimo_erro}")
91
+
92
+
34
93
  def test_provider(db, provider: str) -> dict:
35
- """Ping real ao endpoint chat/completions do provedor (1 token)."""
94
+ """Chamada real e curta (POST /chat/completions) para validar a chave.
95
+
96
+ 404/410 → 'degraded': a chave pode funcionar, mas o modelo endpoint está
97
+ indisponível — o pipeline segue pelo fallback. A UI distingue os estados.
98
+ """
36
99
  if provider not in PROVEDORES:
37
100
  return {"ok": False, "detail": f"Provedor desconhecido: {provider}"}
38
101
  cfg = provider_config(db, provider)
@@ -51,41 +114,18 @@ def test_provider(db, provider: str) -> dict:
51
114
  elapsed_ms = int((time.perf_counter() - started) * 1000)
52
115
  if resp.status_code == 200:
53
116
  return {"ok": True, "status": 200, "latency_ms": elapsed_ms, "model": cfg["model"], "base_url": cfg["base_url"]}
117
+ if resp.status_code in (404, 410):
118
+ return {
119
+ "ok": False,
120
+ "degraded": True,
121
+ "status": resp.status_code,
122
+ "detail": (
123
+ f"Sua chave da NVIDIA retornou erro {resp.status_code}. "
124
+ "O sistema usará o Gemini (fallback) para gerar as notícias."
125
+ if provider == "nvidia"
126
+ else f"Endpoint do Gemini retornou {resp.status_code}. O sistema usará a NVIDIA (primário) para gerar as notícias."
127
+ ),
128
+ }
54
129
  return {"ok": False, "status": resp.status_code, "detail": resp.text[:400]}
55
130
  except Exception as exc: # noqa: BLE001
56
131
  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