@danhachuel/thunderbolt 0.2.49 → 0.2.50

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.
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ REFERENCES_DIR = ROOT / "seed" / "references"
14
+
15
+ REFERENCE_FILES = {
16
+ "title_formulas": "title-formulas.md",
17
+ "viral_titles": "viral-titles.md",
18
+ "thumbnail_checklist": "thumbnail-checklist.md",
19
+ "viral_thumbnails": "viral-thumbnails.md",
20
+ "trend_intelligence": "trend-intelligence.md",
21
+ "humanize": "humanize-integration.md",
22
+ "ai_tells": "ai-tells.md",
23
+ }
24
+
25
+
26
+ class CreativeGenerationError(RuntimeError):
27
+ """Raised when a configured LLM cannot produce a valid creative package."""
28
+
29
+
30
+ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
31
+ provider = str(settings.get("llm_provider") or "openai").strip().lower()
32
+ key = str(settings.get(f"{provider}_api_key") or "").strip()
33
+ base_url = str(settings.get(f"{provider}_base_url") or "").strip()
34
+ model = str(settings.get(f"{provider}_model_name") or "").strip()
35
+ if provider == "openai":
36
+ base_url = base_url or "https://api.openai.com/v1"
37
+ elif provider == "ollama":
38
+ base_url = base_url or "http://127.0.0.1:11434/v1"
39
+ if not base_url:
40
+ raise CreativeGenerationError(
41
+ f"O provider LLM '{provider}' não tem Base URL configurada em Configurações Técnicas > API Keys."
42
+ )
43
+ if not model:
44
+ raise CreativeGenerationError(
45
+ f"O provider LLM '{provider}' não tem modelo configurado em Configurações Técnicas > API Keys."
46
+ )
47
+ if provider not in {"ollama", "litellm"} and not key:
48
+ raise CreativeGenerationError(
49
+ f"Configure a API key do provider LLM '{provider}' em Configurações Técnicas > API Keys."
50
+ )
51
+ return provider, key, base_url.rstrip("/"), model
52
+
53
+
54
+ @lru_cache(maxsize=1)
55
+ def reference_bundle() -> str:
56
+ sections: list[str] = []
57
+ for label, filename in REFERENCE_FILES.items():
58
+ path = REFERENCES_DIR / filename
59
+ try:
60
+ content = path.read_text(encoding="utf-8")
61
+ except OSError:
62
+ continue
63
+ # Keep prompts bounded while preserving the concrete rules from the attachments.
64
+ sections.append(f"[{label}]\n{content[:4200]}")
65
+ return "\n\n".join(sections)
66
+
67
+
68
+ def _json_content(payload: dict[str, Any]) -> dict[str, Any]:
69
+ choices = payload.get("choices") or []
70
+ if not choices:
71
+ raise CreativeGenerationError("O endpoint LLM devolveu uma resposta sem choices.")
72
+ message = (choices[0] or {}).get("message") or {}
73
+ content = message.get("content")
74
+ if isinstance(content, list):
75
+ content = "".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
76
+ text = str(content or "").strip()
77
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
78
+ text = re.sub(r"\s*```$", "", text).strip()
79
+ try:
80
+ parsed = json.loads(text)
81
+ except json.JSONDecodeError as exc:
82
+ raise CreativeGenerationError("O endpoint LLM não devolveu JSON válido.") from exc
83
+ if not isinstance(parsed, dict):
84
+ raise CreativeGenerationError("A resposta do endpoint LLM não é um objecto JSON.")
85
+ return parsed
86
+
87
+
88
+ def _chat_json(settings: dict[str, Any], system_prompt: str, user_prompt: str) -> dict[str, Any]:
89
+ _provider, api_key, base_url, model = _provider_config(settings)
90
+ headers = {"Content-Type": "application/json"}
91
+ if api_key:
92
+ headers["Authorization"] = f"Bearer {api_key}"
93
+ body = {
94
+ "model": model,
95
+ "messages": [
96
+ {"role": "system", "content": system_prompt},
97
+ {"role": "user", "content": user_prompt},
98
+ ],
99
+ "response_format": {"type": "json_object"},
100
+ }
101
+ endpoint = f"{base_url}/chat/completions"
102
+ try:
103
+ response = requests.post(endpoint, headers=headers, json=body, timeout=120)
104
+ except requests.RequestException as exc:
105
+ raise CreativeGenerationError(f"Não foi possível contactar o provider LLM: {exc}") from exc
106
+ if response.status_code >= 400:
107
+ detail = response.text[:500].replace(api_key, "[REDACTED]") if api_key else response.text[:500]
108
+ raise CreativeGenerationError(f"O provider LLM devolveu HTTP {response.status_code}: {detail}")
109
+ try:
110
+ payload = response.json()
111
+ except ValueError as exc:
112
+ raise CreativeGenerationError("O provider LLM devolveu uma resposta que não é JSON.") from exc
113
+ return _json_content(payload)
114
+
115
+
116
+ def channel_context(channel: dict[str, Any], blueprint: dict[str, Any] | None = None) -> dict[str, Any]:
117
+ blueprint = blueprint or {}
118
+ metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
119
+ return {
120
+ "channel_name": str(channel.get("name") or "Canal sem nome"),
121
+ "handle": str(channel.get("handle") or ""),
122
+ "description": str(channel.get("description") or ""),
123
+ "language": str(channel.get("language") or "Português"),
124
+ "style_wide": str(channel.get("style_wide") or "pexels"),
125
+ "blueprint_id": str(blueprint.get("id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
126
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
127
+ "blueprint_niche": str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or ""),
128
+ "default_voice": str(channel.get("default_voice") or channel.get("voice") or ""),
129
+ }
130
+
131
+
132
+ def generate_topic_for_channel(
133
+ settings: dict[str, Any],
134
+ channel: dict[str, Any],
135
+ blueprint: dict[str, Any] | None = None,
136
+ user_context: str = "",
137
+ ) -> dict[str, Any]:
138
+ context = channel_context(channel, blueprint)
139
+ system = (
140
+ "És o estratega editorial de um canal faceless. Gera um briefing específico para este canal. "
141
+ "Não inventes factos sobre o canal. Se o contexto já tiver um tópico, desenvolve-o sem trocar o nicho. "
142
+ "Escreve de forma natural, sem introduções artificiais, frases de IA, clickbait enganoso ou CTA genérico. "
143
+ "Responde apenas com JSON válido com as chaves topic, angle, hook, niche e rationale."
144
+ )
145
+ user = json.dumps(
146
+ {
147
+ "channel": context,
148
+ "user_context": user_context.strip(),
149
+ "reference_rules": reference_bundle(),
150
+ "requirements": [
151
+ "topic em uma frase clara",
152
+ "angle contrarian ou inesperado quando fizer sentido",
153
+ "hook para os primeiros segundos",
154
+ "niche coerente com o Blueprint e descrição",
155
+ "rationale curta e prática",
156
+ ],
157
+ },
158
+ ensure_ascii=False,
159
+ )
160
+ result = _chat_json(settings, system, user)
161
+ required = ("topic", "angle", "hook", "niche", "rationale")
162
+ if any(not str(result.get(key) or "").strip() for key in required):
163
+ raise CreativeGenerationError("O briefing gerado veio incompleto; tente novamente.")
164
+ result["topic_source"] = "llm"
165
+ result["channel_id"] = str(channel.get("id") or "")
166
+ return result
167
+
168
+
169
+ def _score(value: Any) -> int:
170
+ try:
171
+ return max(0, min(3, int(value)))
172
+ except (TypeError, ValueError):
173
+ return 0
174
+
175
+
176
+ def _short_overlay(value: Any) -> str:
177
+ words = re.findall(r"\S+", str(value or "").strip())
178
+ return " ".join(words[:4])
179
+
180
+
181
+ def generate_creative_package(
182
+ settings: dict[str, Any],
183
+ channel: dict[str, Any],
184
+ topic: str,
185
+ blueprint: dict[str, Any] | None = None,
186
+ language: str = "",
187
+ ) -> dict[str, Any]:
188
+ if not topic.strip():
189
+ raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e thumbnail.")
190
+ context = channel_context(channel, blueprint)
191
+ system = (
192
+ "És director editorial e de thumbnails para YouTube. Cria um pacote coerente de título e thumbnail "
193
+ "para o tópico fornecido. Gera exactamente pelo menos 20 títulos candidatos e entre 3 e 5 variantes de thumbnail. "
194
+ "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
195
+ "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara, texto opcional de até 4 palavras, "
196
+ "safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
197
+ "Responde apenas com JSON válido nas chaves selected_title, title_candidates e thumbnail_variants."
198
+ )
199
+ user = json.dumps(
200
+ {
201
+ "channel": context,
202
+ "language": language or context["language"],
203
+ "topic": topic.strip(),
204
+ "reference_rules": reference_bundle(),
205
+ "title_candidates_schema": {
206
+ "title": "string",
207
+ "formula": "string",
208
+ "curiosity_score": "integer 0-3",
209
+ "specificity_score": "integer 0-3",
210
+ "emotional_score": "integer 0-3",
211
+ },
212
+ "thumbnail_variant_schema": {
213
+ "concept": "string",
214
+ "overlay_text": "string, maximum 4 words",
215
+ "composition": "string",
216
+ "color_palette": "string",
217
+ "subject": "string",
218
+ "image_prompt": "string, no text rendered in the image",
219
+ "title_synergy": "string",
220
+ },
221
+ },
222
+ ensure_ascii=False,
223
+ )
224
+ result = _chat_json(settings, system, user)
225
+ raw_titles = result.get("title_candidates")
226
+ if not isinstance(raw_titles, list) or len(raw_titles) < 20:
227
+ raise CreativeGenerationError("O provider deve devolver pelo menos 20 títulos candidatos.")
228
+ titles: list[dict[str, Any]] = []
229
+ for item in raw_titles[:30]:
230
+ if not isinstance(item, dict) or not str(item.get("title") or "").strip():
231
+ continue
232
+ title = str(item["title"]).strip()
233
+ titles.append(
234
+ {
235
+ "title": title,
236
+ "formula": str(item.get("formula") or "custom").strip(),
237
+ "curiosity_score": _score(item.get("curiosity_score")),
238
+ "specificity_score": _score(item.get("specificity_score")),
239
+ "emotional_score": _score(item.get("emotional_score")),
240
+ }
241
+ )
242
+ if len(titles) < 20:
243
+ raise CreativeGenerationError("Os títulos devolvidos pelo provider não têm conteúdo suficiente.")
244
+ variants_raw = result.get("thumbnail_variants")
245
+ if not isinstance(variants_raw, list) or len(variants_raw) < 3:
246
+ raise CreativeGenerationError("O provider deve devolver pelo menos 3 variantes de thumbnail.")
247
+ variants: list[dict[str, Any]] = []
248
+ for item in variants_raw[:5]:
249
+ if not isinstance(item, dict):
250
+ continue
251
+ if not str(item.get("concept") or "").strip() or not str(item.get("image_prompt") or "").strip():
252
+ continue
253
+ variants.append(
254
+ {
255
+ "concept": str(item.get("concept") or "").strip(),
256
+ "overlay_text": _short_overlay(item.get("overlay_text")),
257
+ "composition": str(item.get("composition") or "").strip(),
258
+ "color_palette": str(item.get("color_palette") or "").strip(),
259
+ "subject": str(item.get("subject") or "").strip(),
260
+ "image_prompt": str(item.get("image_prompt") or "").strip(),
261
+ "title_synergy": str(item.get("title_synergy") or "").strip(),
262
+ "status": "prompt_ready",
263
+ }
264
+ )
265
+ if len(variants) < 3:
266
+ raise CreativeGenerationError("As variantes de thumbnail devolvidas pelo provider estão incompletas.")
267
+ selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
268
+ if selected_title not in {item["title"] for item in titles}:
269
+ selected_title = titles[0]["title"]
270
+ return {
271
+ "title": selected_title,
272
+ "title_candidates": titles,
273
+ "thumbnail_variant": variants[0],
274
+ "thumbnail_variants": variants,
275
+ "thumbnail_status": "prompt_ready",
276
+ "topic_source": "llm",
277
+ "generated_by": "configured_llm",
278
+ }
@@ -108,39 +108,66 @@ def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, o
108
108
 
109
109
 
110
110
  def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
111
+ """Expand a batch into tasks, allowing independent payloads for each channel."""
111
112
  tasks = read_json("tasks.json", [])
112
113
  channels = {c["id"]: c for c in read_json("channels.json", [])}
113
- target_channels = batch["channel_ids"]
114
- count = batch["quantity"]
115
- if batch["mode"] == "general":
114
+ options = batch.get("options") or {}
115
+ channel_payloads = options.get("channel_payloads") or {}
116
+ target_channels = batch.get("channel_ids") or []
117
+ count = max(1, int(batch.get("quantity") or 1))
118
+ if batch.get("mode") == "general":
116
119
  count = 1
117
120
  created: list[dict[str, Any]] = []
118
121
  for channel_id in target_channels:
122
+ channel = channels.get(channel_id, {})
123
+ payload = channel_payloads.get(channel_id) if isinstance(channel_payloads, dict) else None
124
+ payload = payload if isinstance(payload, dict) else {}
119
125
  for index in range(count):
120
- channel = channels.get(channel_id, {})
126
+ default_topic = str(batch.get("topic") or "").strip()
127
+ if count == 1:
128
+ topic = str(payload.get("topic") or default_topic).strip()
129
+ else:
130
+ topic = str(payload.get("topic") or f"{default_topic} — variação {index + 1}").strip()
131
+ if not topic:
132
+ topic = f"Vídeo para {channel.get('name', 'Canal')}"
133
+ title = str(payload.get("title") or topic).strip()
134
+ artifacts = dict(payload.get("artifacts") or {})
135
+ thumbnail_path = str(payload.get("thumbnail_path") or "").strip()
136
+ if thumbnail_path:
137
+ artifacts.setdefault("thumbnail", thumbnail_path)
121
138
  task = {
122
139
  "id": make_id("video"),
123
140
  "batch_id": batch["id"],
124
- "creation_mode": batch["mode"],
141
+ "creation_mode": batch.get("mode", "single"),
125
142
  "channel_id": channel_id,
126
143
  "channel_name": channel.get("name", "Canal"),
127
- "topic": batch["topic"] if count == 1 else f"{batch['topic']} — variação {index + 1}",
128
- "language": batch["options"].get("language", channel.get("language", "Português")),
129
- "format": batch["options"].get("format", "wide"),
130
- "style_wide": batch["options"].get("style_wide", channel.get("style_wide", "pexels")),
131
- "style_ia": batch["options"].get("style_ia", ""),
132
- "music_mode": batch["options"].get("music_mode", False),
133
- "music_path": batch["options"].get("music_path", ""),
134
- "music_source": batch["options"].get("music_source", ""),
135
- "background_mode": batch["options"].get("background_mode", "stock"),
136
- "blueprint_id": channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
137
- "voice": channel.get("default_voice") or channel.get("voice", ""),
144
+ "topic": topic,
145
+ "title": title,
146
+ "topic_source": str(payload.get("topic_source") or options.get("topic_source") or "manual"),
147
+ "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
148
+ "format": payload.get("format", options.get("format", "wide")),
149
+ "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
150
+ "style_ia": payload.get("style_ia", options.get("style_ia", "")),
151
+ "music_mode": payload.get("music_mode", options.get("music_mode", False)),
152
+ "music_path": payload.get("music_path", options.get("music_path", "")),
153
+ "music_source": payload.get("music_source", options.get("music_source", "")),
154
+ "background_mode": payload.get("background_mode", options.get("background_mode", "stock")),
155
+ "blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
156
+ "blueprint_name": payload.get("blueprint_name", ""),
157
+ "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
138
158
  "automation_on": bool(channel.get("automation_on", False)),
139
159
  "automation_time": channel.get("automation_time", "00:00"),
160
+ "thumbnail_variant": payload.get("thumbnail_variant", {}),
161
+ "thumbnail_variants": payload.get("thumbnail_variants", []),
162
+ "thumbnail_prompt": payload.get("thumbnail_prompt", ""),
163
+ "thumbnail_text": payload.get("thumbnail_text", ""),
164
+ "thumbnail_status": payload.get("thumbnail_status", "not_generated"),
165
+ "title_candidates": payload.get("title_candidates", []),
166
+ "ai_generation": payload.get("ai_generation", {}),
140
167
  "stage": "script",
141
168
  "state": "to_do",
142
169
  "progress": 0,
143
- "artifacts": {},
170
+ "artifacts": artifacts,
144
171
  "error": None,
145
172
  "created_at": now(),
146
173
  "updated_at": now(),
@@ -148,10 +175,12 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
148
175
  tasks.append(task)
149
176
  created.append(task)
150
177
  write_json("tasks.json", tasks)
151
- for task in created:
152
- queues = read_json("queues.json", {})
153
- queues.setdefault("script", []).append(task["id"])
154
- write_json("queues.json", queues)
178
+ queues = read_json("queues.json", {})
179
+ if not isinstance(queues, dict):
180
+ queues = {}
181
+ queues.setdefault("script", [])
182
+ queues["script"].extend(task["id"] for task in created)
183
+ write_json("queues.json", queues)
155
184
  return created
156
185
 
157
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.49",
3
+ "version": "0.2.50",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  "storage/blueprints/**/*.json",
18
18
  "seed/blueprints/**/*.json",
19
19
  "seed/skills/*.md",
20
+ "seed/references/*.md",
20
21
  "README.md",
21
22
  "MANUAL-INSTALACAO.md",
22
23
  "THIRD-PARTY-NOTICES.md",