@danhachuel/pandanews 0.1.3 → 0.2.0
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/README.md +5 -1
- package/backend/app/main.py +23 -4
- package/backend/app/routers/blogs.py +87 -0
- package/backend/app/routers/configuracoes.py +58 -0
- package/backend/app/routers/dashboard.py +59 -0
- package/backend/app/routers/llm.py +16 -0
- package/backend/app/routers/logs.py +38 -0
- package/backend/app/routers/noticias.py +65 -0
- package/backend/app/schemas.py +71 -1
- package/backend/core/config.py +42 -1
- package/backend/core/database.py +86 -4
- package/backend/core/llm_router.py +89 -4
- package/frontend/dist/assets/index-BaPdaYIz.css +1 -0
- package/frontend/dist/assets/index-D0e-vr7T.js +70 -0
- package/frontend/dist/favicon.svg +11 -0
- package/frontend/dist/index.html +17 -0
- package/package.json +1 -1
- package/scripts/build.js +12 -3
- package/scripts/start.js +57 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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.2.00 (2026-10-23)
|
|
6
|
+
|
|
7
|
+
- **UI React completa** (Fase 2): Vite + TypeScript + Tailwind, sidebar com 7 seções, topbar com seletor de blog e tema claro/escuro
|
|
8
|
+
- Dashboard: checklist de onboarding (efeito Zeigarnik) com barra de progresso, estatísticas, gráfico de geração dos últimos 7 dias e últimos erros
|
|
9
|
+
- Blogs: cards, criação em assistente de 3 passos (Lei de Hick), edição, pausar/ativar e exclusão com confirmação modal (Lei de Jakob)
|
|
10
|
+
- Notícias: filtros por blog/status, badges de status (rascunho em âmbar — von Restorff), aprovar/rejeitar e edição manual
|
|
11
|
+
- Configurações: NVIDIA NIM e Google Gemini com keys mascaradas e teste de conexão real, ordem de fallback reordenável, banco SQLite/Supabase e agendamento — em blocos expansíveis (Lei de Miller)
|
|
12
|
+
- Automação: toggle global do scheduler e por blog; Imagens e Logs com empty states
|
|
13
|
+
- **Favicon de urso-panda** 🐼 nas guias do navegador (SVG) — também no placeholder
|
|
14
|
+
- **Backend Fase 1**: SQLite com tabelas blogs/noticias/configuracoes/logs criadas no startup, CRUD de blogs (`/api/v1/blogs`), configurações mascaradas (`/api/v1/configuracoes`), teste de LLM real por provedor (`/api/v1/llm/test`), dashboard (`/api/v1/dashboard`), notícias e logs
|
|
15
|
+
- `start.js`: proxy `/api` → backend (UI same-origin) e fallback SPA para rotas do React Router
|
|
16
|
+
- Workflow: build do frontend antes do publish (`frontend/dist` entra no tarball)
|
|
17
|
+
|
|
5
18
|
## 0.1.03 (2026-10-23)
|
|
6
19
|
|
|
7
20
|
- Runtime movido para um diretório **estável por usuário** (`~/.pandanews`, sobrescrevível via `PANDANEWS_HOME`): upgrades de versão do pacote não reinstalam mais Python/dependências — o instalador detecta o que já existe e só instala o que falta
|
package/README.md
CHANGED
|
@@ -34,7 +34,11 @@ npx --yes @danhachuel/pandanews install
|
|
|
34
34
|
|
|
35
35
|
O runtime fica em `~/.pandanews` (sobrescreva com a variável de ambiente `PANDANEWS_HOME`) — **upgrades de versão não reinstalam** o que já existe: o instalador detecta Python, dependências e dados já presentes e só instala o que falta.
|
|
36
36
|
|
|
37
|
-
## Status (versão 0.
|
|
37
|
+
## Status (versão 0.2.00)
|
|
38
|
+
|
|
39
|
+
- UI React completa: Dashboard, Blogs, Notícias, Imagens, Automação, Configurações e Logs
|
|
40
|
+
- Backend: CRUD de blogs, configurações NVIDIA/Gemini com teste de conexão, dashboard e logs — SQLite local
|
|
41
|
+
- Próximo: pipeline de geração de notícias (Fase 3) e scheduler ativo (Fase 5)
|
|
38
42
|
|
|
39
43
|
- `npx @danhachuel/pandanews` instala tudo junto (Python portátil + dependências + banco local) e sobe o backend em 127.0.0.1:8000 com página em 127.0.0.1:4030 — se a porta estiver ocupada, usa a seguinte automaticamente (4031, 4032, ...)
|
|
40
44
|
- A UI React completa (Dashboard, Blogs, Notícias, Imagens, Automação, Configurações) e o pipeline de notícias chegam nas próximas versões (Fases 1 a 3 do plano — ver `CHANGELOG.md`)
|
package/backend/app/main.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
"""PandaNews API — FastAPI.
|
|
2
2
|
|
|
3
|
-
Fase
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Fase 1: CRUD de blogs, configurações (keys mascaradas), teste de LLM,
|
|
4
|
+
dashboard com métricas/onboarding, notícias e logs. SQLite local criado
|
|
5
|
+
automaticamente no startup (init_db). CORS liberado para a UI local.
|
|
6
6
|
"""
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
7
9
|
from fastapi import FastAPI
|
|
8
10
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
11
|
|
|
10
|
-
app
|
|
12
|
+
from backend.app.routers import blogs, configuracoes, dashboard, llm, logs, noticias
|
|
13
|
+
from backend.core.database import init_db
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def lifespan(_: FastAPI):
|
|
18
|
+
init_db()
|
|
19
|
+
yield
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = FastAPI(title="PandaNews API", version="0.2.0", lifespan=lifespan)
|
|
11
23
|
|
|
12
24
|
app.add_middleware(
|
|
13
25
|
CORSMiddleware,
|
|
@@ -16,6 +28,13 @@ app.add_middleware(
|
|
|
16
28
|
allow_headers=["*"],
|
|
17
29
|
)
|
|
18
30
|
|
|
31
|
+
app.include_router(blogs.router, prefix="/api/v1")
|
|
32
|
+
app.include_router(configuracoes.router, prefix="/api/v1")
|
|
33
|
+
app.include_router(dashboard.router, prefix="/api/v1")
|
|
34
|
+
app.include_router(llm.router, prefix="/api/v1")
|
|
35
|
+
app.include_router(noticias.router, prefix="/api/v1")
|
|
36
|
+
app.include_router(logs.router, prefix="/api/v1")
|
|
37
|
+
|
|
19
38
|
|
|
20
39
|
@app.get("/api/v1/health")
|
|
21
40
|
def health() -> dict:
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CRUD de blogs (/api/v1/blogs)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from backend.app.schemas import BlogIn, BlogPatch
|
|
11
|
+
from backend.core.database import Blog, Log, get_db
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/blogs", tags=["blogs"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def blog_to_dict(b: Blog) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"id": b.id,
|
|
19
|
+
"nome": b.nome,
|
|
20
|
+
"tema": b.tema,
|
|
21
|
+
"regiao": b.regiao,
|
|
22
|
+
"fontes_rss": json.loads(b.fontes_rss or "[]"),
|
|
23
|
+
"estilo_editorial": b.estilo_editorial,
|
|
24
|
+
"frequencia_horas": b.frequencia_horas,
|
|
25
|
+
"status": b.status,
|
|
26
|
+
"created_at": b.created_at,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("")
|
|
31
|
+
def listar_blogs(db: Session = Depends(get_db)) -> list[dict]:
|
|
32
|
+
blogs = db.scalars(select(Blog).order_by(Blog.created_at.desc())).all()
|
|
33
|
+
return [blog_to_dict(b) for b in blogs]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/{blog_id}")
|
|
37
|
+
def obter_blog(blog_id: int, db: Session = Depends(get_db)) -> dict:
|
|
38
|
+
blog = db.get(Blog, blog_id)
|
|
39
|
+
if blog is None:
|
|
40
|
+
raise HTTPException(status_code=404, detail="Blog não encontrado")
|
|
41
|
+
return blog_to_dict(blog)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post("", status_code=201)
|
|
45
|
+
def criar_blog(payload: BlogIn, db: Session = Depends(get_db)) -> dict:
|
|
46
|
+
blog = Blog(
|
|
47
|
+
nome=payload.nome,
|
|
48
|
+
tema=payload.tema,
|
|
49
|
+
regiao=payload.regiao,
|
|
50
|
+
fontes_rss=json.dumps([u.strip() for u in payload.fontes_rss if u.strip()]),
|
|
51
|
+
estilo_editorial=payload.estilo_editorial,
|
|
52
|
+
frequencia_horas=payload.frequencia_horas,
|
|
53
|
+
status=payload.status,
|
|
54
|
+
)
|
|
55
|
+
db.add(blog)
|
|
56
|
+
db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog criado: {payload.nome}"))
|
|
57
|
+
db.commit()
|
|
58
|
+
db.refresh(blog)
|
|
59
|
+
return blog_to_dict(blog)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.patch("/{blog_id}")
|
|
63
|
+
def atualizar_blog(blog_id: int, payload: BlogPatch, db: Session = Depends(get_db)) -> dict:
|
|
64
|
+
blog = db.get(Blog, blog_id)
|
|
65
|
+
if blog is None:
|
|
66
|
+
raise HTTPException(status_code=404, detail="Blog não encontrado")
|
|
67
|
+
data = payload.model_dump(exclude_none=True)
|
|
68
|
+
if "fontes_rss" in data:
|
|
69
|
+
blog.fontes_rss = json.dumps([u.strip() for u in data.pop("fontes_rss") if u.strip()])
|
|
70
|
+
for key, value in data.items():
|
|
71
|
+
setattr(blog, key, value)
|
|
72
|
+
db.commit()
|
|
73
|
+
db.refresh(blog)
|
|
74
|
+
return blog_to_dict(blog)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.delete("/{blog_id}", status_code=204)
|
|
78
|
+
def excluir_blog(blog_id: int, db: Session = Depends(get_db)) -> None:
|
|
79
|
+
from backend.core.database import Noticia
|
|
80
|
+
|
|
81
|
+
blog = db.get(Blog, blog_id)
|
|
82
|
+
if blog is None:
|
|
83
|
+
raise HTTPException(status_code=404, detail="Blog não encontrado")
|
|
84
|
+
db.query(Noticia).filter(Noticia.blog_id == blog_id).delete()
|
|
85
|
+
db.delete(blog)
|
|
86
|
+
db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog excluído: {blog.nome}"))
|
|
87
|
+
db.commit()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Configurações (/api/v1/configuracoes) — API keys mascaradas, sem travas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from backend.app.schemas import ConfiguracoesPatch
|
|
8
|
+
from backend.core.config import DEFAULT_LLM_BASE_URLS, DEFAULT_LLM_MODELS, SUPABASE_URL
|
|
9
|
+
from backend.core.database import Configuracao, get_db
|
|
10
|
+
from backend.core.llm_router import get_config
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/configuracoes", tags=["configuracoes"])
|
|
13
|
+
|
|
14
|
+
SECRET_KEYS = ("nvidia_api_key", "google_api_key", "supabase_service_role_key")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mask(value: str) -> dict:
|
|
18
|
+
if not value:
|
|
19
|
+
return {"configured": False, "preview": ""}
|
|
20
|
+
if len(value) <= 8:
|
|
21
|
+
return {"configured": True, "preview": "••••"}
|
|
22
|
+
return {"configured": True, "preview": f"{value[:4]}••••{value[-4:]}"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("")
|
|
26
|
+
def obter_configuracoes(db: Session = Depends(get_db)) -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"nvidia_api_key": _mask(get_config(db, "nvidia_api_key")),
|
|
29
|
+
"nvidia_base_url": get_config(db, "nvidia_base_url", DEFAULT_LLM_BASE_URLS["nvidia"]),
|
|
30
|
+
"nvidia_model": get_config(db, "nvidia_model", DEFAULT_LLM_MODELS["nvidia"]),
|
|
31
|
+
"google_api_key": _mask(get_config(db, "google_api_key")),
|
|
32
|
+
"google_base_url": get_config(db, "google_base_url", DEFAULT_LLM_BASE_URLS["google"]),
|
|
33
|
+
"google_model": get_config(db, "google_model", DEFAULT_LLM_MODELS["google"]),
|
|
34
|
+
"llm_fallback_ordem": get_config(db, "llm_fallback_ordem", "nvidia,google"),
|
|
35
|
+
"db_backend": get_config(db, "db_backend", "supabase" if SUPABASE_URL else "sqlite"),
|
|
36
|
+
"supabase_url": SUPABASE_URL,
|
|
37
|
+
"supabase_service_role_key": _mask(get_config(db, "supabase_service_role_key")),
|
|
38
|
+
"scheduler_ativo": get_config(db, "scheduler_ativo", "true") == "true",
|
|
39
|
+
"intervalo_padrao_horas": int(get_config(db, "intervalo_padrao_horas", "6") or 6),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.put("")
|
|
44
|
+
def salvar_configuracoes(payload: ConfiguracoesPatch, db: Session = Depends(get_db)) -> dict:
|
|
45
|
+
data = payload.model_dump(exclude_none=True)
|
|
46
|
+
for chave, valor in data.items():
|
|
47
|
+
if chave in SECRET_KEYS and isinstance(valor, str) and valor.strip() == "":
|
|
48
|
+
continue # vazio não apaga segredo existente (UX sem travas)
|
|
49
|
+
if isinstance(valor, bool):
|
|
50
|
+
valor = "true" if valor else "false"
|
|
51
|
+
row = db.get(Configuracao, chave)
|
|
52
|
+
if row is None:
|
|
53
|
+
row = Configuracao(chave=chave, valor=str(valor))
|
|
54
|
+
db.add(row)
|
|
55
|
+
else:
|
|
56
|
+
row.valor = str(valor)
|
|
57
|
+
db.commit()
|
|
58
|
+
return obter_configuracoes(db)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Dashboard (/api/v1/dashboard): métricas, onboarding e últimos erros."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from backend.core.database import Blog, Log, Noticia, get_db
|
|
11
|
+
from backend.core.llm_router import get_config
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("")
|
|
17
|
+
def estatisticas(db: Session = Depends(get_db)) -> dict:
|
|
18
|
+
agora = datetime.now(timezone.utc)
|
|
19
|
+
inicio_hoje = agora.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
20
|
+
|
|
21
|
+
blogs_total = db.scalar(select(func.count(Blog.id))) or 0
|
|
22
|
+
blogs_ativos = db.scalar(select(func.count(Blog.id)).where(Blog.status == "ativo")) or 0
|
|
23
|
+
noticias_total = db.scalar(select(func.count(Noticia.id))) or 0
|
|
24
|
+
noticias_hoje = db.scalar(select(func.count(Noticia.id)).where(Noticia.created_at >= inicio_hoje)) or 0
|
|
25
|
+
noticias_pendentes = db.scalar(select(func.count(Noticia.id)).where(Noticia.status == "rascunho")) or 0
|
|
26
|
+
erros_total = db.scalar(select(func.count(Log.id)).where(Log.status == "erro")) or 0
|
|
27
|
+
|
|
28
|
+
geracao: list[dict] = []
|
|
29
|
+
for offset in range(6, -1, -1):
|
|
30
|
+
dia = (agora - timedelta(days=offset)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
31
|
+
dia_fim = dia + timedelta(days=1)
|
|
32
|
+
total = db.scalar(select(func.count(Noticia.id)).where(Noticia.created_at >= dia, Noticia.created_at < dia_fim)) or 0
|
|
33
|
+
geracao.append({"data": dia.date().isoformat(), "total": total})
|
|
34
|
+
|
|
35
|
+
ultimos_erros = [
|
|
36
|
+
{"id": e.id, "blog_id": e.blog_id, "etapa": e.etapa, "mensagem": e.mensagem, "created_at": e.created_at}
|
|
37
|
+
for e in db.scalars(select(Log).where(Log.status == "erro").order_by(Log.created_at.desc()).limit(5)).all()
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
llm_configurado = bool(get_config(db, "nvidia_api_key") or get_config(db, "google_api_key"))
|
|
41
|
+
onboarding = {
|
|
42
|
+
"llm_configurado": llm_configurado,
|
|
43
|
+
"tem_blog": blogs_total > 0,
|
|
44
|
+
"tem_noticia": noticias_total > 0,
|
|
45
|
+
}
|
|
46
|
+
onboarding["concluidos"] = sum(1 for v in onboarding.values() if v)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"blogs_total": blogs_total,
|
|
50
|
+
"blogs_ativos": blogs_ativos,
|
|
51
|
+
"noticias_total": noticias_total,
|
|
52
|
+
"noticias_hoje": noticias_hoje,
|
|
53
|
+
"noticias_pendentes": noticias_pendentes,
|
|
54
|
+
"erros_total": erros_total,
|
|
55
|
+
"scheduler_ativo": get_config(db, "scheduler_ativo", "true") == "true",
|
|
56
|
+
"geracao_7dias": geracao,
|
|
57
|
+
"ultimos_erros": ultimos_erros,
|
|
58
|
+
"onboarding": onboarding,
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Teste de conexão LLM (/api/v1/llm/test) — por provedor, sem travas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from backend.app.schemas import LlmTestIn
|
|
8
|
+
from backend.core.database import get_db
|
|
9
|
+
from backend.core.llm_router import test_provider
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/llm", tags=["llm"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.post("/test")
|
|
15
|
+
def testar_llm(payload: LlmTestIn, db: Session = Depends(get_db)) -> dict:
|
|
16
|
+
return test_provider(db, payload.provider.strip().lower())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Logs de execução (/api/v1/logs)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from backend.core.database import Log, get_db
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/logs", tags=["logs"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
def listar_logs(
|
|
15
|
+
blog_id: int | None = None,
|
|
16
|
+
etapa: str | None = None,
|
|
17
|
+
status: str | None = None,
|
|
18
|
+
limit: int = 200,
|
|
19
|
+
db: Session = Depends(get_db),
|
|
20
|
+
) -> list[dict]:
|
|
21
|
+
query = select(Log).order_by(Log.created_at.desc()).limit(min(limit, 1000))
|
|
22
|
+
if blog_id is not None:
|
|
23
|
+
query = query.where(Log.blog_id == blog_id)
|
|
24
|
+
if etapa:
|
|
25
|
+
query = query.where(Log.etapa == etapa)
|
|
26
|
+
if status:
|
|
27
|
+
query = query.where(Log.status == status)
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
"id": l.id,
|
|
31
|
+
"blog_id": l.blog_id,
|
|
32
|
+
"etapa": l.etapa,
|
|
33
|
+
"status": l.status,
|
|
34
|
+
"mensagem": l.mensagem,
|
|
35
|
+
"created_at": l.created_at,
|
|
36
|
+
}
|
|
37
|
+
for l in db.scalars(query).all()
|
|
38
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Notícias (/api/v1/noticias): listagem, filtros e revisão humana."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from backend.app.schemas import NoticiaPatch, STATUS_NOTICIA
|
|
9
|
+
from backend.core.database import Noticia, get_db
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/noticias", tags=["noticias"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("")
|
|
15
|
+
def listar_noticias(
|
|
16
|
+
blog_id: int | None = None,
|
|
17
|
+
status: str | None = None,
|
|
18
|
+
limit: int = 100,
|
|
19
|
+
db: Session = Depends(get_db),
|
|
20
|
+
) -> list[dict]:
|
|
21
|
+
query = select(Noticia).order_by(Noticia.created_at.desc()).limit(min(limit, 500))
|
|
22
|
+
if blog_id is not None:
|
|
23
|
+
query = query.where(Noticia.blog_id == blog_id)
|
|
24
|
+
if status:
|
|
25
|
+
query = query.where(Noticia.status == status)
|
|
26
|
+
noticias = db.scalars(query).all()
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
"id": n.id,
|
|
30
|
+
"blog_id": n.blog_id,
|
|
31
|
+
"titulo": n.titulo,
|
|
32
|
+
"corpo": n.corpo,
|
|
33
|
+
"resumo": n.resumo,
|
|
34
|
+
"imagem_url": n.imagem_url,
|
|
35
|
+
"status": n.status,
|
|
36
|
+
"llm_provider": n.llm_provider,
|
|
37
|
+
"created_at": n.created_at,
|
|
38
|
+
}
|
|
39
|
+
for n in noticias
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.patch("/{noticia_id}")
|
|
44
|
+
def atualizar_noticia(noticia_id: int, payload: NoticiaPatch, db: Session = Depends(get_db)) -> dict:
|
|
45
|
+
noticia = db.get(Noticia, noticia_id)
|
|
46
|
+
if noticia is None:
|
|
47
|
+
raise HTTPException(status_code=404, detail="Notícia não encontrada")
|
|
48
|
+
data = payload.model_dump(exclude_none=True)
|
|
49
|
+
if "status" in data and data["status"] not in STATUS_NOTICIA:
|
|
50
|
+
raise HTTPException(status_code=422, detail=f"Status inválido. Use: {', '.join(STATUS_NOTICIA)}")
|
|
51
|
+
for key, value in data.items():
|
|
52
|
+
setattr(noticia, key, value)
|
|
53
|
+
db.commit()
|
|
54
|
+
db.refresh(noticia)
|
|
55
|
+
return {
|
|
56
|
+
"id": noticia.id,
|
|
57
|
+
"blog_id": noticia.blog_id,
|
|
58
|
+
"titulo": noticia.titulo,
|
|
59
|
+
"corpo": noticia.corpo,
|
|
60
|
+
"resumo": noticia.resumo,
|
|
61
|
+
"imagem_url": noticia.imagem_url,
|
|
62
|
+
"status": noticia.status,
|
|
63
|
+
"llm_provider": noticia.llm_provider,
|
|
64
|
+
"created_at": noticia.created_at,
|
|
65
|
+
}
|
package/backend/app/schemas.py
CHANGED
|
@@ -1 +1,71 @@
|
|
|
1
|
-
"""Schemas Pydantic do PandaNews (
|
|
1
|
+
"""Schemas Pydantic do PandaNews (entrada e saída das rotas)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
TEMAS = ["geral", "esporte", "politica", "economia", "tecnologia"]
|
|
10
|
+
STATUS_BLOG = ["ativo", "pausado"]
|
|
11
|
+
STATUS_NOTICIA = ["rascunho", "aprovado", "rejeitado", "publicado"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BlogIn(BaseModel):
|
|
15
|
+
nome: str = Field(min_length=1, max_length=200)
|
|
16
|
+
tema: str = "geral"
|
|
17
|
+
regiao: str = "Brasil"
|
|
18
|
+
fontes_rss: List[str] = []
|
|
19
|
+
estilo_editorial: str = ""
|
|
20
|
+
frequencia_horas: int = Field(default=6, ge=1, le=168)
|
|
21
|
+
status: str = "ativo"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BlogPatch(BaseModel):
|
|
25
|
+
nome: Optional[str] = None
|
|
26
|
+
tema: Optional[str] = None
|
|
27
|
+
regiao: Optional[str] = None
|
|
28
|
+
fontes_rss: Optional[List[str]] = None
|
|
29
|
+
estilo_editorial: Optional[str] = None
|
|
30
|
+
frequencia_horas: Optional[int] = Field(default=None, ge=1, le=168)
|
|
31
|
+
status: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoticiaPatch(BaseModel):
|
|
35
|
+
titulo: Optional[str] = None
|
|
36
|
+
corpo: Optional[str] = None
|
|
37
|
+
resumo: Optional[str] = None
|
|
38
|
+
status: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfiguracoesPatch(BaseModel):
|
|
42
|
+
nvidia_api_key: Optional[str] = None
|
|
43
|
+
nvidia_base_url: Optional[str] = None
|
|
44
|
+
nvidia_model: Optional[str] = None
|
|
45
|
+
google_api_key: Optional[str] = None
|
|
46
|
+
google_base_url: Optional[str] = None
|
|
47
|
+
google_model: Optional[str] = None
|
|
48
|
+
llm_fallback_ordem: Optional[str] = None
|
|
49
|
+
supabase_url: Optional[str] = None
|
|
50
|
+
supabase_service_role_key: Optional[str] = None
|
|
51
|
+
db_backend: Optional[str] = None
|
|
52
|
+
scheduler_ativo: Optional[bool] = None
|
|
53
|
+
intervalo_padrao_horas: Optional[int] = Field(default=None, ge=1, le=168)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LlmTestIn(BaseModel):
|
|
57
|
+
provider: str # nvidia | google
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BlogOut(BaseModel):
|
|
61
|
+
model_config = ConfigDict(from_attributes=True)
|
|
62
|
+
|
|
63
|
+
id: int
|
|
64
|
+
nome: str
|
|
65
|
+
tema: str
|
|
66
|
+
regiao: str
|
|
67
|
+
fontes_rss: List[str]
|
|
68
|
+
estilo_editorial: str
|
|
69
|
+
frequencia_horas: int
|
|
70
|
+
status: str
|
|
71
|
+
created_at: datetime
|
package/backend/core/config.py
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
"""Configuração de runtime do PandaNews
|
|
1
|
+
"""Configuração de runtime do PandaNews.
|
|
2
|
+
|
|
3
|
+
Resolve a raiz estável do runtime (mesma lógica do CLI em Node):
|
|
4
|
+
PANDANEWS_HOME -> ~/.pandanews. Carrega .env quando presente.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
load_dotenv()
|
|
15
|
+
except Exception: # noqa: S110 - .env é opcional
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_home() -> Path:
|
|
20
|
+
env = os.environ.get("PANDANEWS_HOME")
|
|
21
|
+
if env:
|
|
22
|
+
return Path(env).expanduser().resolve()
|
|
23
|
+
return Path.home() / ".pandanews"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
HOME = resolve_home()
|
|
27
|
+
DATA_DIR = HOME / "data"
|
|
28
|
+
IMAGES_DIR = DATA_DIR / "imagens"
|
|
29
|
+
DB_PATH = DATA_DIR / "pandanews.db"
|
|
30
|
+
DB_URL = os.environ.get("PANDANEWS_DB_URL", f"sqlite:///{DB_PATH}")
|
|
31
|
+
|
|
32
|
+
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
|
33
|
+
SUPABASE_SERVICE_ROLE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
34
|
+
|
|
35
|
+
DEFAULT_LLM_BASE_URLS = {
|
|
36
|
+
"nvidia": "https://integrate.api.nvidia.com/v1",
|
|
37
|
+
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
38
|
+
}
|
|
39
|
+
DEFAULT_LLM_MODELS = {
|
|
40
|
+
"nvidia": "meta/llama-3.3-70b-instruct",
|
|
41
|
+
"google": "gemini-3.8-flash",
|
|
42
|
+
}
|
package/backend/core/database.py
CHANGED
|
@@ -1,6 +1,88 @@
|
|
|
1
|
-
"""Camada de dados do PandaNews
|
|
1
|
+
"""Camada de dados do PandaNews.
|
|
2
2
|
|
|
3
|
-
SQLite local por padrão (data/pandanews.db) via SQLAlchemy 2.0
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
SQLite local por padrão (data/pandanews.db) via SQLAlchemy 2.0. Supabase é
|
|
4
|
+
detectado via SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY (integração completa na
|
|
5
|
+
Fase 6). Tabelas: blogs, noticias, configuracoes, logs.
|
|
6
6
|
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import ForeignKey, String, Text, create_engine
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
|
13
|
+
|
|
14
|
+
from backend.core.config import DB_PATH, DB_URL
|
|
15
|
+
|
|
16
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
_engine_kwargs: dict = {}
|
|
19
|
+
if DB_URL.startswith("sqlite"):
|
|
20
|
+
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
|
21
|
+
engine = create_engine(DB_URL, **_engine_kwargs)
|
|
22
|
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Base(DeclarativeBase):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _now() -> datetime:
|
|
30
|
+
return datetime.now(timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Blog(Base):
|
|
34
|
+
__tablename__ = "blogs"
|
|
35
|
+
|
|
36
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
37
|
+
nome: Mapped[str] = mapped_column(String(200))
|
|
38
|
+
tema: Mapped[str] = mapped_column(String(50), default="geral")
|
|
39
|
+
regiao: Mapped[str] = mapped_column(String(100), default="Brasil")
|
|
40
|
+
fontes_rss: Mapped[str] = mapped_column(Text, default="[]") # JSON list
|
|
41
|
+
estilo_editorial: Mapped[str] = mapped_column(Text, default="")
|
|
42
|
+
frequencia_horas: Mapped[int] = mapped_column(default=6)
|
|
43
|
+
status: Mapped[str] = mapped_column(String(20), default="ativo") # ativo | pausado
|
|
44
|
+
created_at: Mapped[datetime] = mapped_column(default=_now)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Noticia(Base):
|
|
48
|
+
__tablename__ = "noticias"
|
|
49
|
+
|
|
50
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
51
|
+
blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.id"), index=True)
|
|
52
|
+
titulo: Mapped[str] = mapped_column(String(300), default="")
|
|
53
|
+
corpo: Mapped[str] = mapped_column(Text, default="")
|
|
54
|
+
resumo: Mapped[str] = mapped_column(Text, default="")
|
|
55
|
+
imagem_url: Mapped[str] = mapped_column(String(500), default="")
|
|
56
|
+
status: Mapped[str] = mapped_column(String(20), default="rascunho", index=True)
|
|
57
|
+
llm_provider: Mapped[str] = mapped_column(String(50), default="")
|
|
58
|
+
created_at: Mapped[datetime] = mapped_column(default=_now, index=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Configuracao(Base):
|
|
62
|
+
__tablename__ = "configuracoes"
|
|
63
|
+
|
|
64
|
+
chave: Mapped[str] = mapped_column(String(100), primary_key=True)
|
|
65
|
+
valor: Mapped[str] = mapped_column(Text, default="")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Log(Base):
|
|
69
|
+
__tablename__ = "logs"
|
|
70
|
+
|
|
71
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
72
|
+
blog_id: Mapped[int | None] = mapped_column(ForeignKey("blogs.id"), nullable=True, index=True)
|
|
73
|
+
etapa: Mapped[str] = mapped_column(String(50), index=True)
|
|
74
|
+
status: Mapped[str] = mapped_column(String(20), index=True) # sucesso | erro
|
|
75
|
+
mensagem: Mapped[str] = mapped_column(Text, default="")
|
|
76
|
+
created_at: Mapped[datetime] = mapped_column(default=_now, index=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def init_db() -> None:
|
|
80
|
+
Base.metadata.create_all(bind=engine)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_db():
|
|
84
|
+
db = SessionLocal()
|
|
85
|
+
try:
|
|
86
|
+
yield db
|
|
87
|
+
finally:
|
|
88
|
+
db.close()
|