@danhachuel/pandanews 0.2.1 → 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 +17 -0
- package/README.md +5 -4
- package/backend/app/main.py +26 -6
- package/backend/app/routers/automacao.py +47 -0
- package/backend/app/routers/blogs.py +10 -0
- package/backend/app/routers/configuracoes.py +12 -0
- package/backend/app/routers/imagens.py +49 -0
- package/backend/app/routers/noticias.py +46 -0
- package/backend/core/database.py +31 -0
- package/backend/core/image_gen.py +82 -4
- package/backend/core/pipeline.py +212 -4
- package/backend/core/scheduler.py +80 -0
- package/frontend/dist/assets/index-BcgBzdzo.js +70 -0
- package/frontend/dist/assets/index-gsZzhCX8.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -2
- package/scripts/start.js +52 -34
- package/frontend/dist/assets/index-BaPdaYIz.css +0 -1
- package/frontend/dist/assets/index-D0e-vr7T.js +0 -70
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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
|
+
|
|
14
|
+
## 0.2.02 (2026-10-24)
|
|
15
|
+
|
|
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)
|
|
17
|
+
- **Terminal limpo**: logs de acesso do uvicorn (stdout — `GET /health`, `GET /api/v1/*` etc.) vão apenas para `data/backend.log`; o console mantém somente stderr (banner de inicialização, WARNING/ERROR e tracebacks)
|
|
18
|
+
- **Fallback de porta também para o backend**: se 8000 estiver ocupada (ex.: outra instância), o backend sobe em 8001, 8002... e o proxy da UI segue automaticamente — junto com o fallback já existente da UI (4030, 4031...)
|
|
19
|
+
- Novo endpoint `GET /health` na raiz (200 OK) para clientes que esperam esse caminho — elimina os 404; o health canônico segue em `/api/v1/health`
|
|
20
|
+
- `data/backend.log` continua registrando TODAS as requisições (incluindo `/health`) para auditoria — o filtro no logger `uvicorn.access` (Python) foi deliberadamente evitado: removeria `/health` também do arquivo de auditoria; o silenciamento é feito apenas no console
|
|
21
|
+
|
|
5
22
|
## 0.2.01 (2026-10-23)
|
|
6
23
|
|
|
7
24
|
- **Blindagem do backend**: saída do uvicorn espelhada no console e gravada em `<home>/data/backend.log` — queda anormal registra o motivo para diagnóstico
|
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.
|
|
37
|
+
## Status (versão 0.3.00)
|
|
38
38
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
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`)
|
package/backend/app/main.py
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
"""PandaNews API — FastAPI.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
dashboard com
|
|
5
|
-
|
|
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.
|
|
34
|
+
app = FastAPI(title="PandaNews API", version="0.3.0", lifespan=lifespan)
|
|
23
35
|
|
|
24
36
|
app.add_middleware(
|
|
25
37
|
CORSMiddleware,
|
|
@@ -33,9 +45,17 @@ 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
|
|
|
39
53
|
@app.get("/api/v1/health")
|
|
40
54
|
def health() -> dict:
|
|
41
55
|
return {"status": "ok", "service": "pandanews", "version": app.version}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.get("/health")
|
|
59
|
+
def health_root() -> dict:
|
|
60
|
+
"""Alias na raiz para clientes que esperam /health (extensões, monitores)."""
|
|
61
|
+
return {"status": "ok", "service": "pandanews", "version": app.version}
|
|
@@ -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,
|
package/backend/core/database.py
CHANGED
|
@@ -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 (
|
|
1
|
+
"""Geração de imagens do PandaNews (Gemini Image).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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)
|