@danhachuel/thunderbolt 0.2.71 → 0.2.73
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/LICENSE +21 -0
- package/README.md +2 -2
- package/app/main.py +123 -7
- package/hermes_ui/automation_worker.py +15 -0
- package/hermes_ui/cuts.py +9 -0
- package/hermes_ui/domain.py +39 -0
- package/hermes_ui/metadata_cleaner.py +8 -0
- package/hermes_ui/music.py +8 -0
- package/hermes_ui/notifications.py +340 -0
- package/hermes_ui/python_editor.py +8 -0
- package/hermes_ui/storage.py +25 -0
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DanHachuel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -38,13 +38,13 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
|
|
|
38
38
|
|
|
39
39
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
40
40
|
|
|
41
|
-
## Navegação da UI 0.2.
|
|
41
|
+
## Navegação da UI 0.2.72
|
|
42
42
|
|
|
43
43
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais** e **Tutorial Meta**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
44
44
|
|
|
45
45
|
Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo`, documentos de Upload directo, `INNERTUBE_API_KEY`, o formulário **Adicionar outra conta Gmail** e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes API Keys, providers, modelos, serviços, materiais, Nano Banana, TikTok, Postiz e o **Teste de vozes**.
|
|
46
46
|
|
|
47
|
-
A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações**
|
|
47
|
+
A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada.
|
|
48
48
|
|
|
49
49
|
## Canais Youtube — edição por cartão e vídeos recentes
|
|
50
50
|
|
package/app/main.py
CHANGED
|
@@ -34,6 +34,7 @@ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips,
|
|
|
34
34
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
35
35
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
36
36
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
37
|
+
from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
|
|
37
38
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
38
39
|
from hermes_ui.script_generation import generate_script_document
|
|
39
40
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
@@ -331,7 +332,16 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
|
|
|
331
332
|
blueprint_for_channel(channel),
|
|
332
333
|
language=str(channel.get("language") or "Português"),
|
|
333
334
|
)
|
|
334
|
-
|
|
335
|
+
payload = creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
|
|
336
|
+
creative_key = hashlib.sha1(json.dumps({"channel_id": channel.get("id") or "", "topic": topic, "titles": payload.get("title_candidates", [])}, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
|
337
|
+
record_notification(
|
|
338
|
+
"title_generation_completed",
|
|
339
|
+
f"Títulos gerados: {payload.get('title') or topic or 'Vídeo'}",
|
|
340
|
+
f"O pacote de títulos para {channel.get('name') or 'o canal seleccionado'} terminou de ser gerado.",
|
|
341
|
+
metadata={"channel_name": channel.get("name") or "", "topic": topic},
|
|
342
|
+
dedupe_key=f"titles:{creative_key}",
|
|
343
|
+
)
|
|
344
|
+
return payload
|
|
335
345
|
|
|
336
346
|
|
|
337
347
|
def valid_hhmm(value: str) -> bool:
|
|
@@ -671,8 +681,10 @@ def render_blueprints():
|
|
|
671
681
|
try:
|
|
672
682
|
blueprint, branding = create_blueprint_from_link(source_url, niche, language, creation_type == "Blueprint + Branding completo", channel_name, blueprint_name)
|
|
673
683
|
blueprint_path, branding_path = save_generated_blueprint(blueprint, branding)
|
|
684
|
+
record_notification("blueprint_completed", f"Blueprint criado: {blueprint_path.stem}", "O Blueprint foi criado e guardado no storage local.", metadata={"name": blueprint_path.stem}, dedupe_key=f"blueprint:{blueprint_path}:{blueprint_path.stat().st_mtime_ns}")
|
|
674
685
|
st.success(f"Blueprint criado: {blueprint_path.name}")
|
|
675
686
|
if branding_path:
|
|
687
|
+
record_notification("branding_completed", f"Branding criado: {branding_path.stem}", "O Branding foi criado e guardado no storage local.", metadata={"name": branding_path.stem}, dedupe_key=f"branding:{branding_path}:{branding_path.stat().st_mtime_ns}")
|
|
676
688
|
st.success(f"Branding completo criado: {branding_path.name}")
|
|
677
689
|
st.rerun()
|
|
678
690
|
except ValueError as exc:
|
|
@@ -692,6 +704,7 @@ def render_blueprints():
|
|
|
692
704
|
st.warning("O ficheiro já existe. Confirme a substituição.")
|
|
693
705
|
else:
|
|
694
706
|
destination.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
707
|
+
record_notification("blueprint_completed", f"Blueprint guardado: {destination.stem}", "O Blueprint importado foi guardado no storage local.", metadata={"name": destination.stem}, dedupe_key=f"blueprint:{destination}:{destination.stat().st_mtime_ns}")
|
|
695
708
|
st.success(f"Blueprint guardado em {destination}")
|
|
696
709
|
st.rerun()
|
|
697
710
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
|
@@ -731,6 +744,7 @@ def render_blueprints():
|
|
|
731
744
|
target = BLUEPRINTS / "brandings" / (Path(branding_upload.name).stem.replace(" ", "-") + ".json")
|
|
732
745
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
733
746
|
target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
747
|
+
record_notification("branding_completed", f"Branding guardado: {target.stem}", "O Branding importado foi guardado no storage local.", metadata={"name": target.stem}, dedupe_key=f"branding:{target}:{target.stat().st_mtime_ns}")
|
|
734
748
|
st.success(f"Branding guardado em {target}")
|
|
735
749
|
st.rerun()
|
|
736
750
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
|
@@ -1534,6 +1548,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1534
1548
|
payload["thumbnail_path"] = thumbnail_path
|
|
1535
1549
|
payload["thumbnail_status"] = "generated"
|
|
1536
1550
|
st.session_state["new_video_general_payloads"] = payloads
|
|
1551
|
+
record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": channel.get("name") or "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
|
|
1537
1552
|
st.success("Thumbnail gerada com Nano Banana.")
|
|
1538
1553
|
st.rerun()
|
|
1539
1554
|
except ThumbnailGenerationError as exc:
|
|
@@ -1588,6 +1603,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1588
1603
|
payload["thumbnail_path"] = thumbnail_path
|
|
1589
1604
|
payload["thumbnail_status"] = "generated"
|
|
1590
1605
|
st.session_state["new_video_creative_payload"] = payload
|
|
1606
|
+
record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
|
|
1591
1607
|
st.success("Thumbnail gerada com Nano Banana.")
|
|
1592
1608
|
st.rerun()
|
|
1593
1609
|
except ThumbnailGenerationError as exc:
|
|
@@ -1761,6 +1777,16 @@ def render_scripts():
|
|
|
1761
1777
|
structure_notes=structure_notes,
|
|
1762
1778
|
generation_settings=script_settings,
|
|
1763
1779
|
)
|
|
1780
|
+
generation_key = hashlib.sha1(f"{document_type}|{generated.get('title', '')}|{generated.get('content', '')}".encode("utf-8")).hexdigest()
|
|
1781
|
+
generated["notification_dedupe_key"] = f"script-generation:{generation_key}"
|
|
1782
|
+
script_event_type = "music_lyrics_generated" if document_type == "Letra de música" else "standalone_script_generated"
|
|
1783
|
+
record_notification(
|
|
1784
|
+
script_event_type,
|
|
1785
|
+
f"{'Letra de música' if document_type == 'Letra de música' else 'Roteiro autónomo'} gerado: {generated.get('title') or title or 'Documento'}",
|
|
1786
|
+
"A geração do documento terminou com sucesso e o rascunho está disponível para revisão.",
|
|
1787
|
+
metadata={"document_type": "music_lyrics" if document_type == "Letra de música" else "video_script", "title": generated.get("title") or title or "Documento"},
|
|
1788
|
+
dedupe_key=generated["notification_dedupe_key"],
|
|
1789
|
+
)
|
|
1764
1790
|
st.session_state["script_draft"] = generated
|
|
1765
1791
|
st.session_state["script_draft_title"] = generated["title"]
|
|
1766
1792
|
st.session_state["script_draft_summary"] = generated.get("summary", "")
|
|
@@ -1886,6 +1912,9 @@ def render_niche_finder():
|
|
|
1886
1912
|
)
|
|
1887
1913
|
st.session_state["niche_results"] = results
|
|
1888
1914
|
st.session_state["niche_last_parameters"] = current_parameters
|
|
1915
|
+
niche_key = hashlib.sha1(json.dumps(current_parameters, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
|
1916
|
+
summary = results.get("summary", {}) if isinstance(results, dict) else {}
|
|
1917
|
+
record_notification("niche_analysis_completed", "Análise de nicho concluída", "A análise Kaggle terminou com resultados prontos para consulta.", metadata={"source": "Kaggle", "rows_filtered": summary.get("rows_filtered", 0)}, dedupe_key=f"niche:kaggle:{niche_key}")
|
|
1889
1918
|
st.success("Análise concluída.")
|
|
1890
1919
|
except (NicheAnalysisError, DatasetError, OSError) as exc:
|
|
1891
1920
|
st.error("Não foi possível concluir a análise solicitada com os parâmetros actuais.")
|
|
@@ -2029,6 +2058,7 @@ def render_niche_finder_apify():
|
|
|
2029
2058
|
history = []
|
|
2030
2059
|
history.insert(0, st.session_state["niche_apify_last_run"])
|
|
2031
2060
|
write_json("niche_apify_runs.json", history[:20])
|
|
2061
|
+
record_notification("niche_analysis_completed", "Análise de nicho concluída", f"A pesquisa Apify terminou com {len(items)} vídeo(s) recebido(s).", metadata={"run_id": finished.run_id, "item_count": len(items)}, dedupe_key=f"niche:apify:{finished.run_id}")
|
|
2032
2062
|
st.session_state.pop("niche_apify_active_run", None)
|
|
2033
2063
|
progress.progress(100, text="Pesquisa Apify concluída.")
|
|
2034
2064
|
st.success(f"Pesquisa concluída: {len(items)} vídeo(s) recebido(s).")
|
|
@@ -2570,6 +2600,7 @@ def render_upload_direct():
|
|
|
2570
2600
|
uploads = read_json("uploads.json", [])
|
|
2571
2601
|
uploads.append(record)
|
|
2572
2602
|
write_json("uploads.json", uploads)
|
|
2603
|
+
reconcile_persisted_notifications()
|
|
2573
2604
|
(st.success if result.ok else st.error)(result.message)
|
|
2574
2605
|
|
|
2575
2606
|
|
|
@@ -2657,6 +2688,7 @@ def render_upload_postiz():
|
|
|
2657
2688
|
uploads = read_json("uploads.json", [])
|
|
2658
2689
|
uploads.append(record)
|
|
2659
2690
|
write_json("uploads.json", uploads)
|
|
2691
|
+
reconcile_persisted_notifications()
|
|
2660
2692
|
(st.success if result.ok else st.error)(result.message)
|
|
2661
2693
|
|
|
2662
2694
|
|
|
@@ -2833,6 +2865,7 @@ def render_upload_conventional():
|
|
|
2833
2865
|
uploads = read_json("uploads.json", [])
|
|
2834
2866
|
uploads.append(record)
|
|
2835
2867
|
write_json("uploads.json", uploads)
|
|
2868
|
+
reconcile_persisted_notifications()
|
|
2836
2869
|
(st.success if result.ok else st.error)(result.message)
|
|
2837
2870
|
if result.data.get("attempts"):
|
|
2838
2871
|
with st.expander("Detalhes dos mecanismos de upload"):
|
|
@@ -2852,6 +2885,7 @@ def render_upload_conventional():
|
|
|
2852
2885
|
uploads = read_json("uploads.json", [])
|
|
2853
2886
|
uploads.append(record)
|
|
2854
2887
|
write_json("uploads.json", uploads)
|
|
2888
|
+
reconcile_persisted_notifications()
|
|
2855
2889
|
(st.success if result.ok else st.warning)(result.message)
|
|
2856
2890
|
if "Instagram" in destination:
|
|
2857
2891
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
@@ -3358,13 +3392,91 @@ def render_settings():
|
|
|
3358
3392
|
|
|
3359
3393
|
def render_notifications():
|
|
3360
3394
|
st.title("Notificações")
|
|
3361
|
-
st.caption("Centro de notificações
|
|
3362
|
-
|
|
3395
|
+
st.caption("Centro de notificações internas persistentes do Thunderbolt. As conclusões são guardadas no storage local e aparecem quando a aplicação é actualizada.")
|
|
3396
|
+
reconcile_persisted_notifications()
|
|
3397
|
+
preferences = notification_preferences()
|
|
3398
|
+
catalog = notification_event_catalog()
|
|
3399
|
+
notifications = list_notifications(limit=500)
|
|
3400
|
+
unread_count = unread_notification_count()
|
|
3401
|
+
summary_cols = st.columns(3)
|
|
3402
|
+
with summary_cols[0]:
|
|
3403
|
+
st.metric("Não lidas", unread_count)
|
|
3404
|
+
with summary_cols[1]:
|
|
3405
|
+
st.metric("Total guardado", len(notifications))
|
|
3406
|
+
with summary_cols[2]:
|
|
3407
|
+
st.metric("Operações mapeadas", len(catalog))
|
|
3408
|
+
|
|
3409
|
+
action_cols = st.columns([1.4, 1.4, 2.2])
|
|
3410
|
+
with action_cols[0]:
|
|
3411
|
+
if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
|
|
3412
|
+
mark_all_notifications_read()
|
|
3413
|
+
st.rerun()
|
|
3414
|
+
with action_cols[1]:
|
|
3415
|
+
if st.button("Actualizar notificações", use_container_width=True):
|
|
3416
|
+
reconcile_persisted_notifications()
|
|
3417
|
+
st.rerun()
|
|
3418
|
+
with action_cols[2]:
|
|
3419
|
+
confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
|
|
3420
|
+
if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
|
|
3421
|
+
clear_notifications()
|
|
3422
|
+
st.session_state.pop("confirm_clear_notifications", None)
|
|
3423
|
+
st.rerun()
|
|
3424
|
+
|
|
3425
|
+
st.divider()
|
|
3426
|
+
st.subheader("Operações notificadas")
|
|
3427
|
+
st.caption("Ligue ou desligue cada tipo de notificação. As preferências ficam guardadas no storage local e aplicam-se às próximas conclusões.")
|
|
3428
|
+
grouped: dict[str, list[dict[str, str]]] = {}
|
|
3429
|
+
for event in catalog:
|
|
3430
|
+
grouped.setdefault(event["category"], []).append(event)
|
|
3431
|
+
with st.form("notification_preferences_form"):
|
|
3432
|
+
pending_preferences: dict[str, bool] = {}
|
|
3433
|
+
for category, events in grouped.items():
|
|
3434
|
+
st.markdown(f"**{category}**")
|
|
3435
|
+
for event in events:
|
|
3436
|
+
pending_preferences[event["code"]] = st.checkbox(
|
|
3437
|
+
event["label"],
|
|
3438
|
+
value=bool(preferences.get(event["code"], True)),
|
|
3439
|
+
help=event["description"],
|
|
3440
|
+
key=f"notification_preference_{event['code']}",
|
|
3441
|
+
)
|
|
3442
|
+
if st.form_submit_button("Guardar preferências", type="primary", use_container_width=True):
|
|
3443
|
+
save_notification_preferences(pending_preferences)
|
|
3444
|
+
st.success("Preferências de notificação guardadas.")
|
|
3445
|
+
st.rerun()
|
|
3446
|
+
|
|
3363
3447
|
st.divider()
|
|
3364
|
-
st.subheader("
|
|
3365
|
-
st.
|
|
3366
|
-
|
|
3367
|
-
|
|
3448
|
+
st.subheader("Histórico de notificações")
|
|
3449
|
+
filter_cols = st.columns([1, 1.4])
|
|
3450
|
+
category_options = ["Todas"] + sorted({event["category"] for event in catalog})
|
|
3451
|
+
with filter_cols[0]:
|
|
3452
|
+
selected_category = st.selectbox("Categoria", category_options, key="notifications_category_filter")
|
|
3453
|
+
with filter_cols[1]:
|
|
3454
|
+
selected_state = st.selectbox("Estado", ["Todas", "Não lidas", "Lidas"], key="notifications_state_filter")
|
|
3455
|
+
category_filter = "" if selected_category == "Todas" else selected_category
|
|
3456
|
+
unread_filter = selected_state == "Não lidas"
|
|
3457
|
+
filtered = list_notifications(limit=500, category=category_filter, unread_only=unread_filter)
|
|
3458
|
+
if selected_state == "Lidas":
|
|
3459
|
+
filtered = [item for item in filtered if item.get("read")]
|
|
3460
|
+
if not filtered:
|
|
3461
|
+
st.info("Ainda não existem notificações para os filtros seleccionados.")
|
|
3462
|
+
for item in filtered:
|
|
3463
|
+
with st.container(border=True):
|
|
3464
|
+
notification_cols = st.columns([3.3, 1.4, 1])
|
|
3465
|
+
with notification_cols[0]:
|
|
3466
|
+
st.write(f"**{item.get('title') or item.get('label') or 'Notificação'}**")
|
|
3467
|
+
st.caption(item.get("message") or "")
|
|
3468
|
+
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
|
|
3469
|
+
if metadata:
|
|
3470
|
+
public_details = " · ".join(f"{key}: {value}" for key, value in metadata.items() if value not in (None, ""))
|
|
3471
|
+
if public_details:
|
|
3472
|
+
st.caption(public_details)
|
|
3473
|
+
with notification_cols[1]:
|
|
3474
|
+
st.caption(f"{item.get('category', 'Sistema')} · {item.get('created_at', '—')}")
|
|
3475
|
+
st.write("Lida" if item.get("read") else "Não lida")
|
|
3476
|
+
with notification_cols[2]:
|
|
3477
|
+
if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", use_container_width=True):
|
|
3478
|
+
mark_notification_read(str(item.get("id")))
|
|
3479
|
+
st.rerun()
|
|
3368
3480
|
|
|
3369
3481
|
|
|
3370
3482
|
def render_models_ai_tutorial():
|
|
@@ -3782,6 +3894,10 @@ def main():
|
|
|
3782
3894
|
"Configuração API": render_settings,
|
|
3783
3895
|
"Notificações": render_notifications,
|
|
3784
3896
|
}
|
|
3897
|
+
try:
|
|
3898
|
+
reconcile_persisted_notifications()
|
|
3899
|
+
except Exception:
|
|
3900
|
+
pass
|
|
3785
3901
|
renderers.get(current_page, render_dashboard)()
|
|
3786
3902
|
|
|
3787
3903
|
if __name__ == "__main__":
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
from . import storage
|
|
11
11
|
from .creative_generation import generate_creative_package, generate_topic_for_channel
|
|
12
12
|
from .domain import create_batch, create_tasks_for_batch
|
|
13
|
+
from .notifications import record_notification
|
|
13
14
|
|
|
14
15
|
WORKER_STATE_FILE = "automation_worker.json"
|
|
15
16
|
LOCK_FILENAME = "automation_worker.lock"
|
|
@@ -257,6 +258,13 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
|
|
|
257
258
|
"batch_id": batch["id"],
|
|
258
259
|
"task_ids": [task["id"] for task in item["tasks"]],
|
|
259
260
|
}
|
|
261
|
+
record_notification(
|
|
262
|
+
"automation_completed",
|
|
263
|
+
"Automação concluída",
|
|
264
|
+
f"O lote agendado do canal {item['channel_id']} foi criado com sucesso.",
|
|
265
|
+
metadata={"channel_id": item["channel_id"], "batch_id": batch["id"], "time": current_minute},
|
|
266
|
+
dedupe_key=f"automation:{batch['id']}",
|
|
267
|
+
)
|
|
260
268
|
_write_status(status)
|
|
261
269
|
return {
|
|
262
270
|
"ok": True,
|
|
@@ -268,6 +276,13 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
|
|
|
268
276
|
except Exception as exc:
|
|
269
277
|
status["last_error"] = str(exc)
|
|
270
278
|
_write_status(status)
|
|
279
|
+
record_notification(
|
|
280
|
+
"automation_failed",
|
|
281
|
+
"Automação falhou",
|
|
282
|
+
f"A execução automática das {current_minute} terminou com erro: {exc}",
|
|
283
|
+
metadata={"date": day, "time": current_minute},
|
|
284
|
+
dedupe_key=f"automation:failed:{day}:{current_minute}",
|
|
285
|
+
)
|
|
271
286
|
return {"ok": False, "local_time": _local_iso(current), "error": str(exc), "created": created}
|
|
272
287
|
|
|
273
288
|
|
package/hermes_ui/cuts.py
CHANGED
|
@@ -17,6 +17,7 @@ import requests
|
|
|
17
17
|
|
|
18
18
|
from . import storage
|
|
19
19
|
from .metadata_cleaner import VIDEO_EXTENSIONS, _resolve_ffmpeg
|
|
20
|
+
from .notifications import record_notification
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class CutsError(RuntimeError):
|
|
@@ -380,6 +381,14 @@ def generate_clips(
|
|
|
380
381
|
record["error"] = str(exc)
|
|
381
382
|
_write_manifest(record, run_dir)
|
|
382
383
|
storage.append_json("cuts_runs.json", record)
|
|
384
|
+
if record["status"] == "complete":
|
|
385
|
+
record_notification(
|
|
386
|
+
"cuts_completed",
|
|
387
|
+
"Cortes concluídos",
|
|
388
|
+
f"A geração de cortes para {record['source_name']} terminou com {len(record['clips'])} clip(s).",
|
|
389
|
+
metadata={"run_id": record["id"], "clip_count": len(record["clips"]), "output_format": record.get("output_format") or ""},
|
|
390
|
+
dedupe_key=f"cuts:{record['id']}",
|
|
391
|
+
)
|
|
383
392
|
if record["status"] == "error":
|
|
384
393
|
raise CutsError(record["error"])
|
|
385
394
|
return record
|
package/hermes_ui/domain.py
CHANGED
|
@@ -4,6 +4,7 @@ import re
|
|
|
4
4
|
import uuid
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from .notifications import record_notification
|
|
7
8
|
from .storage import append_json, now, read_json, write_json
|
|
8
9
|
|
|
9
10
|
STAGES = ["niche", "blueprint", "brand", "script", "title", "thumbnail", "video", "edit", "upload"]
|
|
@@ -199,13 +200,49 @@ def update_channel_video(video_id: str, updates: dict[str, Any]) -> dict[str, An
|
|
|
199
200
|
return None
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
def _notify_task_completion(task: dict[str, Any], previous_state: str = "") -> None:
|
|
204
|
+
current_state = str(task.get("state") or "")
|
|
205
|
+
task_id = str(task.get("id") or "")
|
|
206
|
+
if not task_id or current_state == previous_state and current_state in {"done", "failed"}:
|
|
207
|
+
return
|
|
208
|
+
title = str(task.get("title") or task.get("topic") or "Actividade")
|
|
209
|
+
channel = str(task.get("channel_name") or "Canal")
|
|
210
|
+
if current_state == "failed":
|
|
211
|
+
record_notification(
|
|
212
|
+
"activity_failed",
|
|
213
|
+
f"Actividade falhou: {title}",
|
|
214
|
+
f"A actividade de {channel} terminou com erro.",
|
|
215
|
+
metadata={"task_id": task_id, "channel_name": channel, "error": task.get("error") or ""},
|
|
216
|
+
dedupe_key=f"task:{task_id}:failed",
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
if current_state != "done":
|
|
220
|
+
return
|
|
221
|
+
artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
|
|
222
|
+
if bool(task.get("music_mode")) or str(task.get("style_wide") or "") == "music":
|
|
223
|
+
event_type, label = "music_completed", "Música concluída"
|
|
224
|
+
elif str(task.get("stage") or "") == "script" and not artifacts.get("video"):
|
|
225
|
+
event_type, label = "script_stage_completed", "Roteiro concluído"
|
|
226
|
+
else:
|
|
227
|
+
event_type, label = "video_completed", "Vídeo concluído"
|
|
228
|
+
record_notification(
|
|
229
|
+
event_type,
|
|
230
|
+
f"{label}: {title}",
|
|
231
|
+
f"A actividade de {channel} terminou com sucesso.",
|
|
232
|
+
metadata={"task_id": task_id, "channel_name": channel, "stage": task.get("stage") or "", "creation_mode": task.get("creation_mode") or ""},
|
|
233
|
+
dedupe_key=f"task:{task_id}:{event_type}:done",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
202
237
|
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
203
238
|
tasks = read_json("tasks.json", [])
|
|
204
239
|
for task in tasks:
|
|
205
240
|
if task.get("id") == task_id:
|
|
241
|
+
previous_state = str(task.get("state") or "")
|
|
206
242
|
task.update(updates)
|
|
207
243
|
task["updated_at"] = now()
|
|
208
244
|
write_json("tasks.json", tasks)
|
|
245
|
+
_notify_task_completion(task, previous_state)
|
|
209
246
|
return task
|
|
210
247
|
return None
|
|
211
248
|
|
|
@@ -216,6 +253,7 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
|
|
|
216
253
|
tasks = read_json("tasks.json", [])
|
|
217
254
|
for task in tasks:
|
|
218
255
|
if task.get("id") == task_id:
|
|
256
|
+
previous_state = str(task.get("state") or "")
|
|
219
257
|
if state:
|
|
220
258
|
task["state"] = state
|
|
221
259
|
if stage:
|
|
@@ -226,6 +264,7 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
|
|
|
226
264
|
task["error"] = error
|
|
227
265
|
task["updated_at"] = now()
|
|
228
266
|
write_json("tasks.json", tasks)
|
|
267
|
+
_notify_task_completion(task, previous_state)
|
|
229
268
|
return task
|
|
230
269
|
return None
|
|
231
270
|
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from . import storage
|
|
14
|
+
from .notifications import record_notification
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _metadata_root() -> Path:
|
|
@@ -175,6 +176,13 @@ def save_edit_record(source: Path, output: Path, metadata: dict[str, Any], run_i
|
|
|
175
176
|
"created_at": run_info.get("created_at", _now()),
|
|
176
177
|
}
|
|
177
178
|
storage.append_json("metadata_edits.json", record)
|
|
179
|
+
record_notification(
|
|
180
|
+
"metadata_cleaning_completed",
|
|
181
|
+
f"Metadados limpos: {output.name}",
|
|
182
|
+
"A limpeza de metadados terminou com um ficheiro de saída guardado.",
|
|
183
|
+
metadata={"record_id": record["id"], "output_name": output.name},
|
|
184
|
+
dedupe_key=f"metadata_cleaning_completed:{record['id']}",
|
|
185
|
+
)
|
|
178
186
|
return record
|
|
179
187
|
|
|
180
188
|
|
package/hermes_ui/music.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
import requests
|
|
9
9
|
|
|
10
10
|
from . import storage
|
|
11
|
+
from .notifications import record_notification
|
|
11
12
|
|
|
12
13
|
MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
|
13
14
|
|
|
@@ -35,6 +36,13 @@ def store_music_file(name: str, content: bytes) -> Path:
|
|
|
35
36
|
raise ValueError("O ficheiro de música está vazio.")
|
|
36
37
|
target = music_directory() / safe_music_name(name)
|
|
37
38
|
target.write_bytes(content)
|
|
39
|
+
record_notification(
|
|
40
|
+
"music_completed",
|
|
41
|
+
f"Música concluída: {target.stem}",
|
|
42
|
+
f"O ficheiro de música {target.name} foi guardado no storage local.",
|
|
43
|
+
metadata={"filename": target.name, "source": "local_storage"},
|
|
44
|
+
dedupe_key=f"music:{target.name}:{target.stat().st_mtime_ns}",
|
|
45
|
+
)
|
|
38
46
|
return target
|
|
39
47
|
|
|
40
48
|
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Persistent local notifications for Thunderbolt activity completion events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import storage
|
|
11
|
+
|
|
12
|
+
NOTIFICATIONS_FILE = "notifications.json"
|
|
13
|
+
MAX_NOTIFICATIONS = 500
|
|
14
|
+
|
|
15
|
+
EVENT_CATALOG: tuple[dict[str, str], ...] = (
|
|
16
|
+
{"code": "video_completed", "category": "Produção", "label": "Vídeo concluído", "description": "Quando uma tarefa de vídeo chegar ao estado final."},
|
|
17
|
+
{"code": "music_completed", "category": "Produção", "label": "Música concluída", "description": "Quando uma tarefa musical ou um ficheiro musical terminar de ser guardado."},
|
|
18
|
+
{"code": "standalone_script_generated", "category": "Roteiros", "label": "Roteiro autónomo gerado", "description": "Quando um roteiro independente terminar de ser gerado."},
|
|
19
|
+
{"code": "music_lyrics_generated", "category": "Roteiros", "label": "Letra de música gerada", "description": "Quando uma letra de música independente terminar de ser gerada."},
|
|
20
|
+
{"code": "script_stage_completed", "category": "Pipeline", "label": "Etapa de roteiro concluída", "description": "Quando a etapa de roteiro de uma tarefa terminar."},
|
|
21
|
+
{"code": "title_generation_completed", "category": "Pipeline", "label": "Títulos gerados", "description": "Quando o pacote de títulos terminar de ser gerado."},
|
|
22
|
+
{"code": "thumbnail_generation_completed", "category": "Pipeline", "label": "Thumbnail gerada", "description": "Quando a imagem final da thumbnail for criada."},
|
|
23
|
+
{"code": "blueprint_completed", "category": "Pipeline", "label": "Blueprint criado ou importado", "description": "Quando um Blueprint for criado, importado ou guardado."},
|
|
24
|
+
{"code": "branding_completed", "category": "Pipeline", "label": "Branding criado ou importado", "description": "Quando um Branding for criado, importado ou guardado."},
|
|
25
|
+
{"code": "niche_analysis_completed", "category": "Pipeline", "label": "Análise de nicho concluída", "description": "Quando uma análise Kaggle ou Apify terminar com resultados."},
|
|
26
|
+
{"code": "cuts_completed", "category": "Edição", "label": "Cortes concluídos", "description": "Quando a geração de cortes terminar com um manifesto completo."},
|
|
27
|
+
{"code": "metadata_cleaning_completed", "category": "Edição", "label": "Metadados limpos", "description": "Quando uma cópia com metadados limpos for criada."},
|
|
28
|
+
{"code": "python_edit_completed", "category": "Edição", "label": "Edição Python concluída", "description": "Quando uma operação do Editor Python guardar o artefacto."},
|
|
29
|
+
{"code": "automation_completed", "category": "Automação", "label": "Automação concluída", "description": "Quando o worker concluir o lote agendado de um canal."},
|
|
30
|
+
{"code": "automation_failed", "category": "Automação", "label": "Automação falhou", "description": "Quando uma execução automática terminar com erro."},
|
|
31
|
+
{"code": "activity_failed", "category": "Sistema", "label": "Actividade falhou", "description": "Quando uma tarefa ou operação persistida terminar em erro."},
|
|
32
|
+
{"code": "upload_youtube_success", "category": "Upload", "label": "Upload YouTube concluído", "description": "Quando um vídeo for publicado num canal YouTube."},
|
|
33
|
+
{"code": "upload_tiktok_success", "category": "Upload", "label": "Upload TikTok concluído", "description": "Quando um vídeo for publicado numa conta TikTok."},
|
|
34
|
+
{"code": "upload_instagram_success", "category": "Upload", "label": "Upload Instagram concluído", "description": "Quando um vídeo for publicado num perfil Instagram."},
|
|
35
|
+
{"code": "upload_facebook_pages_success", "category": "Upload", "label": "Upload Facebook Pages concluído", "description": "Quando um vídeo for publicado numa Facebook Page."},
|
|
36
|
+
{"code": "upload_postiz_success", "category": "Upload", "label": "Upload Postiz concluído", "description": "Quando um vídeo for enviado e publicado através do Postiz."},
|
|
37
|
+
{"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
|
|
38
|
+
)
|
|
39
|
+
EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
|
|
40
|
+
SENSITIVE_MARKERS = (
|
|
41
|
+
"token",
|
|
42
|
+
"secret",
|
|
43
|
+
"api_key",
|
|
44
|
+
"apikey",
|
|
45
|
+
"cookie",
|
|
46
|
+
"password",
|
|
47
|
+
"authorization",
|
|
48
|
+
"sessioninfo",
|
|
49
|
+
"session_info",
|
|
50
|
+
"access_token",
|
|
51
|
+
"client_secret",
|
|
52
|
+
"sid",
|
|
53
|
+
"ssid",
|
|
54
|
+
"hsid",
|
|
55
|
+
"apisid",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def notification_event_catalog() -> list[dict[str, str]]:
|
|
60
|
+
return [dict(item) for item in EVENT_CATALOG]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_notification_preferences() -> dict[str, bool]:
|
|
64
|
+
return {item["code"]: True for item in EVENT_CATALOG}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def notification_preferences() -> dict[str, bool]:
|
|
68
|
+
settings = storage.read_json("settings.json", {})
|
|
69
|
+
raw = settings.get("notification_preferences", {}) if isinstance(settings, dict) else {}
|
|
70
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
71
|
+
preferences = default_notification_preferences()
|
|
72
|
+
for code in preferences:
|
|
73
|
+
if code in raw:
|
|
74
|
+
preferences[code] = bool(raw[code])
|
|
75
|
+
return preferences
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_notification_preferences(preferences: dict[str, Any]) -> dict[str, bool]:
|
|
79
|
+
settings = storage.read_json("settings.json", {})
|
|
80
|
+
existing = settings.get("notification_preferences", {}) if isinstance(settings, dict) else {}
|
|
81
|
+
existing = dict(existing) if isinstance(existing, dict) else {}
|
|
82
|
+
for code in EVENTS_BY_CODE:
|
|
83
|
+
if code in preferences:
|
|
84
|
+
existing[code] = bool(preferences[code])
|
|
85
|
+
settings["notification_preferences"] = existing
|
|
86
|
+
storage.write_json("settings.json", settings)
|
|
87
|
+
return notification_preferences()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _redact_text(value: Any) -> str:
|
|
91
|
+
text = str(value or "").strip()
|
|
92
|
+
if not text:
|
|
93
|
+
return ""
|
|
94
|
+
pattern = r"(?i)(api[_ -]?key|client[_ -]?secret|access[_ -]?token|authorization|bearer|session[_ -]?info)\s*[:=]\s*[^\s,;]+"
|
|
95
|
+
text = re.sub(pattern, r"\1=[redacted]", text)
|
|
96
|
+
return re.sub(r"(?i)\bbearer\s+[^\s,;]+", "Bearer [redacted]", text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _safe_metadata(value: Any, *, key: str = "") -> Any:
|
|
100
|
+
normalized_key = re.sub(r"[^a-z0-9_]", "", key.lower())
|
|
101
|
+
if any(marker in normalized_key for marker in SENSITIVE_MARKERS):
|
|
102
|
+
return "[redacted]"
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
return {str(item_key): _safe_metadata(item_value, key=str(item_key)) for item_key, item_value in value.items() if str(item_key).lower() not in {"payload", "response", "headers"}}
|
|
105
|
+
if isinstance(value, list):
|
|
106
|
+
return [_safe_metadata(item) for item in value[:50]]
|
|
107
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
108
|
+
return _redact_text(value) if isinstance(value, str) else value
|
|
109
|
+
return _redact_text(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _history() -> list[dict[str, Any]]:
|
|
113
|
+
saved = storage.read_json(NOTIFICATIONS_FILE, [])
|
|
114
|
+
if not isinstance(saved, list):
|
|
115
|
+
return []
|
|
116
|
+
return [item for item in saved if isinstance(item, dict)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def record_notification(
|
|
120
|
+
event_type: str,
|
|
121
|
+
title: str,
|
|
122
|
+
message: str,
|
|
123
|
+
*,
|
|
124
|
+
metadata: dict[str, Any] | None = None,
|
|
125
|
+
dedupe_key: str = "",
|
|
126
|
+
) -> dict[str, Any] | None:
|
|
127
|
+
event = EVENTS_BY_CODE.get(str(event_type))
|
|
128
|
+
if event is None:
|
|
129
|
+
raise ValueError(f"Tipo de notificação desconhecido: {event_type}")
|
|
130
|
+
if not notification_preferences().get(event_type, True):
|
|
131
|
+
return None
|
|
132
|
+
history = _history()
|
|
133
|
+
if dedupe_key:
|
|
134
|
+
for existing in history:
|
|
135
|
+
if str(existing.get("dedupe_key") or "") == dedupe_key:
|
|
136
|
+
return None
|
|
137
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
138
|
+
entry = {
|
|
139
|
+
"id": f"notification_{uuid.uuid4().hex[:12]}",
|
|
140
|
+
"event_type": event_type,
|
|
141
|
+
"category": event["category"],
|
|
142
|
+
"label": event["label"],
|
|
143
|
+
"title": _redact_text(title),
|
|
144
|
+
"message": _redact_text(message),
|
|
145
|
+
"created_at": created_at,
|
|
146
|
+
"read": False,
|
|
147
|
+
"metadata": _safe_metadata(metadata or {}),
|
|
148
|
+
"dedupe_key": str(dedupe_key or ""),
|
|
149
|
+
}
|
|
150
|
+
storage.write_json(NOTIFICATIONS_FILE, [entry, *history][:MAX_NOTIFICATIONS])
|
|
151
|
+
return entry
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_notifications(*, limit: int = 200, category: str = "", unread_only: bool = False) -> list[dict[str, Any]]:
|
|
155
|
+
entries = _history()
|
|
156
|
+
if category:
|
|
157
|
+
entries = [item for item in entries if item.get("category") == category]
|
|
158
|
+
if unread_only:
|
|
159
|
+
entries = [item for item in entries if not item.get("read")]
|
|
160
|
+
entries.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
161
|
+
return entries[: max(1, min(int(limit), MAX_NOTIFICATIONS))]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def unread_notification_count() -> int:
|
|
165
|
+
return sum(1 for item in _history() if not item.get("read"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def mark_notification_read(notification_id: str) -> bool:
|
|
169
|
+
history = _history()
|
|
170
|
+
changed = False
|
|
171
|
+
for item in history:
|
|
172
|
+
if str(item.get("id")) == str(notification_id):
|
|
173
|
+
item["read"] = True
|
|
174
|
+
changed = True
|
|
175
|
+
break
|
|
176
|
+
if changed:
|
|
177
|
+
storage.write_json(NOTIFICATIONS_FILE, history)
|
|
178
|
+
return changed
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def mark_all_notifications_read() -> int:
|
|
182
|
+
history = _history()
|
|
183
|
+
changed = sum(1 for item in history if not item.get("read"))
|
|
184
|
+
if changed:
|
|
185
|
+
for item in history:
|
|
186
|
+
item["read"] = True
|
|
187
|
+
storage.write_json(NOTIFICATIONS_FILE, history)
|
|
188
|
+
return changed
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def clear_notifications() -> int:
|
|
192
|
+
count = len(_history())
|
|
193
|
+
storage.write_json(NOTIFICATIONS_FILE, [])
|
|
194
|
+
return count
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _task_notifications() -> int:
|
|
198
|
+
created = 0
|
|
199
|
+
tasks = storage.read_json("tasks.json", [])
|
|
200
|
+
if not isinstance(tasks, list):
|
|
201
|
+
return 0
|
|
202
|
+
for task in tasks:
|
|
203
|
+
if not isinstance(task, dict):
|
|
204
|
+
continue
|
|
205
|
+
task_id = str(task.get("id") or "")
|
|
206
|
+
if not task_id:
|
|
207
|
+
continue
|
|
208
|
+
state = str(task.get("state") or "")
|
|
209
|
+
title = str(task.get("title") or task.get("topic") or "Actividade")
|
|
210
|
+
channel = str(task.get("channel_name") or "Canal")
|
|
211
|
+
if state == "failed":
|
|
212
|
+
if record_notification("activity_failed", f"Actividade falhou: {title}", f"A actividade de {channel} terminou com erro.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:failed"):
|
|
213
|
+
created += 1
|
|
214
|
+
continue
|
|
215
|
+
if state != "done":
|
|
216
|
+
continue
|
|
217
|
+
artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
|
|
218
|
+
if bool(task.get("music_mode")) or str(task.get("style_wide") or "") == "music":
|
|
219
|
+
event_type = "music_completed"
|
|
220
|
+
label = "Música concluída"
|
|
221
|
+
elif str(task.get("stage") or "") == "script" and not artifacts.get("video"):
|
|
222
|
+
event_type = "script_stage_completed"
|
|
223
|
+
label = "Roteiro concluído"
|
|
224
|
+
else:
|
|
225
|
+
event_type = "video_completed"
|
|
226
|
+
label = "Vídeo concluído"
|
|
227
|
+
if record_notification(event_type, f"{label}: {title}", f"A actividade de {channel} terminou com sucesso.", metadata={"task_id": task_id, "channel_name": channel, "stage": task.get("stage") or "", "creation_mode": task.get("creation_mode") or ""}, dedupe_key=f"task:{task_id}:{event_type}:done"):
|
|
228
|
+
created += 1
|
|
229
|
+
if isinstance(task.get("title_candidates"), list) and task.get("title_candidates"):
|
|
230
|
+
if record_notification("title_generation_completed", f"Títulos gerados: {title}", f"O pacote de títulos para {channel} terminou de ser gerado.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:titles"):
|
|
231
|
+
created += 1
|
|
232
|
+
if str(task.get("thumbnail_status") or "") == "generated" or artifacts.get("thumbnail"):
|
|
233
|
+
if record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {title}", f"A thumbnail de {channel} está pronta.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:thumbnail"):
|
|
234
|
+
created += 1
|
|
235
|
+
return created
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _upload_notifications() -> int:
|
|
239
|
+
created = 0
|
|
240
|
+
uploads = storage.read_json("uploads.json", [])
|
|
241
|
+
if not isinstance(uploads, list):
|
|
242
|
+
return 0
|
|
243
|
+
event_by_destination = {
|
|
244
|
+
"youtube": "upload_youtube_success",
|
|
245
|
+
"tiktok": "upload_tiktok_success",
|
|
246
|
+
"instagram": "upload_instagram_success",
|
|
247
|
+
"facebook pages": "upload_facebook_pages_success",
|
|
248
|
+
"facebook_pages": "upload_facebook_pages_success",
|
|
249
|
+
"postiz": "upload_postiz_success",
|
|
250
|
+
"youtube direct frontend": "upload_youtube_success",
|
|
251
|
+
}
|
|
252
|
+
for upload in uploads:
|
|
253
|
+
if not isinstance(upload, dict) or str(upload.get("status") or "").lower() not in {"published", "success", "done"}:
|
|
254
|
+
continue
|
|
255
|
+
upload_id = str(upload.get("id") or "")
|
|
256
|
+
task_id = str(upload.get("task_id") or upload_id or uuid.uuid4().hex[:8])
|
|
257
|
+
upload_created_at = str(upload.get("created_at") or "")
|
|
258
|
+
destination = str(upload.get("destination") or "Upload").strip()
|
|
259
|
+
event_type = event_by_destination.get(destination.lower(), "upload_youtube_success" if destination.lower().startswith("youtube") else "upload_postiz_success" if destination.lower().startswith("postiz") else "upload_youtube_success")
|
|
260
|
+
target = upload.get("target") if isinstance(upload.get("target"), dict) else {}
|
|
261
|
+
target_name = str(target.get("name") or target.get("label") or target.get("handle") or target.get("username") or "destino seleccionado")
|
|
262
|
+
if record_notification(event_type, f"Upload concluído: {destination}", f"O vídeo foi enviado com sucesso para {target_name}.", metadata={"task_id": task_id, "destination": destination, "target": target, "route": (upload.get("data") or {}).get("route") if isinstance(upload.get("data"), dict) else ""}, dedupe_key=f"upload:{upload_id or task_id}:{destination.lower()}:{upload_created_at}"):
|
|
263
|
+
created += 1
|
|
264
|
+
return created
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _script_notifications() -> int:
|
|
268
|
+
created = 0
|
|
269
|
+
records = storage.read_json("scripts.json", [])
|
|
270
|
+
if not isinstance(records, list):
|
|
271
|
+
return 0
|
|
272
|
+
for record in records:
|
|
273
|
+
if not isinstance(record, dict) or not record.get("id"):
|
|
274
|
+
continue
|
|
275
|
+
event_type = "music_lyrics_generated" if str(record.get("document_type") or "") == "music_lyrics" else "standalone_script_generated"
|
|
276
|
+
label = "Letra de música" if event_type == "music_lyrics_generated" else "Roteiro autónomo"
|
|
277
|
+
document_id = str(record["id"])
|
|
278
|
+
dedupe_key = str(record.get("notification_dedupe_key") or f"script:{document_id}:generated")
|
|
279
|
+
if record_notification(event_type, f"{label} gerado: {record.get('title') or 'Documento'}", f"O documento foi guardado no histórico de roteiros.", metadata={"document_id": document_id, "document_type": record.get("document_type") or ""}, dedupe_key=dedupe_key):
|
|
280
|
+
created += 1
|
|
281
|
+
return created
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _automation_notifications() -> int:
|
|
285
|
+
created = 0
|
|
286
|
+
worker = storage.read_json("automation_worker.json", {})
|
|
287
|
+
last_runs = worker.get("last_runs", {}) if isinstance(worker, dict) else {}
|
|
288
|
+
if isinstance(last_runs, dict):
|
|
289
|
+
for channel_id, run in last_runs.items():
|
|
290
|
+
if not isinstance(run, dict) or not run.get("batch_id"):
|
|
291
|
+
continue
|
|
292
|
+
if record_notification("automation_completed", "Automação concluída", f"O lote agendado do canal {channel_id} foi criado com sucesso.", metadata={"channel_id": channel_id, "batch_id": run.get("batch_id"), "time": run.get("time") or ""}, dedupe_key=f"automation:{run['batch_id']}"):
|
|
293
|
+
created += 1
|
|
294
|
+
return created
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _generic_completion_notifications() -> int:
|
|
298
|
+
created = 0
|
|
299
|
+
cuts = storage.read_json("cuts_runs.json", [])
|
|
300
|
+
if isinstance(cuts, list):
|
|
301
|
+
for run in cuts:
|
|
302
|
+
if isinstance(run, dict) and str(run.get("status") or "") == "complete" and run.get("id"):
|
|
303
|
+
if record_notification("cuts_completed", "Cortes concluídos", "A geração de cortes terminou com um manifesto completo.", metadata={"run_id": run.get("id")}, dedupe_key=f"cuts:{run['id']}"):
|
|
304
|
+
created += 1
|
|
305
|
+
indexed_sources = (
|
|
306
|
+
("metadata_edits.json", "metadata_cleaning_completed", "Metadados limpos", "metadata edit"),
|
|
307
|
+
("python_editor_edits.json", "python_edit_completed", "Edição Python concluída", "Python edit"),
|
|
308
|
+
)
|
|
309
|
+
for filename, event_type, label, source_label in indexed_sources:
|
|
310
|
+
records = storage.read_json(filename, [])
|
|
311
|
+
if not isinstance(records, list):
|
|
312
|
+
continue
|
|
313
|
+
for record in records:
|
|
314
|
+
if not isinstance(record, dict) or not record.get("id"):
|
|
315
|
+
continue
|
|
316
|
+
record_id = str(record["id"])
|
|
317
|
+
output_name = str(record.get("output_name") or "artefacto guardado")
|
|
318
|
+
if record_notification(event_type, f"{label}: {output_name}", f"A operação {source_label} terminou com sucesso.", metadata={"record_id": record_id, "output_name": output_name, "operation": record.get("operation") or ""}, dedupe_key=f"{event_type}:{record_id}"):
|
|
319
|
+
created += 1
|
|
320
|
+
apify_runs = storage.read_json("niche_apify_runs.json", [])
|
|
321
|
+
if isinstance(apify_runs, list):
|
|
322
|
+
for run in apify_runs:
|
|
323
|
+
status = str(run.get("status") or "").lower() if isinstance(run, dict) else ""
|
|
324
|
+
if status in {"succeeded", "success", "completed", "complete"} and run.get("run_id"):
|
|
325
|
+
run_id = str(run["run_id"])
|
|
326
|
+
if record_notification("niche_analysis_completed", "Análise de nicho concluída", f"A pesquisa Apify terminou com {run.get('item_count', 0)} vídeo(s) recebido(s).", metadata={"run_id": run_id, "item_count": run.get("item_count", 0)}, dedupe_key=f"niche:apify:{run_id}"):
|
|
327
|
+
created += 1
|
|
328
|
+
return created
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def reconcile_persisted_notifications() -> int:
|
|
332
|
+
"""Emit idempotent events for completions written by other local processes."""
|
|
333
|
+
total = 0
|
|
334
|
+
for resolver in (_task_notifications, _upload_notifications, _script_notifications, _automation_notifications, _generic_completion_notifications):
|
|
335
|
+
try:
|
|
336
|
+
total += resolver()
|
|
337
|
+
except Exception:
|
|
338
|
+
# Notifications must never prevent the main production/upload flow.
|
|
339
|
+
continue
|
|
340
|
+
return total
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from . import storage
|
|
14
|
+
from .notifications import record_notification
|
|
14
15
|
from .metadata_cleaner import VIDEO_EXTENSIONS as METADATA_VIDEO_EXTENSIONS
|
|
15
16
|
from .metadata_cleaner import _resolve_ffmpeg
|
|
16
17
|
|
|
@@ -257,6 +258,13 @@ def save_edit_record(source: Path, output: Path, run_info: dict[str, Any]) -> di
|
|
|
257
258
|
"created_at": run_info.get("created_at", _now()),
|
|
258
259
|
}
|
|
259
260
|
storage.append_json("python_editor_edits.json", record)
|
|
261
|
+
record_notification(
|
|
262
|
+
"python_edit_completed",
|
|
263
|
+
f"Edição Python concluída: {output.name}",
|
|
264
|
+
f"A operação {record.get('operation') or 'de edição'} terminou com um artefacto guardado.",
|
|
265
|
+
metadata={"record_id": record["id"], "output_name": output.name, "operation": record.get("operation") or ""},
|
|
266
|
+
dedupe_key=f"python_edit_completed:{record['id']}",
|
|
267
|
+
)
|
|
260
268
|
return record
|
|
261
269
|
|
|
262
270
|
|
package/hermes_ui/storage.py
CHANGED
|
@@ -24,6 +24,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
24
24
|
"queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
|
|
25
25
|
"batches.json": [],
|
|
26
26
|
"uploads.json": [],
|
|
27
|
+
"notifications.json": [],
|
|
27
28
|
"display_names.json": {"blueprints": {}, "prompt_masters": {}},
|
|
28
29
|
"niche_apify_runs.json": [],
|
|
29
30
|
"metadata_edits.json": [],
|
|
@@ -90,6 +91,30 @@ DEFAULTS: dict[str, Any] = {
|
|
|
90
91
|
"youtube_client_secret": "",
|
|
91
92
|
"youtube_batch_accounts": [],
|
|
92
93
|
"youtube_batch_selected_account_id": "",
|
|
94
|
+
"notification_preferences": {
|
|
95
|
+
"video_completed": True,
|
|
96
|
+
"music_completed": True,
|
|
97
|
+
"standalone_script_generated": True,
|
|
98
|
+
"music_lyrics_generated": True,
|
|
99
|
+
"script_stage_completed": True,
|
|
100
|
+
"title_generation_completed": True,
|
|
101
|
+
"thumbnail_generation_completed": True,
|
|
102
|
+
"blueprint_completed": True,
|
|
103
|
+
"branding_completed": True,
|
|
104
|
+
"niche_analysis_completed": True,
|
|
105
|
+
"cuts_completed": True,
|
|
106
|
+
"metadata_cleaning_completed": True,
|
|
107
|
+
"python_edit_completed": True,
|
|
108
|
+
"automation_completed": True,
|
|
109
|
+
"automation_failed": True,
|
|
110
|
+
"activity_failed": True,
|
|
111
|
+
"upload_youtube_success": True,
|
|
112
|
+
"upload_tiktok_success": True,
|
|
113
|
+
"upload_instagram_success": True,
|
|
114
|
+
"upload_facebook_pages_success": True,
|
|
115
|
+
"upload_postiz_success": True,
|
|
116
|
+
"mcp_operation_completed": True,
|
|
117
|
+
},
|
|
93
118
|
"kaggle_username": "",
|
|
94
119
|
"kaggle_api_key": "",
|
|
95
120
|
"kaggle_kernel_slug": "thunderbolt-niche-finder",
|
package/package.json
CHANGED