@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 +20 -0
- package/backend/app/main.py +1 -1
- package/backend/core/llm_router.py +81 -41
- package/backend/core/proc.py +89 -0
- 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/scripts/build.js +8 -2
- package/scripts/install.js +20 -3
- package/scripts/test-start-isolation.mjs +194 -31
- package/frontend/dist/assets/index-CUCWGfn9.css +0 -1
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)
|
package/backend/app/main.py
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
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
|
+
# 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
|
-
"""
|
|
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)
|