@danhachuel/pandanews 0.4.3 → 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
@@ -2,6 +2,26 @@
2
2
 
3
3
  Todas as versões publicadas são registradas aqui antes do publish (regra em AGENTS.md). A etiqueta visual usa PATCH de dois dígitos (ex.: 0.1.00); o registry npm normaliza para a versão canônica (ex.: 0.1.0).
4
4
 
5
+ ## 0.4.04 (2026-11-08)
6
+
7
+ - **HOTFIX real — fim das janelas de CMD no instalador**: o 0.4.03 blindou apenas o launcher (`start.js`) e deixou o **caminho de instalação** descoberto. No Windows, `tar` (extração do Python portátil), `python -m venv` e `pip install -r requirements.txt` eram lançados **sem `windowsHide`** — era aí que as múltiplas janelas de terminal apareciam. Os três `spawnSync` de `scripts/install.js` agora nascem com `windowsHide: true` (`CREATE_NO_WINDOW`)
8
+ - `scripts/build.js` blindado também: `npm install` e `npm run build` do frontend são chamados com `shell: true` (o wrapper `cmd.exe`/`npm.cmd` entra na conta) — ambos agora com `windowsHide: true`, sem piscar tela preta
9
+ - Saída preservada: o instalador mantém `stdio: 'inherit'`, então o progresso continua visível **no console que o usuário já abriu** — só a janela extra deixa de existir (verificado empiricamente no Windows)
10
+ - **Helper Python único e obrigatório**: `backend/core/proc.py` expõe `run_silent_process` / `run_silent` / `spawn_silent` / `call_silent` / `check_output_silent` / `system_silent`, aplicando `CREATE_NO_WINDOW = 0x08000000` via `creationflags` no Windows. O pipeline e o APScheduler rodam em memória (threads do `BackgroundScheduler`), sem processos do SO; o helper é o ponto único para qualquer subprocesso futuro
11
+ - **Teste de regressão ampliado e validado**: `scripts/test-start-isolation.mjs` deixa de olhar só o `start.js` e passa a varrer **todos** os pontos de entrada — `start.js`, `install.js`, `build.js` e `bin/cli.js` — exigindo `windowsHide: true` em cada `spawn`/`spawnSync`/`exec`/`execSync` (inclusive quando as opções vêm de constante compartilhada, ex. `...SILENT_WINDOWS`)
12
+ - O teste também trava regressões do backend Python: qualquer `subprocess.run/Popen/call/check_output/check_call` ou `os.system`/`os.popen` direto em `backend/**/*.py` falha o build (precisa passar por `backend.core.proc`)
13
+ - E ainda barra `uvicorn` com `--reload`/`--workers` no launcher — cada worker é um processo filho extra que, no Windows, pode alocar console próprio e ignorar as flags do pai
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
+ - 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
+
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
+
5
25
  ## 0.4.03 (2026-11-08)
6
26
 
7
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.3", 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
@@ -0,0 +1,89 @@
1
+ """Processos filhos silenciosos (regra permanente do AGENTS.md).
2
+
3
+ O PandaNews NUNCA pode abrir janelas de terminal (CMD, PowerShell, console
4
+ Python) no computador do usuário. No Windows, todo processo filho precisa da
5
+ flag ``CREATE_NO_WINDOW`` (0x08000000); sem ela, qualquer ``subprocess`` ou
6
+ gerador de console aloca uma tela preta.
7
+
8
+ Este módulo é o ÚNICO ponto de entrada autorizado para subprocessos no
9
+ backend. Nunca chame ``subprocess.run``/``subprocess.Popen``/``subprocess.call``
10
+ nem ``os.system`` diretamente — use ``run_silent_process``/``run_silent``/
11
+ ``spawn_silent``. O teste ``scripts/test-start-isolation.mjs`` (rodado no CI)
12
+ falha o build se encontrar uma chamada direta em ``backend/**/*.py``.
13
+
14
+ Exemplos::
15
+
16
+ from backend.core.proc import run_silent_process, spawn_silent
17
+
18
+ # execução síncrona
19
+ run_silent_process(["ffmpeg", "-version"], check=True, capture_output=True)
20
+
21
+ # execução assíncrona (não bloqueia o event loop)
22
+ proc = spawn_silent(["pandoc", "--version"])
23
+ proc.wait()
24
+
25
+ O pipeline de notícias e o APScheduler rodam em memória (threads do
26
+ ``BackgroundScheduler``), sem processos do sistema operacional — este módulo
27
+ só é necessário quando uma ferramenta externa realmente precisa ser chamada.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import subprocess
32
+ import sys
33
+
34
+ # Flag do Windows: o processo nasce sem console, portanto sem janela visível.
35
+ # Ver https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
36
+ CREATE_NO_WINDOW = 0x08000000
37
+
38
+
39
+ def silent_kwargs(kwargs: dict | None = None) -> dict:
40
+ """Devolve as opções de subprocesso com a flag de ocultação aplicada.
41
+
42
+ Em Windows adiciona ``creationflags=CREATE_NO_WINDOW``; nas demais
43
+ plataformas não há console novo a ocultar, então as opções passam
44
+ intactas. Nunca use ``shell=True`` junto a este helper.
45
+ """
46
+ opts = dict(kwargs or {})
47
+ if sys.platform == "win32":
48
+ opts["creationflags"] = CREATE_NO_WINDOW
49
+ return opts
50
+
51
+
52
+ def run_silent_process(cmd_args, **kwargs):
53
+ """Substitui ``subprocess.run`` — sem janela de console no Windows."""
54
+ return subprocess.run(cmd_args, **silent_kwargs(kwargs))
55
+
56
+
57
+ # Alias: mesma semântica, nome curto para uso interno.
58
+ run_silent = run_silent_process
59
+
60
+
61
+ def spawn_silent(cmd_args, **kwargs):
62
+ """Substitui ``subprocess.Popen`` — sem janela de console no Windows."""
63
+ return subprocess.Popen(cmd_args, **silent_kwargs(kwargs))
64
+
65
+
66
+ def call_silent(cmd_args, **kwargs):
67
+ """Substitui ``subprocess.call`` — sem janela de console no Windows."""
68
+ return subprocess.call(cmd_args, **silent_kwargs(kwargs))
69
+
70
+
71
+ def check_output_silent(cmd_args, **kwargs):
72
+ """Substitui ``subprocess.check_output`` — sem janela de console no Windows."""
73
+ return subprocess.check_output(cmd_args, **silent_kwargs(kwargs))
74
+
75
+
76
+ def system_silent(cmd: str) -> int:
77
+ """Substitui ``os.system`` sem abrir shell visível.
78
+
79
+ ``os.system`` sempre passa por ``cmd.exe``/``/bin/sh`` e herda o console;
80
+ quando não há alternativa, a chamada é feita pelo helper silencioso.
81
+ """
82
+ if sys.platform == "win32":
83
+ proc = subprocess.run(
84
+ cmd,
85
+ shell=True,
86
+ creationflags=CREATE_NO_WINDOW,
87
+ )
88
+ return proc.returncode
89
+ return subprocess.call(cmd, shell=True)