@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 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.1.03)
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`)
@@ -1,13 +1,25 @@
1
1
  """PandaNews API — FastAPI.
2
2
 
3
- Fase 0/1: aplicação mínima com endpoint de health e CORS liberado para a UI
4
- local (localhost/127.0.0.1 em qualquer porta — a UI usa porta dinâmica a partir
5
- de 4030). CRUD de blogs e configurações chegam na Fase 1.
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 = FastAPI(title="PandaNews API", version="0.1.3")
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
+ }
@@ -1 +1,71 @@
1
- """Schemas Pydantic do PandaNews (preenchidos na Fase 1)."""
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
@@ -1 +1,42 @@
1
- """Configuração de runtime do PandaNews (Fase 1): .env, caminhos e constantes."""
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
+ }
@@ -1,6 +1,88 @@
1
- """Camada de dados do PandaNews (Fase 1).
1
+ """Camada de dados do PandaNews.
2
2
 
3
- SQLite local por padrão (data/pandanews.db) via SQLAlchemy 2.0, com detecção
4
- automática de Supabase quando SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY estão
5
- definidas no ambiente. Tabelas: blogs, noticias, configuracoes, logs.
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()