@danhachuel/pandanews 0.2.2 → 0.3.1

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.
@@ -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()
@@ -0,0 +1,80 @@
1
+ """Scheduler do PandaNews (APScheduler).
2
+
3
+ Um job por blog ativo, com intervalo = frequencia_horas do blog. O estado
4
+ global (configuracoes.scheduler_ativo) liga/desliga tudo. Os jobs chamam o
5
+ pipeline e registram logs; erros não derrubam o scheduler.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from apscheduler.schedulers.background import BackgroundScheduler
10
+ from apscheduler.triggers.interval import IntervalTrigger
11
+ from sqlalchemy import select
12
+
13
+ from backend.core.database import Blog, Log, SessionLocal
14
+
15
+ _scheduler: BackgroundScheduler | None = None
16
+
17
+
18
+ def esta_ativo() -> bool:
19
+ return _scheduler is not None and _scheduler.running
20
+
21
+
22
+ def iniciar() -> None:
23
+ global _scheduler
24
+ if esta_ativo():
25
+ return
26
+ _scheduler = BackgroundScheduler(timezone="UTC", daemon=True)
27
+ _scheduler.start()
28
+
29
+
30
+ def parar() -> None:
31
+ global _scheduler
32
+ if _scheduler is not None:
33
+ _scheduler.shutdown(wait=False)
34
+ _scheduler = None
35
+
36
+
37
+ def recarregar() -> None:
38
+ """Sincroniza os jobs com os blogs ativos (após CRUD ou toggle)."""
39
+ if _scheduler is None:
40
+ return
41
+ db = SessionLocal()
42
+ try:
43
+ ativos = db.scalars(select(Blog).where(Blog.status == "ativo")).all()
44
+ _scheduler.remove_all_jobs()
45
+ for blog in ativos:
46
+ _scheduler.add_job(
47
+ _executar,
48
+ IntervalTrigger(hours=max(1, int(blog.frequencia_horas or 6))),
49
+ id=f"blog_{blog.id}",
50
+ args=[blog.id],
51
+ replace_existing=True,
52
+ )
53
+ finally:
54
+ db.close()
55
+
56
+
57
+ def jobs() -> list[dict]:
58
+ if _scheduler is None:
59
+ return []
60
+ resultado = []
61
+ for job in _scheduler.get_jobs():
62
+ resultado.append(
63
+ {
64
+ "blog_id": job.args[0] if job.args else None,
65
+ "proxima_execucao": job.next_run_time.isoformat() if job.next_run_time else None,
66
+ }
67
+ )
68
+ return resultado
69
+
70
+
71
+ def _executar(blog_id: int) -> None:
72
+ from backend.core import pipeline
73
+
74
+ db = SessionLocal()
75
+ try:
76
+ db.add(Log(blog_id=blog_id, etapa="pipeline", status="sucesso", mensagem="Execução agendada iniciada"))
77
+ db.commit()
78
+ finally:
79
+ db.close()
80
+ pipeline.gerar_noticia(blog_id)