@danhachuel/thunderbolt 0.4.16 → 0.4.17

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.
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from . import storage
11
11
  from .creative_generation import generate_title_and_keywords, generate_topic_for_channel
12
12
  from .domain import create_batch, create_tasks_for_batch
13
+ from .languages import language_code
13
14
  from .material_sources import selected_material_source
14
15
  from .notifications import record_notification
15
16
  from integrations.session_info_health import check_all_accounts_session_info_health, emit_session_info_health_alerts
@@ -124,6 +125,15 @@ def _valid_schedule(value: Any) -> bool:
124
125
  return 0 <= hour <= 23 and 0 <= minute <= 59
125
126
 
126
127
 
128
+ def _scheduled_time(current: datetime, value: Any) -> datetime | None:
129
+ """Return today's scheduled local time, allowing a late worker tick to catch up."""
130
+ text = str(value or "").strip()
131
+ if not _valid_schedule(text):
132
+ return None
133
+ hour, minute = (int(part) for part in text.split(":"))
134
+ return current.replace(hour=hour, minute=minute, second=0, microsecond=0)
135
+
136
+
127
137
  def _daily_quantity(channel: dict[str, Any]) -> int:
128
138
  try:
129
139
  return max(1, min(100, int(channel.get("daily_limit", 1))))
@@ -198,14 +208,42 @@ def _tasks_for_batch(batch_id: str) -> list[dict[str, Any]]:
198
208
  return [task for task in tasks if isinstance(task, dict) and task.get("batch_id") == batch_id]
199
209
 
200
210
 
211
+ def _pending_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
212
+ """Build the lightweight scheduled payload; creative work runs in the pipeline."""
213
+ settings = storage.read_json("settings.json", {})
214
+ blueprint = _blueprint_for_channel(channel)
215
+ style_wide = str(channel.get("style_wide") or "pexels").strip().casefold()
216
+ material_source = selected_material_source(settings) if style_wide in {"pexels", "pexels/pixabay", "stock"} else ""
217
+ language = language_code(channel.get("language") or "pt")
218
+ payload = {
219
+ "topic": "",
220
+ "topic_source": "llm_pending",
221
+ "title": "",
222
+ "title_candidates": [],
223
+ "thumbnail_variant": {},
224
+ "thumbnail_variants": [],
225
+ "thumbnail_prompt": "",
226
+ "thumbnail_text": "",
227
+ "thumbnail_status": "pending_prompt",
228
+ "language": language,
229
+ "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
230
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
231
+ "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
232
+ "material_source": material_source,
233
+ "generation_settings": {"material_source": material_source},
234
+ "ai_generation": {},
235
+ }
236
+ return "", payload
237
+
238
+
201
239
  def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str, Any]:
202
240
  channel_id = str(channel["id"])
203
241
  date_key = when.date().isoformat()
204
242
  style_wide = str(channel.get("style_wide") or "pexels")
205
243
  music_mode = style_wide == "music"
206
- topic, payload = _creative_payload(channel)
244
+ topic, payload = _pending_payload(channel)
207
245
  options = {
208
- "language": channel.get("language") or "Português",
246
+ "language": payload.get("language") or channel.get("language") or "pt",
209
247
  "format": "wide",
210
248
  "style_wide": style_wide,
211
249
  "style_ia": channel.get("style_ia") or "",
@@ -214,7 +252,7 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
214
252
  "background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
215
253
  "music_path": channel.get("music_path") or "",
216
254
  "music_source": channel.get("music_source") or "",
217
- "topic_source": "llm",
255
+ "topic_source": payload.get("topic_source") or "llm_pending",
218
256
  "channel_payloads": {channel_id: payload},
219
257
  "automation_worker": True,
220
258
  "automation_date": date_key,
@@ -254,7 +292,8 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
254
292
  channel_id = str(channel.get("id") or "")
255
293
  if not channel_id or not bool(channel.get("active", True)) or not bool(channel.get("automation_on", False)):
256
294
  continue
257
- if not _valid_schedule(channel.get("automation_time")) or str(channel.get("automation_time")).strip() != current_minute:
295
+ scheduled_at = _scheduled_time(current, channel.get("automation_time"))
296
+ if scheduled_at is None or current < scheduled_at:
258
297
  continue
259
298
  existing = _batch_for_day(channel_id, day)
260
299
  if existing:
@@ -263,12 +302,14 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
263
302
  existing_tasks = create_tasks_for_batch(existing)
264
303
  skipped.append(channel_id)
265
304
  continue
266
- created.append(_create_channel_batch(channel, current))
305
+ created.append(_create_channel_batch(channel, scheduled_at))
267
306
  for item in created:
268
307
  batch = item["batch"]
308
+ scheduled_at_value = str((batch.get("options") or {}).get("automation_scheduled_at") or "")
309
+ scheduled_time = scheduled_at_value[11:16] if len(scheduled_at_value) >= 16 else current_minute
269
310
  status["last_runs"][item["channel_id"]] = {
270
311
  "date": day,
271
- "time": current_minute,
312
+ "time": scheduled_time,
272
313
  "batch_id": batch["id"],
273
314
  "task_ids": [task["id"] for task in item["tasks"]],
274
315
  }
@@ -276,7 +317,7 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
276
317
  "automation_completed",
277
318
  "Automação concluída",
278
319
  f"O lote agendado do canal {item['channel_id']} foi criado com sucesso.",
279
- metadata={"channel_id": item["channel_id"], "batch_id": batch["id"], "time": current_minute},
320
+ metadata={"channel_id": item["channel_id"], "batch_id": batch["id"], "time": scheduled_time},
280
321
  dedupe_key=f"automation:{batch['id']}",
281
322
  )
282
323
  _write_status(status)
@@ -8,6 +8,7 @@ import requests
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from .languages import LANGUAGE_BY_CODE, language_code
11
12
  from .llm_providers import active_llm_card, provider_definition
12
13
  from .provider_routing import ProviderRoutingError, route_llm_json
13
14
 
@@ -30,6 +31,23 @@ class CreativeGenerationError(RuntimeError):
30
31
  """Raised when a configured LLM cannot produce a valid creative package."""
31
32
 
32
33
 
34
+ _TARGET_LANGUAGE_NAMES = {
35
+ code: str(item.get("ui_name") or item.get("name") or code)
36
+ for code, item in LANGUAGE_BY_CODE.items()
37
+ }
38
+
39
+
40
+ def target_language(value: Any, default: str = "pt") -> tuple[str, str]:
41
+ """Return a canonical video-language code and an unambiguous prompt label."""
42
+ code = language_code(value, default=default)
43
+ return code, _TARGET_LANGUAGE_NAMES.get(code, code)
44
+
45
+
46
+ def _language_instruction(value: Any, default: str = "pt") -> str:
47
+ code, label = target_language(value, default=default)
48
+ return f"{label} ({code})"
49
+
50
+
33
51
  def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
34
52
  card = active_llm_card(settings)
35
53
  provider = str(card.get("provider") or "openai").strip().lower()
@@ -112,7 +130,8 @@ def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None =
112
130
  "channel_name": str(channel.get("name") or "Canal sem nome"),
113
131
  "handle": str(channel.get("handle") or ""),
114
132
  "description": str(channel.get("description") or ""),
115
- "language": str(channel.get("language") or "Português"),
133
+ "language": language_code(channel.get("language") or "pt"),
134
+ "language_name": _TARGET_LANGUAGE_NAMES.get(language_code(channel.get("language") or "pt"), "Portuguese"),
116
135
  "style_wide": str(channel.get("style_wide") or "pexels"),
117
136
  "blueprint_id": str(blueprint.get("id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
118
137
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
@@ -136,9 +155,12 @@ def generate_video_description(
136
155
  if not topic and not title:
137
156
  raise CreativeGenerationError("É necessário um tópico ou título antes de gerar a descrição do vídeo.")
138
157
  context = channel_context(channel)
158
+ requested_language = language or channel.get("language") or context["language"]
159
+ language_instruction = _language_instruction(requested_language)
139
160
  normalized_tags = [str(item).strip() for item in (tags or []) if str(item).strip()][:15]
140
161
  system = (
141
162
  "És um editor de metadados de YouTube. Cria uma descrição útil, clara e envolvente para o vídeo, "
163
+ f"e escreve todos os campos textuais de saída em {language_instruction}. "
142
164
  "sem alegações não verificadas, sem clickbait falso e sem mencionar que foi gerada por IA. "
143
165
  "Usa dois parágrafos curtos, inclui um convite natural para subscrever quando apropriado e devolve "
144
166
  "apenas JSON válido com a chave description."
@@ -146,7 +168,8 @@ def generate_video_description(
146
168
  user = json.dumps(
147
169
  {
148
170
  "channel": context,
149
- "language": language or context["language"],
171
+ "language": target_language(requested_language)[0],
172
+ "language_name": target_language(requested_language)[1],
150
173
  "topic": topic,
151
174
  "title": title or topic,
152
175
  "tags": normalized_tags,
@@ -168,8 +191,10 @@ def generate_topic_for_channel(
168
191
  user_context: str = "",
169
192
  ) -> dict[str, Any]:
170
193
  context = channel_context(channel, blueprint)
194
+ language_instruction = _language_instruction(channel.get("language") or context["language"])
171
195
  system = (
172
196
  "És o estratega editorial de um canal faceless. Gera um briefing específico para este canal. "
197
+ f"Todos os campos textuais devolvidos devem estar em {language_instruction}. "
173
198
  "Não inventes factos sobre o canal. Se o contexto já tiver um tópico, desenvolve-o sem trocar o nicho. "
174
199
  "Escreve de forma natural, sem introduções artificiais, frases de IA, clickbait enganoso ou CTA genérico. "
175
200
  "Responde apenas com JSON válido com as chaves topic, angle, hook, niche e rationale."
@@ -177,6 +202,7 @@ def generate_topic_for_channel(
177
202
  user = json.dumps(
178
203
  {
179
204
  "channel": context,
205
+ "output_language": language_instruction,
180
206
  "user_context": user_context.strip(),
181
207
  "reference_rules": reference_bundle(),
182
208
  "requirements": [
@@ -294,8 +320,11 @@ def generate_thumbnail_prompt(
294
320
  if not topic:
295
321
  raise CreativeGenerationError("É necessário um tópico para refazer o prompt da thumbnail.")
296
322
  context = channel_context(channel, blueprint)
323
+ requested_language = language or channel.get("language") or context["language"]
324
+ language_instruction = _language_instruction(requested_language)
297
325
  system = (
298
326
  "És um director de arte de thumbnails para YouTube. Refaz um prompt de imagem forte e específico "
327
+ f"O overlay_text e todo o texto editorial devem estar em {language_instruction}; o image_prompt pode permanecer em inglês. "
299
328
  "para o tópico fornecido, mantendo a intenção visual quando já existir um prompt. "
300
329
  "Separa rigorosamente a imagem sem texto do lettering: image_prompt nunca deve pedir texto renderizado, "
301
330
  "enquanto overlay_text é obrigatório, deve ser curto, legível e ter entre três e quatro palavras. "
@@ -305,7 +334,8 @@ def generate_thumbnail_prompt(
305
334
  user = json.dumps(
306
335
  {
307
336
  "channel": context,
308
- "language": language or context["language"],
337
+ "language": target_language(requested_language)[0],
338
+ "language_name": target_language(requested_language)[1],
309
339
  "topic": topic,
310
340
  "current_prompt": str(current_prompt or "").strip(),
311
341
  "reference_rules": reference_bundle(),
@@ -350,8 +380,11 @@ def generate_title_and_keywords(
350
380
  if not topic.strip():
351
381
  raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e keywords.")
352
382
  context = channel_context(channel, blueprint)
383
+ requested_language = language or channel.get("language") or context["language"]
384
+ language_instruction = _language_instruction(requested_language)
353
385
  system = (
354
386
  "És um director editorial para YouTube. Cria apenas o pacote editorial do vídeo: títulos candidatos e keywords SEO. "
387
+ f"Todos os títulos, fórmulas e campos editoriais textuais devem estar em {language_instruction}; as keywords podem permanecer em inglês para SEO. "
355
388
  "Não cries prompts, conceitos ou variantes de thumbnail. Gera exactamente pelo menos 20 títulos candidatos, "
356
389
  "um título seleccionado e entre 8 e 15 keywords curtas. O título deve carregar keywords no início, ter curiosidade, "
357
390
  "especificidade e emoção, sem clickbait falso. Responde apenas com JSON válido nas chaves selected_title, "
@@ -360,7 +393,8 @@ def generate_title_and_keywords(
360
393
  user = json.dumps(
361
394
  {
362
395
  "channel": context,
363
- "language": language or context["language"],
396
+ "language": target_language(requested_language)[0],
397
+ "language_name": target_language(requested_language)[1],
364
398
  "topic": topic.strip(),
365
399
  "reference_rules": reference_bundle(),
366
400
  "keywords_schema": ["lista de 8 a 15 keywords SEO curtas, sem hashtags"],
@@ -419,8 +453,11 @@ def generate_creative_package(
419
453
  if not topic.strip():
420
454
  raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e thumbnail.")
421
455
  context = channel_context(channel, blueprint)
456
+ requested_language = language or channel.get("language") or context["language"]
457
+ language_instruction = _language_instruction(requested_language)
422
458
  system = (
423
459
  "És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
460
+ f"Todos os títulos e textos visíveis da thumbnail devem estar em {language_instruction}; o image_prompt pode permanecer em inglês. "
424
461
  "para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
425
462
  "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
426
463
  "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara e lettering obrigatório de 3 a 4 palavras, "
@@ -430,7 +467,8 @@ def generate_creative_package(
430
467
  user = json.dumps(
431
468
  {
432
469
  "channel": context,
433
- "language": language or context["language"],
470
+ "language": target_language(requested_language)[0],
471
+ "language_name": target_language(requested_language)[1],
434
472
  "topic": topic.strip(),
435
473
  "reference_rules": reference_bundle(),
436
474
  "keywords_schema": ["lista de 8 a 15 keywords SEO curtas, sem hashtags"],
@@ -130,13 +130,19 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
130
130
  payload = payload if isinstance(payload, dict) else {}
131
131
  for index in range(count):
132
132
  default_topic = str(batch.get("topic") or "").strip()
133
+ topic_source = str(payload.get("topic_source") or options.get("topic_source") or "manual")
134
+ payload_topic = str(payload.get("topic") or "").strip()
133
135
  if count == 1:
134
- topic = str(payload.get("topic") or default_topic).strip()
136
+ topic = payload_topic or default_topic
137
+ elif payload_topic or default_topic:
138
+ topic = payload_topic or f"{default_topic} — variação {index + 1}"
135
139
  else:
136
- topic = str(payload.get("topic") or f"{default_topic} — variação {index + 1}").strip()
137
- if not topic:
140
+ topic = ""
141
+ topic = topic.strip()
142
+ pending_creative = not topic and topic_source in {"auto", "llm_pending"}
143
+ if not topic and not pending_creative:
138
144
  topic = f"Vídeo para {channel.get('name', 'Canal')}"
139
- title = str(payload.get("title") or topic).strip()
145
+ title = str(payload.get("title") or ("" if pending_creative else topic)).strip()
140
146
  artifacts = dict(payload.get("artifacts") or {})
141
147
  thumbnail_variants = payload.get("thumbnail_variants") if isinstance(payload.get("thumbnail_variants"), list) else []
142
148
  thumbnail_variant = payload.get("thumbnail_variant") if isinstance(payload.get("thumbnail_variant"), dict) else {}
@@ -150,7 +156,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
150
156
  thumbnail_status = str(payload.get("thumbnail_status") or ("generated" if thumbnail_path else "not_generated"))
151
157
  if thumbnail_path:
152
158
  artifacts.setdefault("thumbnail", thumbnail_path)
153
- initial_stage = "topic" if not topic and str(payload.get("topic_source") or options.get("topic_source") or "manual") in {"auto", "llm_pending"} else "script"
159
+ initial_stage = "topic" if pending_creative else "script"
154
160
  task = {
155
161
  "id": make_id("video"),
156
162
  "batch_id": batch["id"],
@@ -159,7 +165,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
159
165
  "channel_name": channel.get("name", "Canal"),
160
166
  "topic": topic,
161
167
  "title": title,
162
- "topic_source": str(payload.get("topic_source") or options.get("topic_source") or "manual"),
168
+ "topic_source": topic_source,
163
169
  "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
164
170
  "format": payload.get("format", options.get("format", "wide")),
165
171
  "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from typing import Any
7
7
 
8
- from .creative_generation import CreativeGenerationError, _chat_json, channel_context
8
+ from .creative_generation import CreativeGenerationError, _chat_json, channel_context, target_language
9
9
 
10
10
 
11
11
  DOCUMENT_TYPES = {
@@ -38,6 +38,8 @@ def generate_script_document(
38
38
  raise CreativeGenerationError("Escreva um tema ou briefing antes de gerar o documento.")
39
39
 
40
40
  channel_context_value = channel_context(channel or {}, blueprint or {})
41
+ requested_language = language or channel_context_value["language"]
42
+ language_code, language_name = target_language(requested_language)
41
43
  blueprint_payload = blueprint or {}
42
44
  generation_settings_payload = generation_settings or {}
43
45
  if normalized_type == "video_script":
@@ -47,6 +49,7 @@ def generate_script_document(
47
49
  }
48
50
  system = (
49
51
  "És um roteirista editorial de vídeos faceless. Cria um roteiro original, natural e executável, "
52
+ f"escrevendo título, resumo e conteúdo integralmente em {language_name} ({language_code}). "
50
53
  "alinhado exclusivamente ao nicho e às regras do Blueprint fornecido. Não inventes factos sensíveis, "
51
54
  "não uses introduções genéricas, não escrevas comentários sobre IA e não incluas um CTA vazio. "
52
55
  "Responde apenas com JSON válido nas chaves title, summary e content."
@@ -58,6 +61,7 @@ def generate_script_document(
58
61
  }
59
62
  system = (
60
63
  "És um compositor de letras originais. Cria uma letra cantável, coerente com o tema, idioma, "
64
+ f"escrevendo título, resumo e conteúdo integralmente em {language_name} ({language_code}). "
61
65
  "Blueprint e direcção musical fornecidos. Não copies letras existentes, não cites artistas sem pedido "
62
66
  "e não acrescentes explicações dentro da letra. Responde apenas com JSON válido nas chaves title, summary e content."
63
67
  )
@@ -67,7 +71,8 @@ def generate_script_document(
67
71
  "document_type": normalized_type,
68
72
  "requested_title": title.strip(),
69
73
  "brief": brief.strip(),
70
- "language": language.strip(),
74
+ "language": language_code,
75
+ "language_name": language_name,
71
76
  "channel": channel_context_value,
72
77
  "blueprint": blueprint_payload,
73
78
  "structure_notes": structure_notes.strip(),
@@ -85,7 +90,7 @@ def generate_script_document(
85
90
  "title": str(result.get("title") or title or brief).strip(),
86
91
  "summary": str(result.get("summary") or "").strip(),
87
92
  "content": content,
88
- "language": language.strip(),
93
+ "language": language_code,
89
94
  "blueprint_id": str(blueprint_payload.get("id") or ""),
90
95
  "blueprint_name": str(blueprint_payload.get("name") or "SEM BLUEPRINT CONFIGURADO"),
91
96
  "channel_id": str((channel or {}).get("id") or ""),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",