@danhachuel/thunderbolt 0.4.16 → 0.4.18
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/app/main.py
CHANGED
|
@@ -34,7 +34,7 @@ def display_version(version: str) -> str:
|
|
|
34
34
|
|
|
35
35
|
APP_VERSION_LABEL = display_version(APP_VERSION)
|
|
36
36
|
|
|
37
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
37
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, stop_task_by_user, transition_task, update_channel, update_channel_video
|
|
38
38
|
from hermes_ui.drafts import list_drafts, save_draft
|
|
39
39
|
from hermes_ui.automation_worker import load_worker_status
|
|
40
40
|
from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
|
|
@@ -3778,8 +3778,12 @@ def _render_video_task_state(task: dict[str, Any]) -> None:
|
|
|
3778
3778
|
state = str(task.get("state") or "unknown").strip().lower()
|
|
3779
3779
|
progress = _video_task_progress(task)
|
|
3780
3780
|
st.caption("Estado")
|
|
3781
|
-
|
|
3782
|
-
|
|
3781
|
+
if state == "blocked" and task.get("stop_reason") == "user":
|
|
3782
|
+
st.write("Stoped by User")
|
|
3783
|
+
st.caption("Parado manualmente pelo utilizador.")
|
|
3784
|
+
else:
|
|
3785
|
+
st.write(state or "—")
|
|
3786
|
+
st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
|
|
3783
3787
|
st.progress(progress, text=f"{progress}%")
|
|
3784
3788
|
helper_status = str(task.get("video_helper_status") or "").strip()
|
|
3785
3789
|
if state == "doing" and helper_status:
|
|
@@ -4240,7 +4244,7 @@ def render_automation():
|
|
|
4240
4244
|
st.rerun()
|
|
4241
4245
|
with stop_col:
|
|
4242
4246
|
if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
|
|
4243
|
-
|
|
4247
|
+
stop_task_by_user(task["id"])
|
|
4244
4248
|
st.rerun()
|
|
4245
4249
|
with delete_col:
|
|
4246
4250
|
confirm_delete_key = f"automation_confirm_delete_{task['id']}"
|
|
@@ -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 =
|
|
244
|
+
topic, payload = _pending_payload(channel)
|
|
207
245
|
options = {
|
|
208
|
-
"language": channel.get("language") or "
|
|
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": "
|
|
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
|
-
|
|
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,
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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"],
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
137
|
-
|
|
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
|
|
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":
|
|
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"))),
|
|
@@ -353,6 +359,8 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
|
|
|
353
359
|
previous_state = str(task.get("state") or "")
|
|
354
360
|
if state:
|
|
355
361
|
task["state"] = state
|
|
362
|
+
if state != "blocked":
|
|
363
|
+
task.pop("stop_reason", None)
|
|
356
364
|
if stage:
|
|
357
365
|
if stage not in STAGES and stage not in LEGACY_STAGES:
|
|
358
366
|
raise ValueError(f"Etapa inválida: {stage}")
|
|
@@ -369,6 +377,21 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
|
|
|
369
377
|
return updated
|
|
370
378
|
|
|
371
379
|
|
|
380
|
+
def stop_task_by_user(task_id: str) -> dict[str, Any] | None:
|
|
381
|
+
"""Stop a running video task and retain the user-originated reason for the UI."""
|
|
382
|
+
task = transition_task(task_id, "blocked")
|
|
383
|
+
if task is None:
|
|
384
|
+
return None
|
|
385
|
+
tasks = read_json("tasks.json", [])
|
|
386
|
+
for persisted in tasks:
|
|
387
|
+
if persisted.get("id") == task_id:
|
|
388
|
+
persisted["stop_reason"] = "user"
|
|
389
|
+
persisted["updated_at"] = now()
|
|
390
|
+
write_json("tasks.json", tasks)
|
|
391
|
+
return persisted
|
|
392
|
+
return task
|
|
393
|
+
|
|
394
|
+
|
|
372
395
|
def retry_task_with_current_settings(task_id: str) -> dict[str, Any] | None:
|
|
373
396
|
"""Queue a failed or blocked task without persisting API credentials or provider snapshots.
|
|
374
397
|
|
|
@@ -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":
|
|
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":
|
|
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