@danhachuel/thunderbolt 0.3.62 → 0.3.63
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/MANUAL-INSTALACAO.md +7 -7
- package/README.md +3 -1
- package/app/main.py +2 -1
- package/hermes_ui/domain.py +13 -2
- package/hermes_ui/logs.py +43 -0
- package/hermes_ui/notifications.py +18 -1
- package/hermes_ui/pipeline_worker.py +203 -16
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.63
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -53,7 +53,7 @@ A execução segue sempre **Tema → Script → Título → Keywords (opcional)
|
|
|
53
53
|
| Full IA | Pool de vídeo limitado a FAL AI, KIE AI e Agnes AI | MP4 gerado pelo provider activo |
|
|
54
54
|
| Apenas Música | Áudio local/Suno já preparado | Ficheiro musical pronto para um upload de música; não gera MP4, thumbnail nem upload YouTube |
|
|
55
55
|
|
|
56
|
-
Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
|
|
56
|
+
Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. Se o MoneyPrinterTurbo devolver `MPT_NEEDS_INPUT`, o Thunderbolt lê `LLM_PROVIDER`, `MISSING` e `INVALID` e mostra no erro todas as APIs afectadas, como **OpenAI / NVIDIA NIM API + Pexels API**, incluindo os campos que faltam. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
|
|
57
57
|
|
|
58
58
|
## 2. Pré-requisitos
|
|
59
59
|
|
|
@@ -128,13 +128,13 @@ Execute:
|
|
|
128
128
|
Windows PowerShell ou MobaXterm:
|
|
129
129
|
|
|
130
130
|
```powershell
|
|
131
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
131
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
Linux/macOS:
|
|
135
135
|
|
|
136
136
|
```bash
|
|
137
|
-
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
137
|
+
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -142,7 +142,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
|
|
|
142
142
|
Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
|
|
143
143
|
|
|
144
144
|
```powershell
|
|
145
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
145
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install --purge-data
|
|
146
146
|
```
|
|
147
147
|
|
|
148
148
|
O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
|
|
@@ -538,7 +538,7 @@ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compacto
|
|
|
538
538
|
|
|
539
539
|
A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal, dentro de um expander fechado por defeito, e não dentro de Criação de Vídeos. Clique no expander e depois em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A única vista disponível é **Lista**, que apresenta título, data, URL, estado e botão **Editar vídeo**. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
|
|
540
540
|
|
|
541
|
-
A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso** e **Detalhes**;
|
|
541
|
+
A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso**, **API/Provider** e **Detalhes**; em qualquer falha nova, **API/Provider** identifica a API ou o pool responsável, o serviço, a rota e os campos de configuração em falta. Registos anteriores sem essa metadata aparecem explicitamente como falhas históricas cuja API não pôde ser identificada. Estados como pendente, em execução, concluído, publicado, falha, cancelado e bloqueado são apresentados quando existirem no storage local.
|
|
542
542
|
|
|
543
543
|
## Canais em lote por conta Google/YouTube
|
|
544
544
|
|
|
@@ -556,7 +556,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
556
556
|
|
|
557
557
|
Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
|
|
558
558
|
|
|
559
|
-
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
559
|
+
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
560
560
|
|
|
561
561
|
### Niche Finder Apify
|
|
562
562
|
|
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opç
|
|
|
44
44
|
|
|
45
45
|
A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa exclusivamente os cartões activos FAL AI, KIE AI e Agnes AI do pool de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
|
|
46
46
|
|
|
47
|
+
Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
|
|
48
|
+
|
|
47
49
|
## Upload-Post — publicação para múltiplas plataformas
|
|
48
50
|
|
|
49
51
|
A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
@@ -85,7 +87,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
|
|
|
85
87
|
|
|
86
88
|
A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
|
|
87
89
|
|
|
88
|
-
## Navegação da UI 0.3.
|
|
90
|
+
## Navegação da UI 0.3.63
|
|
89
91
|
|
|
90
92
|
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 Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A página Geração de Conteúdo IA divide-se em **Imagens**, **Vídeos** e **Motion Control**; a subaba Motion Control permanece vazia nesta release.
|
|
91
93
|
**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**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
package/app/main.py
CHANGED
|
@@ -5862,10 +5862,11 @@ def render_logs():
|
|
|
5862
5862
|
"Registo": st.column_config.TextColumn("Registo", width=280),
|
|
5863
5863
|
"Origem": st.column_config.TextColumn("Origem", width=130),
|
|
5864
5864
|
"Progresso": st.column_config.TextColumn("Progresso", width=100),
|
|
5865
|
+
"API/Provider": st.column_config.TextColumn("API/Provider", width=220),
|
|
5865
5866
|
"Detalhes": st.column_config.TextColumn("Detalhes", width=760),
|
|
5866
5867
|
},
|
|
5867
5868
|
)
|
|
5868
|
-
st.caption("Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
|
|
5869
|
+
st.caption("A coluna API/Provider identifica a API responsável por cada falha; quando o registo é anterior a esta correcção, o sistema assinala que a API não pôde ser identificada. Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
|
|
5869
5870
|
|
|
5870
5871
|
|
|
5871
5872
|
def render_notifications():
|
package/hermes_ui/domain.py
CHANGED
|
@@ -221,11 +221,22 @@ def _notify_task_completion(task: dict[str, Any], previous_state: str = "") -> N
|
|
|
221
221
|
title = str(task.get("title") or task.get("topic") or "Actividade")
|
|
222
222
|
channel = str(task.get("channel_name") or "Canal")
|
|
223
223
|
if current_state == "failed":
|
|
224
|
+
failure_api = str(task.get("failure_api") or "API não identificada (falha anterior)").strip()
|
|
224
225
|
record_notification(
|
|
225
226
|
"activity_failed",
|
|
226
227
|
f"Actividade falhou: {title}",
|
|
227
|
-
f"A actividade de {channel} terminou com erro.",
|
|
228
|
-
metadata={
|
|
228
|
+
f"A actividade de {channel} terminou com erro. API/provider: {failure_api}.",
|
|
229
|
+
metadata={
|
|
230
|
+
"task_id": task_id,
|
|
231
|
+
"channel_name": channel,
|
|
232
|
+
"error": task.get("error") or "",
|
|
233
|
+
"failure_api": task.get("failure_api") or "API não identificada (falha anterior)",
|
|
234
|
+
"failure_provider": task.get("failure_provider") or "unknown",
|
|
235
|
+
"failure_service": task.get("failure_service") or "Thunderbolt",
|
|
236
|
+
"failure_route": task.get("failure_route") or "",
|
|
237
|
+
"failure_config_fields": task.get("failure_config_fields") or "",
|
|
238
|
+
"failure_stage": task.get("failure_stage") or task.get("failed_stage") or task.get("stage") or "pipeline",
|
|
239
|
+
},
|
|
229
240
|
dedupe_key=f"task:{task_id}:failed",
|
|
230
241
|
)
|
|
231
242
|
return
|
package/hermes_ui/logs.py
CHANGED
|
@@ -76,6 +76,29 @@ def _operation_label(operation_code: str, fallback: str = "Operação") -> str:
|
|
|
76
76
|
return str(event.get("label") if event else fallback or operation_code or "Operação")
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _fallback_task_failure_api(task: dict[str, Any]) -> str:
|
|
80
|
+
"""Infer a safe API label for legacy failed tasks without structured metadata."""
|
|
81
|
+
route = str(task.get("material_source") or task.get("style_wide") or "").strip().casefold()
|
|
82
|
+
if "pexels" in route and "pixabay" in route:
|
|
83
|
+
return "Pexels/Pixabay API (fonte efectiva não registada)"
|
|
84
|
+
if "pixabay" in route:
|
|
85
|
+
return "Pixabay API"
|
|
86
|
+
if "pexels" in route or "stock" in route:
|
|
87
|
+
return "Pexels/Pixabay API (fonte efectiva não registada)"
|
|
88
|
+
if route in {"full_ia", "full ia", "ia"}:
|
|
89
|
+
return "FAL AI / KIE AI / Agnes AI"
|
|
90
|
+
if route in {"music", "musica", "música"} or bool(task.get("music_mode")):
|
|
91
|
+
return "Suno / Ficheiro local"
|
|
92
|
+
stage = str(task.get("stage") or task.get("failed_stage") or "").strip().casefold()
|
|
93
|
+
if stage in {"topic", "script", "title", "keywords", "thumbnail_prompt"}:
|
|
94
|
+
return "LLM textual API (provider não registado)"
|
|
95
|
+
if stage == "thumbnail":
|
|
96
|
+
return "Pool de imagem API (provider não registado)"
|
|
97
|
+
if stage == "upload":
|
|
98
|
+
return "YouTube Upload API"
|
|
99
|
+
return "API não identificada (falha anterior)"
|
|
100
|
+
|
|
101
|
+
|
|
79
102
|
def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
|
|
80
103
|
task_id = str(task.get("id") or "").strip()
|
|
81
104
|
if not task_id:
|
|
@@ -100,6 +123,20 @@ def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
100
123
|
if progress_value is not None:
|
|
101
124
|
details.append(f"Progresso: {progress_value}%")
|
|
102
125
|
error = str(task.get("error") or "").strip()
|
|
126
|
+
failure_api = str(task.get("failure_api") or "").strip()
|
|
127
|
+
failure_provider = str(task.get("failure_provider") or "").strip()
|
|
128
|
+
failure_service = str(task.get("failure_service") or "").strip()
|
|
129
|
+
failure_fields = str(task.get("failure_config_fields") or "").strip()
|
|
130
|
+
api_provider = failure_api
|
|
131
|
+
if state in {"failed", "error"}:
|
|
132
|
+
api_provider = api_provider or _fallback_task_failure_api(task)
|
|
133
|
+
details.append(f"API/provider: {api_provider}")
|
|
134
|
+
if failure_provider:
|
|
135
|
+
details.append(f"Provider: {failure_provider}")
|
|
136
|
+
if failure_service:
|
|
137
|
+
details.append(f"Serviço: {failure_service}")
|
|
138
|
+
if failure_fields:
|
|
139
|
+
details.append(f"Configuração: {failure_fields}")
|
|
103
140
|
if error:
|
|
104
141
|
details.append(error[:500])
|
|
105
142
|
return {
|
|
@@ -114,6 +151,7 @@ def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
114
151
|
"source": "Tarefas",
|
|
115
152
|
"record": title,
|
|
116
153
|
"details": " · ".join(details),
|
|
154
|
+
"api_provider": api_provider or "",
|
|
117
155
|
"progress": progress_value,
|
|
118
156
|
"task_id": task_id,
|
|
119
157
|
}
|
|
@@ -141,6 +179,9 @@ def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
141
179
|
date, time = format_log_date_time(occurred_at)
|
|
142
180
|
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
|
|
143
181
|
public_metadata = [f"{key}: {value}" for key, value in metadata.items() if value not in (None, "")]
|
|
182
|
+
api_provider = str(metadata.get("failure_api") or metadata.get("api_provider") or "").strip()
|
|
183
|
+
if status_code in {"failed", "error"}:
|
|
184
|
+
api_provider = api_provider or "API não identificada (falha anterior)"
|
|
144
185
|
return {
|
|
145
186
|
"id": f"notification:{entry_id}",
|
|
146
187
|
"operation_code": event_type,
|
|
@@ -153,6 +194,7 @@ def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
153
194
|
"source": "Notificações",
|
|
154
195
|
"record": str(entry.get("title") or entry.get("label") or "Notificação"),
|
|
155
196
|
"details": " · ".join([str(entry.get("message") or "").strip(), *public_metadata]).strip(" ·")[:1000],
|
|
197
|
+
"api_provider": api_provider,
|
|
156
198
|
"progress": None,
|
|
157
199
|
"task_id": str(metadata.get("task_id") or ""),
|
|
158
200
|
}
|
|
@@ -227,6 +269,7 @@ def logs_to_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
227
269
|
"Registo": item.get("record") or "—",
|
|
228
270
|
"Origem": item.get("source") or "—",
|
|
229
271
|
"Progresso": f"{item['progress']}%" if item.get("progress") is not None else "—",
|
|
272
|
+
"API/Provider": item.get("api_provider") or "—",
|
|
230
273
|
"Detalhes": item.get("details") or "—",
|
|
231
274
|
}
|
|
232
275
|
for item in records
|
|
@@ -236,7 +236,24 @@ def _task_notifications() -> int:
|
|
|
236
236
|
title = str(task.get("title") or task.get("topic") or "Actividade")
|
|
237
237
|
channel = str(task.get("channel_name") or "Canal")
|
|
238
238
|
if state == "failed":
|
|
239
|
-
|
|
239
|
+
failure_api = str(task.get("failure_api") or "API não identificada (falha anterior)").strip()
|
|
240
|
+
if record_notification(
|
|
241
|
+
"activity_failed",
|
|
242
|
+
f"Actividade falhou: {title}",
|
|
243
|
+
f"A actividade de {channel} terminou com erro. API/provider: {failure_api}.",
|
|
244
|
+
metadata={
|
|
245
|
+
"task_id": task_id,
|
|
246
|
+
"channel_name": channel,
|
|
247
|
+
"error": task.get("error") or "",
|
|
248
|
+
"failure_api": task.get("failure_api") or "API não identificada (falha anterior)",
|
|
249
|
+
"failure_provider": task.get("failure_provider") or "unknown",
|
|
250
|
+
"failure_service": task.get("failure_service") or "Thunderbolt",
|
|
251
|
+
"failure_route": task.get("failure_route") or "",
|
|
252
|
+
"failure_config_fields": task.get("failure_config_fields") or "",
|
|
253
|
+
"failure_stage": task.get("failure_stage") or task.get("failed_stage") or task.get("stage") or "pipeline",
|
|
254
|
+
},
|
|
255
|
+
dedupe_key=f"task:{task_id}:failed",
|
|
256
|
+
):
|
|
240
257
|
created += 1
|
|
241
258
|
continue
|
|
242
259
|
if state != "done":
|
|
@@ -33,6 +33,10 @@ WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
|
|
|
33
33
|
class PipelineError(RuntimeError):
|
|
34
34
|
"""Raised when a pipeline stage cannot complete with an actionable error."""
|
|
35
35
|
|
|
36
|
+
def __init__(self, message: str, *, failure_metadata: dict[str, Any] | None = None):
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.failure_metadata = dict(failure_metadata or {})
|
|
39
|
+
|
|
36
40
|
|
|
37
41
|
class PipelineStopped(PipelineError):
|
|
38
42
|
"""Raised when the user stops a task while the worker is processing it."""
|
|
@@ -187,7 +191,9 @@ def _recover_stale_tasks() -> list[str]:
|
|
|
187
191
|
f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
|
|
188
192
|
"Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
|
|
189
193
|
)
|
|
190
|
-
|
|
194
|
+
failed_stage = str(task.get("stage") or "pipeline")
|
|
195
|
+
metadata = _failure_attribution(task, _settings(), failed_stage, error=message)
|
|
196
|
+
update_task(task_id, {"state": "failed", "error": _failure_message(message, metadata), "failed_stage": failed_stage, **metadata})
|
|
191
197
|
recovered.append(task_id)
|
|
192
198
|
return recovered
|
|
193
199
|
|
|
@@ -276,13 +282,160 @@ def _helper_output_value(output: str, key: str) -> str:
|
|
|
276
282
|
|
|
277
283
|
|
|
278
284
|
def _redact_helper_output(text: str) -> str:
|
|
279
|
-
for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY"):
|
|
285
|
+
for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY", "MPT_PIXABAY_API_KEY"):
|
|
280
286
|
secret = os.environ.get(key, "").strip()
|
|
281
287
|
if secret:
|
|
282
288
|
text = text.replace(secret, "[redacted]")
|
|
283
289
|
return text
|
|
284
290
|
|
|
285
291
|
|
|
292
|
+
def _helper_failure_markers(output: str) -> dict[str, Any]:
|
|
293
|
+
missing = [item.strip() for item in re.findall(r"(?m)^MISSING=(.+)$", output) if item.strip()]
|
|
294
|
+
invalid = [item.strip() for item in re.findall(r"(?m)^INVALID=(.+)$", output) if item.strip()]
|
|
295
|
+
return {
|
|
296
|
+
"helper_provider": _helper_output_value(output, "LLM_PROVIDER"),
|
|
297
|
+
"missing_fields": list(dict.fromkeys(missing)),
|
|
298
|
+
"invalid_fields": list(dict.fromkeys(invalid)),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _provider_api_label(provider: str) -> str:
|
|
303
|
+
code = str(provider or "").strip().casefold()
|
|
304
|
+
if code in {"pexels", "pixabay"}:
|
|
305
|
+
return f"{code.capitalize()} API"
|
|
306
|
+
if code in {"local", "local_storage"}:
|
|
307
|
+
return "Ficheiro local"
|
|
308
|
+
return f"{provider_definition(code).label} API"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _failure_attribution(
|
|
312
|
+
task: dict[str, Any],
|
|
313
|
+
settings: dict[str, Any],
|
|
314
|
+
stage: str,
|
|
315
|
+
*,
|
|
316
|
+
error: str = "",
|
|
317
|
+
output: str = "",
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Return safe, human-readable API attribution for every pipeline failure."""
|
|
320
|
+
route = _normalise_video_route(task, settings)
|
|
321
|
+
markers = _helper_failure_markers(output)
|
|
322
|
+
missing = list(markers["missing_fields"])
|
|
323
|
+
invalid = list(markers["invalid_fields"])
|
|
324
|
+
provider_code = str(markers["helper_provider"] or "").strip().casefold()
|
|
325
|
+
combined = f"{output} {error}".casefold()
|
|
326
|
+
|
|
327
|
+
if stage == "video" and missing:
|
|
328
|
+
api_labels: list[str] = []
|
|
329
|
+
provider_values: list[str] = []
|
|
330
|
+
for field in [*missing, *invalid]:
|
|
331
|
+
if field.endswith("_api_keys"):
|
|
332
|
+
source = field.removesuffix("_api_keys")
|
|
333
|
+
label = _provider_api_label(source)
|
|
334
|
+
provider_values.append(source)
|
|
335
|
+
elif field.endswith("_api_key"):
|
|
336
|
+
source = field.removesuffix("_api_key")
|
|
337
|
+
label = _provider_api_label(source)
|
|
338
|
+
provider_values.append(source)
|
|
339
|
+
else:
|
|
340
|
+
label = field
|
|
341
|
+
if label not in api_labels:
|
|
342
|
+
api_labels.append(label)
|
|
343
|
+
return {
|
|
344
|
+
"failure_api": " + ".join(api_labels) or _provider_api_label(route),
|
|
345
|
+
"failure_provider": ", ".join(dict.fromkeys(provider_values or ([provider_code] if provider_code else [route]))),
|
|
346
|
+
"failure_service": "MoneyPrinterTurbo",
|
|
347
|
+
"failure_route": route,
|
|
348
|
+
"failure_config_fields": ", ".join(dict.fromkeys([*missing, *invalid])),
|
|
349
|
+
"failure_stage": stage,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if stage == "video" and route in {"pexels", "pixabay"}:
|
|
353
|
+
return {
|
|
354
|
+
"failure_api": _provider_api_label(route),
|
|
355
|
+
"failure_provider": route,
|
|
356
|
+
"failure_service": "MoneyPrinterTurbo",
|
|
357
|
+
"failure_route": route,
|
|
358
|
+
"failure_config_fields": f"{route}_api_keys",
|
|
359
|
+
"failure_stage": stage,
|
|
360
|
+
}
|
|
361
|
+
if stage == "video" and route == "full_ia":
|
|
362
|
+
providers = [("fal_ai", "FAL AI"), ("kie_ai", "KIE AI"), ("agnes", "Agnes AI")]
|
|
363
|
+
found = [label for code, label in providers if code in combined or label.casefold() in combined]
|
|
364
|
+
labels = found or [label for _, label in providers]
|
|
365
|
+
codes = [code for code, label in providers if label in labels] or [code for code, _ in providers]
|
|
366
|
+
return {
|
|
367
|
+
"failure_api": " / ".join(f"{label} API" for label in labels),
|
|
368
|
+
"failure_provider": ", ".join(codes),
|
|
369
|
+
"failure_service": "Pool de vídeo Full IA",
|
|
370
|
+
"failure_route": route,
|
|
371
|
+
"failure_config_fields": "",
|
|
372
|
+
"failure_stage": stage,
|
|
373
|
+
}
|
|
374
|
+
if stage in {"topic", "script", "title", "keywords", "thumbnail_prompt"}:
|
|
375
|
+
card = active_llm_card(settings)
|
|
376
|
+
provider = str(card.get("provider") or provider_code or "openai").strip()
|
|
377
|
+
return {
|
|
378
|
+
"failure_api": _provider_api_label(provider),
|
|
379
|
+
"failure_provider": provider,
|
|
380
|
+
"failure_service": "LLM textual",
|
|
381
|
+
"failure_route": route,
|
|
382
|
+
"failure_config_fields": "",
|
|
383
|
+
"failure_stage": stage,
|
|
384
|
+
}
|
|
385
|
+
if stage == "thumbnail":
|
|
386
|
+
cards = media_cards_for_pool(settings, "image")
|
|
387
|
+
labels = []
|
|
388
|
+
codes = []
|
|
389
|
+
for card in cards:
|
|
390
|
+
code = str(card.get("provider") or "").strip().casefold()
|
|
391
|
+
if not code:
|
|
392
|
+
continue
|
|
393
|
+
label = _provider_api_label(code)
|
|
394
|
+
if code in combined or label.casefold() in combined or len(cards) == 1:
|
|
395
|
+
labels.append(label)
|
|
396
|
+
codes.append(code)
|
|
397
|
+
if not labels:
|
|
398
|
+
labels = [_provider_api_label(str(card.get("provider") or "imagem")) for card in cards] or ["Pool Imagem API"]
|
|
399
|
+
codes = [str(card.get("provider") or "").strip() for card in cards if card.get("provider")] or ["image_pool"]
|
|
400
|
+
return {
|
|
401
|
+
"failure_api": " / ".join(labels),
|
|
402
|
+
"failure_provider": ", ".join(codes),
|
|
403
|
+
"failure_service": "Pool de imagem",
|
|
404
|
+
"failure_route": route,
|
|
405
|
+
"failure_config_fields": "",
|
|
406
|
+
"failure_stage": stage,
|
|
407
|
+
}
|
|
408
|
+
if stage == "upload":
|
|
409
|
+
return {
|
|
410
|
+
"failure_api": "YouTube Upload API",
|
|
411
|
+
"failure_provider": "youtube_upload",
|
|
412
|
+
"failure_service": "Upload",
|
|
413
|
+
"failure_route": route,
|
|
414
|
+
"failure_config_fields": "",
|
|
415
|
+
"failure_stage": stage,
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
"failure_api": "API não identificada (falha anterior)",
|
|
419
|
+
"failure_provider": provider_code or "unknown",
|
|
420
|
+
"failure_service": "Thunderbolt",
|
|
421
|
+
"failure_route": route,
|
|
422
|
+
"failure_config_fields": ", ".join(dict.fromkeys([*missing, *invalid])),
|
|
423
|
+
"failure_stage": stage or "pipeline",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _failure_message(message: str, metadata: dict[str, Any]) -> str:
|
|
428
|
+
api = str(metadata.get("failure_api") or "API não identificada").strip()
|
|
429
|
+
provider = str(metadata.get("failure_provider") or "").strip()
|
|
430
|
+
fields = str(metadata.get("failure_config_fields") or "").strip()
|
|
431
|
+
suffix = f" API/provider: {api}"
|
|
432
|
+
if provider and provider.casefold() not in api.casefold():
|
|
433
|
+
suffix += f" (provider: {provider})"
|
|
434
|
+
if fields:
|
|
435
|
+
suffix += f"; configuração: {fields}"
|
|
436
|
+
return f"{message.rstrip('.')} —{suffix}."
|
|
437
|
+
|
|
438
|
+
|
|
286
439
|
def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, str]:
|
|
287
440
|
"""Persist only bounded helper diagnostics and return its declared file paths."""
|
|
288
441
|
task_id = str(task.get("id") or "").strip()
|
|
@@ -291,11 +444,15 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
|
|
|
291
444
|
log_file = _helper_output_value(output, "LOG_FILE")
|
|
292
445
|
result_file = _helper_output_value(output, "RESULT_FILE")
|
|
293
446
|
try:
|
|
447
|
+
markers = _helper_failure_markers(output)
|
|
294
448
|
payload: dict[str, Any] = {
|
|
295
449
|
"captured_at": _now(),
|
|
296
450
|
"log_file": log_file,
|
|
297
451
|
"result_file": result_file,
|
|
298
452
|
"output_tail": _redact_helper_output(output[-6000:]),
|
|
453
|
+
"helper_provider": markers["helper_provider"],
|
|
454
|
+
"missing_fields": markers["missing_fields"],
|
|
455
|
+
"invalid_fields": markers["invalid_fields"],
|
|
299
456
|
}
|
|
300
457
|
artifact_path = _save_json_artifact(task_id, "video-diagnostics", payload)
|
|
301
458
|
current = _task_by_id(task_id) or task
|
|
@@ -497,9 +654,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
497
654
|
source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
|
|
498
655
|
if route in {"pexels", "pixabay"} and not source_keys:
|
|
499
656
|
source_label = "Pexels" if route == "pexels" else "Pixabay"
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
)
|
|
657
|
+
message = f"Configure pelo menos uma API key de {source_label} em Configurações > Configuração API > Fontes de materiais."
|
|
658
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
659
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
503
660
|
if configured_root:
|
|
504
661
|
try:
|
|
505
662
|
sync_moneyprinter_config(settings, str(configured_root))
|
|
@@ -527,7 +684,10 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
527
684
|
if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
|
|
528
685
|
voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
|
|
529
686
|
if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
|
|
530
|
-
|
|
687
|
+
message = "O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio."
|
|
688
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
689
|
+
metadata.update({"failure_api": "Ficheiro local", "failure_provider": "local_storage", "failure_service": "Áudio de narração", "failure_config_fields": "voiceover_file"})
|
|
690
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
531
691
|
command.extend(["--custom-audio-file", str(voiceover_file.resolve())])
|
|
532
692
|
output_lines: list[str] = []
|
|
533
693
|
line_queue: queue.Queue[str | None] = queue.Queue()
|
|
@@ -554,7 +714,10 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
554
714
|
bufsize=1,
|
|
555
715
|
)
|
|
556
716
|
except FileNotFoundError as exc:
|
|
557
|
-
|
|
717
|
+
message = "O comando uv não está instalado; não foi possível iniciar a geração de vídeo."
|
|
718
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
719
|
+
metadata.update({"failure_service": "Runtime MoneyPrinterTurbo"})
|
|
720
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata) from exc
|
|
558
721
|
|
|
559
722
|
reader = threading.Thread(target=_read_output, name=f"mpt-output-{task.get('id', 'video')}", daemon=True)
|
|
560
723
|
reader.start()
|
|
@@ -597,7 +760,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
597
760
|
break
|
|
598
761
|
if elapsed >= VIDEO_TIMEOUT_SECONDS:
|
|
599
762
|
_stop_process(process)
|
|
600
|
-
|
|
763
|
+
message = f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
|
|
764
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
765
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
601
766
|
finally:
|
|
602
767
|
reader.join(timeout=2)
|
|
603
768
|
_persist_video_diagnostics(task, "\n".join(output_lines))
|
|
@@ -607,10 +772,14 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
607
772
|
output = "\n".join(output_lines)
|
|
608
773
|
_persist_video_diagnostics(task, output)
|
|
609
774
|
if result_code == 10:
|
|
610
|
-
|
|
775
|
+
metadata = _failure_attribution(task, settings, "video", output=output)
|
|
776
|
+
message = "A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo"
|
|
777
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
611
778
|
if result_code != 0:
|
|
612
779
|
detail = _redact_helper_output(output[-1200:]).strip() or "erro sem detalhes devolvidos pelo helper"
|
|
613
|
-
|
|
780
|
+
metadata = _failure_attribution(task, settings, "video", error=detail, output=output)
|
|
781
|
+
message = f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}"
|
|
782
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
614
783
|
match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
|
|
615
784
|
video_path = Path(match.group(1).strip()).expanduser() if match else None
|
|
616
785
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
@@ -623,7 +792,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
623
792
|
except (OSError, json.JSONDecodeError):
|
|
624
793
|
video_path = None
|
|
625
794
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
626
|
-
|
|
795
|
+
message = "MoneyPrinterTurbo terminou sem devolver um MP4 válido."
|
|
796
|
+
metadata = _failure_attribution(task, settings, "video", error=message, output=output)
|
|
797
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
627
798
|
return video_path
|
|
628
799
|
|
|
629
800
|
|
|
@@ -835,8 +1006,11 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
835
1006
|
})
|
|
836
1007
|
except MediaGenerationError as exc:
|
|
837
1008
|
if route == "full_ia":
|
|
838
|
-
|
|
839
|
-
|
|
1009
|
+
message = f"Pool Full IA (FAL/KIE AI/Agnes AI): {exc}"
|
|
1010
|
+
else:
|
|
1011
|
+
message = f"Pipeline MoneyPrinterTurbo ({route}): {exc}"
|
|
1012
|
+
metadata = _failure_attribution(task, settings, "video", error=str(exc))
|
|
1013
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata) from exc
|
|
840
1014
|
current_after_video = _task_by_id(task_id) or {}
|
|
841
1015
|
artifacts = dict(current_after_video.get("artifacts") or artifacts)
|
|
842
1016
|
artifacts["video"] = str(video_path)
|
|
@@ -972,9 +1146,22 @@ def run_once() -> dict[str, Any]:
|
|
|
972
1146
|
message = str(exc)[:2000]
|
|
973
1147
|
from hermes_ui.domain import update_task
|
|
974
1148
|
current_task = _task_by_id(task_id) or candidate
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1149
|
+
failed_stage = str(current_task.get("stage") or "pipeline")
|
|
1150
|
+
settings = _settings()
|
|
1151
|
+
failure_metadata = dict(getattr(exc, "failure_metadata", {}) or {})
|
|
1152
|
+
if not failure_metadata:
|
|
1153
|
+
failure_metadata = _failure_attribution(current_task, settings, failed_stage, error=message)
|
|
1154
|
+
if "API/provider:" not in message:
|
|
1155
|
+
message = _failure_message(message, failure_metadata)[:2000]
|
|
1156
|
+
failure_updates = {
|
|
1157
|
+
"state": "failed",
|
|
1158
|
+
"error": message,
|
|
1159
|
+
"failed_stage": failed_stage,
|
|
1160
|
+
**failure_metadata,
|
|
1161
|
+
}
|
|
1162
|
+
update_task(task_id, failure_updates)
|
|
1163
|
+
_worker_heartbeat(status="failed", last_error=message, stage=failed_stage, progress=int(current_task.get("progress") or 0), task_id=task_id)
|
|
1164
|
+
return {"ok": False, "task_id": task_id, "error": message, "failure_metadata": failure_metadata, "recovered_task_ids": recovered}
|
|
978
1165
|
finally:
|
|
979
1166
|
try:
|
|
980
1167
|
lock.unlink()
|
package/package.json
CHANGED