@danhachuel/pandanews 0.2.2 → 0.3.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,15 @@
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.3.00 (2026-10-24)
6
+
7
+ - **Pipeline de notícias completo** (Fase 3): coleta RSS (feedparser) → filtragem por relevância (palavras-chave do tema + bônus de região) → redação via LLM com fallback NVIDIA → Gemini (resposta em JSON: título, resumo, corpo) → imagem fotojornalística via Gemini Image salva em `data/imagens/`
8
+ - **Scheduler ativo** (Fase 5): APScheduler com um job por blog ativo (intervalo = frequência do blog), toggle global em Automação liga/desliga de fato, recarregamento automático ao criar/editar/pausar/excluir blogs
9
+ - **Novos endpoints**: `POST /api/v1/noticias/gerar` (dispara o pipeline), `POST /api/v1/noticias/{id}/regenerar`, `GET/POST /api/v1/imagens` (galeria, arquivo e geração manual por prompt), `GET /api/v1/automacao/status` (próxima execução por blog), `POST /api/v1/automacao/executar`
10
+ - Dedup de itens: tabela `vistos` evita reutilizar o mesmo item de feed; migração leve adiciona `fonte_url`/`fonte_titulo` às notícias em bancos existentes
11
+ - Logs de execução por etapa (coleta/redação/imagem/pipeline) visíveis na página Logs
12
+ - UI: botão "Gerar notícia agora" com stepper de progresso (Gradiente de Meta: coletando → redigindo → gerando imagem → salvo), ação Regenerar nas notícias, galeria real de imagens com geração manual e próxima execução por blog na Automação
13
+
5
14
  ## 0.2.02 (2026-10-24)
6
15
 
7
16
  - **O sistema NUNCA abre o navegador**: o app exibe o endereço no terminal e o usuário decide quando abrir (dependência `open` removida)
package/README.md CHANGED
@@ -34,11 +34,12 @@ 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.2.00)
37
+ ## Status (versão 0.3.00)
38
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)
39
+ - **Pipeline completo**: coleta RSS → redação via LLM (fallback NVIDIA → Gemini) → imagem fotojornalística via Gemini Image → rascunho para revisão
40
+ - **Scheduler ativo**: um job por blog ativo (frequência configurável), toggle global em Automação e execução imediata (⚡ Gerar notícia agora)
41
+ - UI completa: Dashboard, Blogs, Notícias (gerar/regenerar/aprovar), Imagens (galeria + geração manual), Automação, Configurações, Logs
42
+ - Próximo (Fase 6): detecção automática de Supabase, migração SQLite → Supabase e revisão das 7 leis de UX
42
43
 
43
44
  - `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, ...)
44
45
  - 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,25 +1,37 @@
1
1
  """PandaNews API — FastAPI.
2
2
 
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.
3
+ Fases 1, 3 e 5: CRUD de blogs, configurações (keys mascaradas), teste de LLM,
4
+ dashboard, notícias (com geração via pipeline), imagens, logs, automação com
5
+ scheduler ativo por blog. SQLite local criado automaticamente no startup.
6
+ CORS liberado para a UI local (localhost em qualquer porta).
6
7
  """
7
8
  from contextlib import asynccontextmanager
8
9
 
9
10
  from fastapi import FastAPI
10
11
  from fastapi.middleware.cors import CORSMiddleware
11
12
 
12
- from backend.app.routers import blogs, configuracoes, dashboard, llm, logs, noticias
13
- from backend.core.database import init_db
13
+ from backend.app.routers import automacao, blogs, configuracoes, dashboard, imagens, llm, logs, noticias
14
+ from backend.core.database import SessionLocal, init_db
15
+ from backend.core.llm_router import get_config
14
16
 
15
17
 
16
18
  @asynccontextmanager
17
19
  async def lifespan(_: FastAPI):
18
20
  init_db()
21
+ from backend.core import scheduler
22
+
23
+ db = SessionLocal()
24
+ try:
25
+ if get_config(db, "scheduler_ativo", "true") == "true":
26
+ scheduler.iniciar()
27
+ scheduler.recarregar()
28
+ finally:
29
+ db.close()
19
30
  yield
31
+ scheduler.parar()
20
32
 
21
33
 
22
- app = FastAPI(title="PandaNews API", version="0.2.2", lifespan=lifespan)
34
+ app = FastAPI(title="PandaNews API", version="0.3.0", lifespan=lifespan)
23
35
 
24
36
  app.add_middleware(
25
37
  CORSMiddleware,
@@ -33,6 +45,8 @@ app.include_router(configuracoes.router, prefix="/api/v1")
33
45
  app.include_router(dashboard.router, prefix="/api/v1")
34
46
  app.include_router(llm.router, prefix="/api/v1")
35
47
  app.include_router(noticias.router, prefix="/api/v1")
48
+ app.include_router(imagens.router, prefix="/api/v1")
49
+ app.include_router(automacao.router, prefix="/api/v1")
36
50
  app.include_router(logs.router, prefix="/api/v1")
37
51
 
38
52
 
@@ -0,0 +1,47 @@
1
+ """Automação (/api/v1/automacao): estado do scheduler e execução imediata."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends
5
+ from pydantic import BaseModel
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from backend.core import scheduler
10
+ from backend.core.database import Blog, get_db
11
+
12
+ router = APIRouter(prefix="/automacao", tags=["automacao"])
13
+
14
+
15
+ class BlogIdIn(BaseModel):
16
+ blog_id: int
17
+
18
+
19
+ @router.get("/status")
20
+ def status(db: Session = Depends(get_db)) -> dict:
21
+ jobs = {job["blog_id"]: job["proxima_execucao"] for job in scheduler.jobs()}
22
+ blogs = db.scalars(select(Blog).where(Blog.status == "ativo")).all()
23
+ return {
24
+ "ativo": scheduler.esta_ativo(),
25
+ "jobs": [
26
+ {
27
+ "blog_id": blog.id,
28
+ "nome": blog.nome,
29
+ "frequencia_horas": blog.frequencia_horas,
30
+ "proxima_execucao": jobs.get(blog.id),
31
+ }
32
+ for blog in blogs
33
+ ],
34
+ }
35
+
36
+
37
+ @router.post("/executar")
38
+ def executar_agora(payload: BlogIdIn) -> dict:
39
+ from backend.core import pipeline
40
+
41
+ noticia = pipeline.gerar_noticia(payload.blog_id)
42
+ if noticia is None:
43
+ return {
44
+ "ok": False,
45
+ "detail": "Nenhuma notícia nova: sem itens nas fontes, todos já utilizados ou erro na geração — verifique os Logs.",
46
+ }
47
+ return {"ok": True, "noticia_id": noticia.id, "titulo": noticia.titulo}
@@ -41,6 +41,13 @@ def obter_blog(blog_id: int, db: Session = Depends(get_db)) -> dict:
41
41
  return blog_to_dict(blog)
42
42
 
43
43
 
44
+ def _sync_scheduler() -> None:
45
+ from backend.core import scheduler
46
+
47
+ if scheduler.esta_ativo():
48
+ scheduler.recarregar()
49
+
50
+
44
51
  @router.post("", status_code=201)
45
52
  def criar_blog(payload: BlogIn, db: Session = Depends(get_db)) -> dict:
46
53
  blog = Blog(
@@ -56,6 +63,7 @@ def criar_blog(payload: BlogIn, db: Session = Depends(get_db)) -> dict:
56
63
  db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog criado: {payload.nome}"))
57
64
  db.commit()
58
65
  db.refresh(blog)
66
+ _sync_scheduler()
59
67
  return blog_to_dict(blog)
60
68
 
61
69
 
@@ -71,6 +79,7 @@ def atualizar_blog(blog_id: int, payload: BlogPatch, db: Session = Depends(get_d
71
79
  setattr(blog, key, value)
72
80
  db.commit()
73
81
  db.refresh(blog)
82
+ _sync_scheduler()
74
83
  return blog_to_dict(blog)
75
84
 
76
85
 
@@ -85,3 +94,4 @@ def excluir_blog(blog_id: int, db: Session = Depends(get_db)) -> None:
85
94
  db.delete(blog)
86
95
  db.add(Log(etapa="blog", status="sucesso", mensagem=f"Blog excluído: {blog.nome}"))
87
96
  db.commit()
97
+ _sync_scheduler()
@@ -55,4 +55,16 @@ def salvar_configuracoes(payload: ConfiguracoesPatch, db: Session = Depends(get_
55
55
  else:
56
56
  row.valor = str(valor)
57
57
  db.commit()
58
+ if "scheduler_ativo" in data:
59
+ from backend.core import scheduler
60
+
61
+ bruto = data["scheduler_ativo"]
62
+ ligado = bruto if isinstance(bruto, bool) else str(bruto).lower() == "true"
63
+ if ligado and not scheduler.esta_ativo():
64
+ scheduler.iniciar()
65
+ scheduler.recarregar()
66
+ elif ligado:
67
+ scheduler.recarregar()
68
+ else:
69
+ scheduler.parar()
58
70
  return obter_configuracoes(db)
@@ -0,0 +1,49 @@
1
+ """Imagens (/api/v1/imagens): galeria, arquivo e geração manual."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from fastapi.responses import FileResponse
6
+ from pydantic import BaseModel
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from backend.core.config import IMAGES_DIR
11
+ from backend.core.database import Noticia, get_db
12
+
13
+ router = APIRouter(prefix="/imagens", tags=["imagens"])
14
+
15
+
16
+ class PromptIn(BaseModel):
17
+ prompt: str
18
+
19
+
20
+ @router.get("")
21
+ def listar_imagens(db: Session = Depends(get_db)) -> list[dict]:
22
+ noticias = db.scalars(
23
+ select(Noticia).where(Noticia.imagem_url != "").order_by(Noticia.created_at.desc()).limit(100)
24
+ ).all()
25
+ return [
26
+ {"noticia_id": n.id, "url": n.imagem_url, "titulo": n.titulo, "created_at": n.created_at}
27
+ for n in noticias
28
+ ]
29
+
30
+
31
+ @router.get("/{nome}")
32
+ def servir_imagem(nome: str) -> FileResponse:
33
+ if "/" in nome or "\\" in nome or ".." in nome or not nome.endswith(".png"):
34
+ raise HTTPException(status_code=400, detail="Nome de arquivo inválido")
35
+ caminho = IMAGES_DIR / nome
36
+ if not caminho.exists():
37
+ raise HTTPException(status_code=404, detail="Imagem não encontrada")
38
+ return FileResponse(caminho, media_type="image/png")
39
+
40
+
41
+ @router.post("/gerar")
42
+ def gerar_imagem_manual(payload: PromptIn, db: Session = Depends(get_db)) -> dict:
43
+ from backend.core import image_gen
44
+
45
+ try:
46
+ url = image_gen.imagem_manual(db, payload.prompt.strip())
47
+ return {"ok": True, "url": url}
48
+ except Exception as exc: # noqa: BLE001
49
+ return {"ok": False, "detail": str(exc)}
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from fastapi import APIRouter, Depends, HTTPException
5
+ from pydantic import BaseModel
5
6
  from sqlalchemy import select
6
7
  from sqlalchemy.orm import Session
7
8
 
@@ -11,6 +12,51 @@ from backend.core.database import Noticia, get_db
11
12
  router = APIRouter(prefix="/noticias", tags=["noticias"])
12
13
 
13
14
 
15
+ class GerarIn(BaseModel):
16
+ blog_id: int
17
+
18
+
19
+ def _serializar(n: Noticia) -> dict:
20
+ return {
21
+ "id": n.id,
22
+ "blog_id": n.blog_id,
23
+ "titulo": n.titulo,
24
+ "corpo": n.corpo,
25
+ "resumo": n.resumo,
26
+ "imagem_url": n.imagem_url,
27
+ "status": n.status,
28
+ "llm_provider": n.llm_provider,
29
+ "fonte_url": n.fonte_url,
30
+ "fonte_titulo": n.fonte_titulo,
31
+ "created_at": n.created_at,
32
+ }
33
+
34
+
35
+ @router.post("/gerar", status_code=201)
36
+ def gerar_noticia(payload: GerarIn) -> dict:
37
+ from backend.core import pipeline
38
+
39
+ noticia = pipeline.gerar_noticia(payload.blog_id)
40
+ if noticia is None:
41
+ raise HTTPException(
42
+ status_code=409,
43
+ detail="Nenhuma notícia nova: sem itens nas fontes, todos já utilizados ou erro na geração — verifique os Logs.",
44
+ )
45
+ return _serializar(noticia)
46
+
47
+
48
+ @router.post("/{noticia_id}/regenerar")
49
+ def regenerar_noticia(noticia_id: int) -> dict:
50
+ from backend.core import pipeline
51
+
52
+ noticia = pipeline.regenerar_noticia(noticia_id)
53
+ if noticia is None:
54
+ raise HTTPException(status_code=404, detail="Notícia (ou blog/fonte) não encontrada")
55
+ if not noticia.fonte_titulo and not noticia.fonte_url:
56
+ raise HTTPException(status_code=422, detail="Notícia sem fonte associada — use Gerar notícia para criar uma nova")
57
+ return _serializar(noticia)
58
+
59
+
14
60
  @router.get("")
15
61
  def listar_noticias(
16
62
  blog_id: int | None = None,
@@ -55,9 +55,22 @@ class Noticia(Base):
55
55
  imagem_url: Mapped[str] = mapped_column(String(500), default="")
56
56
  status: Mapped[str] = mapped_column(String(20), default="rascunho", index=True)
57
57
  llm_provider: Mapped[str] = mapped_column(String(50), default="")
58
+ fonte_url: Mapped[str] = mapped_column(String(600), default="")
59
+ fonte_titulo: Mapped[str] = mapped_column(Text, default="")
58
60
  created_at: Mapped[datetime] = mapped_column(default=_now, index=True)
59
61
 
60
62
 
63
+ class Visto(Base):
64
+ """Itens de feed já utilizados por blog (dedup do pipeline)."""
65
+
66
+ __tablename__ = "vistos"
67
+
68
+ id: Mapped[int] = mapped_column(primary_key=True)
69
+ blog_id: Mapped[int] = mapped_column(ForeignKey("blogs.id"), index=True)
70
+ url: Mapped[str] = mapped_column(String(600))
71
+ created_at: Mapped[datetime] = mapped_column(default=_now)
72
+
73
+
61
74
  class Configuracao(Base):
62
75
  __tablename__ = "configuracoes"
63
76
 
@@ -78,6 +91,24 @@ class Log(Base):
78
91
 
79
92
  def init_db() -> None:
80
93
  Base.metadata.create_all(bind=engine)
94
+ _migrate()
95
+
96
+
97
+ def _migrate() -> None:
98
+ """Migrações leves para bancos existentes (SQLite)."""
99
+ if not DB_URL.startswith("sqlite"):
100
+ return
101
+ from sqlalchemy import text
102
+
103
+ with engine.connect() as conn:
104
+ colunas = [row[1] for row in conn.execute(text("PRAGMA table_info(noticias)"))]
105
+ for coluna, ddl in (
106
+ ("fonte_url", "VARCHAR(600) DEFAULT ''"),
107
+ ("fonte_titulo", "TEXT DEFAULT ''"),
108
+ ):
109
+ if coluna not in colunas:
110
+ conn.execute(text(f"ALTER TABLE noticias ADD COLUMN {coluna} {ddl}"))
111
+ conn.commit()
81
112
 
82
113
 
83
114
  def get_db():
@@ -1,6 +1,84 @@
1
- """Geração de imagens do PandaNews (Fase 3).
1
+ """Geração de imagens do PandaNews (Gemini Image).
2
2
 
3
- Gemini Image API com prompt fotojornalístico a partir de palavras-chave da
4
- notícia; imagens salvas em data/imagens/{noticia_id}.png. Se falhar, a notícia
5
- segue sem imagem e é sinalizada na UI para ação manual.
3
+ Extrai palavras-chave do título/resumo da notícia (TF simples), monta um
4
+ prompt fotojornalístico e chama a Gemini Image API. A imagem é salva em
5
+ <home>/data/imagens/ e servida por /api/v1/imagens/{nome}. Se a geração
6
+ falhar, a notícia segue sem imagem (sinalizada na UI) — o pipeline não para.
6
7
  """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import re
12
+ from datetime import datetime, timezone
13
+
14
+ import httpx
15
+
16
+ from backend.core.config import IMAGES_DIR
17
+ from backend.core.llm_router import get_config
18
+
19
+ IMAGE_MODEL_DEFAULT = "gemini-3.1-flash-image"
20
+
21
+ STOPWORDS = set(
22
+ "a o e de da do das dos em no na nos nas um uma com para por que se ao aos "
23
+ "as os mais como onde quando após sobre entre sua seu suas seus pelo pela "
24
+ "isso esse essa isto este esta foi ser ter pela pelos".split()
25
+ )
26
+
27
+
28
+ def extrair_palavras_chave(texto: str, max_palavras: int = 10) -> list[str]:
29
+ palavras = re.findall(r"[a-záàâãéêíóôõúüç]{4,}", (texto or "").lower())
30
+ contagem: dict[str, int] = {}
31
+ for palavra in palavras:
32
+ if palavra in STOPWORDS:
33
+ continue
34
+ contagem[palavra] = contagem.get(palavra, 0) + 1
35
+ ordenadas = sorted(contagem, key=contagem.get, reverse=True)
36
+ return ordenadas[:max_palavras]
37
+
38
+
39
+ def gerar_imagem(db, prompt_visual: str) -> bytes:
40
+ """Chama a Gemini Image API e devolve os bytes da imagem."""
41
+ api_key = get_config(db, "google_api_key")
42
+ if not api_key:
43
+ raise RuntimeError("API key do Google Gemini não configurada — adicione em Configurações para gerar imagens.")
44
+ modelo = get_config(db, "google_image_model", IMAGE_MODEL_DEFAULT)
45
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{modelo}:generateContent"
46
+ payload = {
47
+ "contents": [{"parts": [{"text": prompt_visual}]}],
48
+ "generationConfig": {"responseModalities": ["IMAGE"]},
49
+ }
50
+ resposta = httpx.post(url, json=payload, headers={"x-goog-api-key": api_key}, timeout=180.0)
51
+ if resposta.status_code != 200:
52
+ raise RuntimeError(f"Gemini Image respondeu {resposta.status_code}: {resposta.text[:300]}")
53
+ dados = resposta.json()
54
+ for candidato in dados.get("candidates", []):
55
+ for parte in candidato.get("content", {}).get("parts", []):
56
+ inline = parte.get("inlineData") or parte.get("inline_data")
57
+ if inline and inline.get("data"):
58
+ return base64.b64decode(inline["data"])
59
+ raise RuntimeError("Gemini Image não retornou imagem — verifique o modelo configurado em Configurações.")
60
+
61
+
62
+ def _salvar(dados: bytes, nome: str) -> str:
63
+ IMAGES_DIR.mkdir(parents=True, exist_ok=True)
64
+ (IMAGES_DIR / nome).write_bytes(dados)
65
+ return f"/api/v1/imagens/{nome}"
66
+
67
+
68
+ def imagem_para_noticia(db, noticia) -> str:
69
+ """Gera a imagem de capa de uma notícia (estilo fotojornalístico)."""
70
+ chaves = extrair_palavras_chave(f"{noticia.titulo} {noticia.resumo}")
71
+ prompt = (
72
+ "Fotografia jornalística profissional, iluminação natural, realista, "
73
+ "sem texto ou marca d'água, retratando: "
74
+ f"{', '.join(chaves) if chaves else (noticia.titulo or 'notícias gerais')}"
75
+ )
76
+ dados = gerar_imagem(db, prompt)
77
+ return _salvar(dados, f"{noticia.id}.png")
78
+
79
+
80
+ def imagem_manual(db, prompt: str) -> str:
81
+ """Gera uma imagem a partir de prompt livre (página Imagens)."""
82
+ dados = gerar_imagem(db, prompt)
83
+ nome = f"manual-{int(datetime.now(timezone.utc).timestamp() * 1000)}.png"
84
+ return _salvar(dados, nome)
@@ -1,6 +1,214 @@
1
- """Pipeline de notícias do PandaNews (Fase 3).
1
+ """Pipeline de notícias do PandaNews.
2
2
 
3
- Coleta RSS -> filtragem por relevância -> rascunho via LLM -> imagem via Gemini
4
- -> revisão humana opcional -> publicação. Disparado por APScheduler conforme a
5
- frequência de cada blog ou manualmente pela interface.
3
+ Coleta RSS → filtragem por relevância (palavras-chave do tema/região) →
4
+ rascunho via LLM (fallback ordenado NVIDIA → Gemini) → imagem
5
+ fotojornalística via Gemini → status rascunho. Cada etapa registra logs
6
+ (sucesso/erro) por blog; itens já utilizados são deduplicados (tabela vistos).
6
7
  """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+
13
+ import feedparser
14
+ from sqlalchemy import select
15
+
16
+ from backend.core import image_gen, llm_router
17
+ from backend.core.database import Blog, Log, Noticia, SessionLocal, Visto
18
+
19
+ TEMAS_PALAVRAS: dict[str, list[str]] = {
20
+ "esporte": [
21
+ "futebol", "jogo", "time", "campeonato", "gol", "partida", "atleta",
22
+ "seleção", "torcida", "liga", "olimpíadas", "campeão", "vitória",
23
+ ],
24
+ "politica": [
25
+ "governo", "presidente", "congresso", "eleição", "eleições", "deputado",
26
+ "senado", "senador", "ministro", "partido", "prefeito", "reforma", "voto",
27
+ ],
28
+ "economia": [
29
+ "mercado", "dólar", "inflação", "juros", "bolsa", "pib", "economia",
30
+ "impostos", "emprego", "salário", "banco central", "câmbio",
31
+ ],
32
+ "tecnologia": [
33
+ "tecnologia", "inteligência artificial", "app", "startup", "software",
34
+ "chip", "smartphone", "internet", "dados", "robô", "algoritmo",
35
+ ],
36
+ "geral": [],
37
+ }
38
+
39
+
40
+ def _log(db, blog_id: int | None, etapa: str, status: str, mensagem: str) -> None:
41
+ db.add(Log(blog_id=blog_id, etapa=etapa, status=status, mensagem=mensagem[:2000]))
42
+ db.commit()
43
+
44
+
45
+ def _limpar(texto: str, limite: int) -> str:
46
+ texto = re.sub(r"<[^>]+>", " ", texto or "")
47
+ return " ".join(texto.split())[:limite]
48
+
49
+
50
+ def coletar_itens(db, blog: Blog, fontes: list[str]) -> list[dict]:
51
+ itens: list[dict] = []
52
+ for url in fontes:
53
+ try:
54
+ feed = feedparser.parse(url)
55
+ if getattr(feed, "bozo", 0) and not feed.entries:
56
+ erro = getattr(feed, "bozo_exception", "formato inválido")
57
+ _log(db, blog.id, "coleta", "erro", f"Falha ao ler feed {url}: {erro}")
58
+ continue
59
+ for entrada in feed.entries[:15]:
60
+ itens.append(
61
+ {
62
+ "titulo": _limpar(getattr(entrada, "title", ""), 300),
63
+ "link": getattr(entrada, "link", "") or "",
64
+ "resumo": _limpar(getattr(entrada, "summary", ""), 600),
65
+ }
66
+ )
67
+ _log(db, blog.id, "coleta", "sucesso", f"{len(feed.entries)} itens coletados de {url}")
68
+ except Exception as exc: # noqa: BLE001
69
+ _log(db, blog.id, "coleta", "erro", f"Exceção ao ler {url}: {exc}")
70
+ return itens
71
+
72
+
73
+ def score_relevancia(item: dict, blog: Blog) -> float:
74
+ texto = f"{item['titulo']} {item['resumo']}".lower()
75
+ pontos = 0.0
76
+ for palavra in TEMAS_PALAVRAS.get(blog.tema, []):
77
+ if palavra in texto:
78
+ pontos += 1.0
79
+ regiao = (blog.regiao or "").strip().lower()
80
+ if regiao and regiao in texto:
81
+ pontos += 2.0
82
+ return pontos
83
+
84
+
85
+ def _prompt_redacao(blog: Blog, item: dict) -> str:
86
+ estilo = (blog.estilo_editorial or "").strip() or "jornalismo claro e direto em português do Brasil"
87
+ return (
88
+ "Você é editor-chefe de um blog de notícias. Escreva uma notícia ORIGINAL em "
89
+ "português do Brasil a partir da fonte abaixo.\n"
90
+ f"Blog: {blog.nome} — tema: {blog.tema} — região: {blog.regiao}\n"
91
+ f"Estilo editorial: {estilo}\n"
92
+ f"Fonte: \"{item['titulo']}\"\n"
93
+ f"Resumo da fonte: {item['resumo']}\n"
94
+ "Regras: reescreva com palavras próprias; não invente fatos que não estejam "
95
+ "na fonte; 3 a 5 parágrafos.\n"
96
+ 'Responda SOMENTE com JSON válido no formato: {"titulo": "...", "resumo": "...", "corpo": "..."}'
97
+ )
98
+
99
+
100
+ def _parse_resposta_llm(texto: str) -> dict:
101
+ bruto = (texto or "").strip()
102
+ bruto = re.sub(r"^```(json)?\s*|\s*```$", "", bruto)
103
+ try:
104
+ return json.loads(bruto)
105
+ except Exception: # noqa: BLE001
106
+ casado = re.search(r"\{.*\}", bruto, re.DOTALL)
107
+ if casado:
108
+ try:
109
+ return json.loads(casado.group(0))
110
+ except Exception: # noqa: BLE001
111
+ pass
112
+ return {"titulo": "", "resumo": "", "corpo": bruto}
113
+
114
+
115
+ def gerar_noticia(blog_id: int) -> Noticia | None:
116
+ """Pipeline completo para um blog. Retorna a notícia criada ou None."""
117
+ db = SessionLocal()
118
+ try:
119
+ blog = db.get(Blog, blog_id)
120
+ if blog is None:
121
+ _log(db, None, "pipeline", "erro", f"Blog {blog_id} não encontrado")
122
+ return None
123
+ fontes = json.loads(blog.fontes_rss or "[]")
124
+ if not fontes:
125
+ _log(db, blog.id, "coleta", "erro", "Blog sem fontes RSS configuradas")
126
+ return None
127
+
128
+ # 1. Coleta
129
+ itens = coletar_itens(db, blog, fontes)
130
+ if not itens:
131
+ _log(db, blog.id, "coleta", "erro", "Nenhum item coletado nas fontes")
132
+ return None
133
+
134
+ # 2. Dedup + relevância
135
+ vistos = {v.url for v in db.scalars(select(Visto).where(Visto.blog_id == blog.id)).all()}
136
+ novos = [i for i in itens if i["link"] and i["link"] not in vistos]
137
+ if not novos:
138
+ _log(db, blog.id, "coleta", "sucesso", "Todos os itens das fontes já haviam sido utilizados")
139
+ return None
140
+ novos.sort(key=lambda i: score_relevancia(i, blog), reverse=True)
141
+ item = novos[0]
142
+
143
+ # 3. Redação via LLM (fallback NVIDIA -> Gemini)
144
+ try:
145
+ resposta, provedor = llm_router.gerar_texto(db, _prompt_redacao(blog, item), max_tokens=1600)
146
+ except Exception as exc: # noqa: BLE001
147
+ _log(db, blog.id, "redacao", "erro", f"Falha na redação: {exc}")
148
+ return None
149
+ dados = _parse_resposta_llm(resposta)
150
+ titulo = str(dados.get("titulo") or item["titulo"]).strip()
151
+ resumo = str(dados.get("resumo") or _limpar(item["resumo"], 240)).strip()
152
+ corpo = str(dados.get("corpo") or resposta).strip()
153
+ _log(db, blog.id, "redacao", "sucesso", f"Notícia redigida via {provedor}: {titulo}")
154
+
155
+ noticia = Noticia(
156
+ blog_id=blog.id,
157
+ titulo=titulo,
158
+ resumo=resumo,
159
+ corpo=corpo,
160
+ status="rascunho",
161
+ llm_provider=provedor,
162
+ fonte_url=item["link"],
163
+ fonte_titulo=item["titulo"],
164
+ )
165
+ db.add(noticia)
166
+ db.add(Visto(blog_id=blog.id, url=item["link"]))
167
+ db.commit()
168
+ db.refresh(noticia)
169
+
170
+ # 4. Imagem (falha não bloqueia: notícia fica sinalizada "sem imagem")
171
+ try:
172
+ caminho = image_gen.imagem_para_noticia(db, noticia)
173
+ noticia.imagem_url = caminho
174
+ db.commit()
175
+ _log(db, blog.id, "imagem", "sucesso", f"Imagem gerada: {caminho}")
176
+ except Exception as exc: # noqa: BLE001
177
+ _log(db, blog.id, "imagem", "erro", f"Imagem não gerada: {exc}")
178
+
179
+ return noticia
180
+ finally:
181
+ db.close()
182
+
183
+
184
+ def regenerar_noticia(noticia_id: int) -> Noticia | None:
185
+ """Re-roda a redação de uma notícia existente a partir da fonte original."""
186
+ db = SessionLocal()
187
+ try:
188
+ noticia = db.get(Noticia, noticia_id)
189
+ if noticia is None:
190
+ return None
191
+ blog = db.get(Blog, noticia.blog_id)
192
+ if blog is None:
193
+ return None
194
+ if not noticia.fonte_titulo and not noticia.fonte_url:
195
+ _log(db, blog.id, "redacao", "erro", f"Notícia {noticia_id} sem fonte associada")
196
+ return noticia
197
+ prompt = _prompt_redacao(
198
+ blog,
199
+ {"titulo": noticia.fonte_titulo, "resumo": noticia.resumo or noticia.corpo[:300], "link": noticia.fonte_url},
200
+ )
201
+ resposta, provedor = llm_router.gerar_texto(db, prompt, max_tokens=1600)
202
+ dados = _parse_resposta_llm(resposta)
203
+ if dados.get("titulo"):
204
+ noticia.titulo = str(dados["titulo"]).strip()
205
+ if dados.get("resumo"):
206
+ noticia.resumo = str(dados["resumo"]).strip()
207
+ noticia.corpo = str(dados.get("corpo") or resposta).strip()
208
+ noticia.llm_provider = provedor
209
+ db.commit()
210
+ db.refresh(noticia)
211
+ _log(db, blog.id, "redacao", "sucesso", f"Notícia {noticia_id} regenerada via {provedor}")
212
+ return noticia
213
+ finally:
214
+ db.close()