@danhachuel/thunderbolt 0.5.79 → 0.5.81
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 +77 -18
- package/hermes_ui/pipeline_worker.py +37 -25
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -22,6 +22,7 @@ sys.stdout = _force_utf8_stream(sys.stdout)
|
|
|
22
22
|
sys.stderr = _force_utf8_stream(sys.stderr)
|
|
23
23
|
|
|
24
24
|
import hashlib
|
|
25
|
+
from html import escape
|
|
25
26
|
import json
|
|
26
27
|
import mimetypes
|
|
27
28
|
import re
|
|
@@ -1501,8 +1502,13 @@ def render_growth_youtube():
|
|
|
1501
1502
|
with selector_col:
|
|
1502
1503
|
selected = st.selectbox("Canal a analisar", channels, format_func=lambda item: str(item.get("name") or "Canal sem nome"), key="growth_youtube_channel")
|
|
1503
1504
|
with action_col:
|
|
1504
|
-
st.
|
|
1505
|
-
|
|
1505
|
+
action_button_col, action_status_col = st.columns([1.15, 0.85], gap="small")
|
|
1506
|
+
with action_button_col:
|
|
1507
|
+
st.write("")
|
|
1508
|
+
analyse_clicked = st.button("ANALISAR CANAL", type="primary", use_container_width=True, key="growth_youtube_analyse")
|
|
1509
|
+
with action_status_col:
|
|
1510
|
+
if st.session_state.get("growth_analysis_status") == "complete":
|
|
1511
|
+
st.markdown('<div style="padding-top:9px;color:#22c55e;font-weight:700;white-space:nowrap;">✓ Análise concluída</div>', unsafe_allow_html=True)
|
|
1506
1512
|
if analyse_clicked:
|
|
1507
1513
|
settings = read_json("settings.json", {})
|
|
1508
1514
|
llm_settings, _ = ensure_llm_provider_cards(settings)
|
|
@@ -1511,8 +1517,10 @@ def render_growth_youtube():
|
|
|
1511
1517
|
try:
|
|
1512
1518
|
record = run_audit(selected, settings, vision_card=vision_card, progress=st.write)
|
|
1513
1519
|
st.session_state["growth_last_analysis_code"] = record["code"]
|
|
1520
|
+
st.session_state["growth_analysis_status"] = "complete"
|
|
1514
1521
|
status.update(label="Análise concluída", state="complete")
|
|
1515
1522
|
except Exception as exc:
|
|
1523
|
+
st.session_state["growth_analysis_status"] = "failed"
|
|
1516
1524
|
status.update(label="Análise falhou", state="error")
|
|
1517
1525
|
st.error(str(exc)[:500])
|
|
1518
1526
|
records = list_analyses()
|
|
@@ -1552,23 +1560,74 @@ def render_growth_youtube():
|
|
|
1552
1560
|
st.subheader("Dashboard de Growth")
|
|
1553
1561
|
st.caption("Visão híbrida: os 3 pilares críticos em destaque e os 5 pilares operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
|
|
1554
1562
|
source_names = {"public": "PÚBLICO", "paligemma": "PALIGEMMA", "estimated": "DADO ESTIMADO", "unavailable": "INDISPONÍVEL", "youtube_analytics_oauth": "ANALYTICS OAUTH"}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1563
|
+
metric_by_label = {str(item.get("label")): item for item in metrics}
|
|
1564
|
+
videos = selected_record.get("videos", []) if selected_record else []
|
|
1565
|
+
analytics_values = (selected_record.get("analytics") or {}).get("values", {}) if selected_record else {}
|
|
1566
|
+
|
|
1567
|
+
def display_value(value: Any, suffix: str = "") -> str:
|
|
1568
|
+
if value is None or value == "" or value == "—":
|
|
1569
|
+
return "—"
|
|
1570
|
+
return f"{value}{suffix}"
|
|
1571
|
+
|
|
1572
|
+
def number_value(value: Any) -> float | None:
|
|
1573
|
+
try:
|
|
1574
|
+
return float(value)
|
|
1575
|
+
except (TypeError, ValueError):
|
|
1576
|
+
return None
|
|
1577
|
+
|
|
1578
|
+
def status_for(value: Any, target: Any = None, *, direction: str = "above", available: bool = True) -> tuple[str, str]:
|
|
1579
|
+
if not available or value is None or target is None:
|
|
1580
|
+
return "A verificar", "#94a3b8"
|
|
1581
|
+
current, goal = number_value(value), number_value(target)
|
|
1582
|
+
if current is None or goal is None:
|
|
1583
|
+
return "A verificar", "#94a3b8"
|
|
1584
|
+
passed = current >= goal if direction == "above" else current <= goal
|
|
1585
|
+
return ("Meta atingida", "#22c55e") if passed else ("Atenção", "#facc15")
|
|
1586
|
+
|
|
1587
|
+
def text_status(value: Any, expected: set[str], *, available: bool = True) -> tuple[str, str]:
|
|
1588
|
+
if not available or value is None or value == "—":
|
|
1589
|
+
return "A verificar", "#94a3b8"
|
|
1590
|
+
return ("Meta atingida", "#22c55e") if str(value).casefold() in {item.casefold() for item in expected} else ("Atenção", "#facc15")
|
|
1591
|
+
|
|
1592
|
+
def avg_video_field(field: str) -> float | None:
|
|
1593
|
+
values = [number_value(video.get(field)) for video in videos]
|
|
1594
|
+
values = [value for value in values if value is not None and value > 0]
|
|
1595
|
+
return sum(values) / len(values) if values else None
|
|
1596
|
+
|
|
1597
|
+
avg_views = avg_video_field("view_count")
|
|
1598
|
+
avg_thumb_score = number_value(metric_by_label.get("Qualidade da thumbnail", {}).get("score"))
|
|
1599
|
+
title_score = number_value(metric_by_label.get("Qualidade do título", {}).get("score"))
|
|
1600
|
+
hook_score = number_value(metric_by_label.get("Hook — retenção inicial", {}).get("score"))
|
|
1601
|
+
average_percentage = number_value(analytics_values.get("averageViewPercentage"))
|
|
1602
|
+
average_duration = number_value(analytics_values.get("averageViewDuration"))
|
|
1603
|
+
gained = number_value(analytics_values.get("subscribersGained"))
|
|
1604
|
+
views_total = number_value(analytics_values.get("views")) or sum(number_value(video.get("view_count")) or 0 for video in videos)
|
|
1605
|
+
conversion = (gained / views_total * 100) if gained is not None and views_total else None
|
|
1606
|
+
niche = str(selected.get("niche") or selected.get("description") or "—")
|
|
1607
|
+
vision_values = [video.get("vision") or {} for video in videos if (video.get("vision") or {}).get("status") == "analisado"]
|
|
1608
|
+
contrast = next((str(item.get("contrast")).strip() for item in vision_values if item.get("contrast")), None)
|
|
1609
|
+
emotion = next((str(item.get("emotion")).strip() for item in vision_values if item.get("emotion")), None)
|
|
1610
|
+
cards = [
|
|
1611
|
+
("Validação de Nicho", metric_by_label.get("Demanda validada", {}).get("score", 0), True, [("Nicho Detectado", niche, "RPM > $7", text_status(niche, {niche}, available=niche != "—")), ("Teto da Concorrência", "—", "> 500k", status_for(None)), ("Média de Views (5 vídeos)", f"{avg_views:,.0f}" if avg_views is not None else "—", "> 50k", status_for(avg_views, 50000)), ("Impressões (48h iniciais)", display_value(analytics_values.get("impressions")), "> 10k", status_for(analytics_values.get("impressions"), 10000)), ("Tráfego de Pesquisa (%)", "—", "15–40%", status_for(None))]),
|
|
1612
|
+
("Thumbnail", metric_by_label.get("Qualidade da thumbnail", {}).get("score", 0), True, [("CTR (Click-Through Rate)", "—", "> 6%", status_for(None)), ("Qualidade Visual (Thumb)", f"{avg_thumb_score / 10:.1f}/10" if avg_thumb_score is not None else "—", "> 7/10", status_for(avg_thumb_score, 70)), ("Contraste da Thumbnail", contrast or "—", "Alto", text_status(contrast, {"alto", "high"}, available=bool(contrast))), ("Emoção Predominante", emotion or "—", "Medo/Surpresa", text_status(emotion, {"medo", "surpresa", "fear", "surprise"}, available=bool(emotion)))]),
|
|
1613
|
+
("Título dos Vídeos", metric_by_label.get("Qualidade do título", {}).get("score", 0), True, [("Score do Título (SEO)", f"{title_score / 10:.1f}/10" if title_score is not None else "—", "> 8/10", status_for(title_score, 80)), ("Gatilho Mental no Título", "—", "> 70%", status_for(None)), ("Palavra-chave no Título", "—", "Sim", text_status(None, {"sim"}, available=False))]),
|
|
1614
|
+
("Hook e Retenção", metric_by_label.get("Hook — retenção inicial", {}).get("score", 0), False, [("Queda nos Primeiros 30s", "—", "< 20%", status_for(None)), ("Retenção Média (%)", display_value(average_percentage, "%"), "> 50%", status_for(average_percentage, 50)), ("Hook (Primeiros 15s)", "Forte" if hook_score is not None and hook_score >= 70 else "—", "Promessa clara", text_status("forte" if hook_score is not None and hook_score >= 70 else None, {"forte"}, available=hook_score is not None))]),
|
|
1615
|
+
("Ritmo e Edição", metric_by_label.get("Ritmo e edição", {}).get("score", 0), False, [("Pico de Abandono", "—", "Queda suave", status_for(None)), ("Tempo Médio (AVD)", f"{average_duration / 60:.1f} min" if average_duration is not None else "—", "> 4 min", status_for(average_duration, 240)), ("Estrutura do Roteiro", "Clara" if hook_score is not None and hook_score >= 60 else "—", "Clara", text_status("clara" if hook_score is not None and hook_score >= 60 else None, {"clara"}, available=hook_score is not None)), ("Densidade de Palavras-chave", "—", "> 3", status_for(None))]),
|
|
1616
|
+
("Tráfego e Algoritmo", metric_by_label.get("Origem do tráfego", {}).get("score", 0), False, [("Origem: Browse (%)", "—", "> 50%", status_for(None)), ("Origem: Inscritos (%)", "—", "< 30%", status_for(None))]),
|
|
1617
|
+
("Conversão e Engajamento", metric_by_label.get("Conversão em inscritos", {}).get("score", 0), False, [("Taxa Conversão (Subs/Views)", display_value(conversion, "%") if conversion is not None else "—", "> 1.5%", status_for(conversion, 1.5)), ("Taxa de Likes (Likes/Views)", "—", "> 4%", status_for(None))]),
|
|
1618
|
+
("Consistência e Saúde", metric_by_label.get("Cadência de publicação", {}).get("score", 0), False, [("Cadência de Postagem", "—", "≤ 7 dias", status_for(None)), ("Biblioteca Perene (%)", "—", "> 40%", status_for(None)), ("Crescimento Líquido de Subs", display_value((gained or 0) - (number_value(analytics_values.get("subscribersLost")) or 0)) if gained is not None else "—", "Positivo", ("Meta atingida", "#22c55e") if gained is not None and (gained - (number_value(analytics_values.get("subscribersLost")) or 0)) > 0 else status_for(None))]),
|
|
1619
|
+
]
|
|
1620
|
+
|
|
1621
|
+
def growth_card(card_data: tuple[str, Any, bool, list[tuple[str, str, str, tuple[str, str]]]]) -> str:
|
|
1622
|
+
title, raw_score, featured_card, rows = card_data
|
|
1623
|
+
score = int(number_value(raw_score) or 0)
|
|
1624
|
+
source = source_names.get(str(metric_by_label.get(title, {}).get("source", "unknown")), "A VERIFICAR")
|
|
1625
|
+
row_html = "".join(f'<tr><td>{escape(label)}</td><td><b>{escape(str(value))}</b></td><td>{escape(target)}</td><td><span style="color:{colour};font-weight:700;">● {escape(status)}</span></td></tr>' for label, value, target, (status, colour) in rows)
|
|
1626
|
+
return f'<div style="min-height:{"350px" if featured_card else "280px"};border:1px solid #2b3b52;border-radius:14px;padding:16px;background:linear-gradient(145deg,#111c2b,#0b121c);box-shadow:0 8px 22px rgba(0,0,0,.16);"><div style="font-size:11px;font-weight:800;letter-spacing:.08em;color:#7dd3fc;text-transform:uppercase;">{escape(source)}</div><div style="font-size:{"19px" if featured_card else "16px"};font-weight:750;color:#f8fafc;margin-top:8px;">{escape(title)}</div><div style="display:flex;align-items:baseline;gap:5px;margin:8px 0 10px;"><span style="font-size:{"38px" if featured_card else "30px"};font-weight:850;color:{_growth_score_color(score)};">{score}</span><span style="font-size:13px;color:#94a3b8;">/100</span></div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:11px;color:#cbd5e1;"><thead><tr><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">KPI</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Valor</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Meta</th><th style="text-align:left;padding:5px 3px;border-bottom:1px solid #334155;">Status</th></tr></thead><tbody>{row_html}</tbody></table></div></div>'
|
|
1569
1627
|
|
|
1570
|
-
st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(growth_card(
|
|
1571
|
-
st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(growth_card(
|
|
1628
|
+
st.markdown('<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:14px 0 18px;">' + "".join(growth_card(item) for item in cards[:3]) + '</div>', unsafe_allow_html=True)
|
|
1629
|
+
st.markdown('<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:0 0 18px;">' + "".join(growth_card(item) for item in cards[3:]) + '</div>', unsafe_allow_html=True)
|
|
1630
|
+
st.caption("Legenda: ● Verde = meta atingida · ● Amarelo = atenção · ● Vermelho = crítico · Cinzento = dado não disponível na fonte autorizada.")
|
|
1572
1631
|
st.divider()
|
|
1573
1632
|
history_tab, dashboard_tab = st.tabs(["Últimas análises", "Leitura estratégica"])
|
|
1574
1633
|
with dashboard_tab:
|
|
@@ -658,28 +658,37 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
|
|
|
658
658
|
return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
|
|
659
659
|
|
|
660
660
|
|
|
661
|
-
def
|
|
662
|
-
"""Return stock
|
|
661
|
+
def _material_video_attempts(task: dict[str, Any], settings: dict[str, Any]) -> list[tuple[str, str]]:
|
|
662
|
+
"""Return one stock attempt per configured key in persisted priority order."""
|
|
663
663
|
route = _normalise_video_route(task, settings)
|
|
664
664
|
if route not in {"pexels", "pixabay"}:
|
|
665
|
-
return [route]
|
|
665
|
+
return [(route, "")]
|
|
666
666
|
|
|
667
|
-
|
|
668
|
-
|
|
667
|
+
has_explicit_cards = isinstance(settings.get("material_source_cards"), list) and bool(settings.get("material_source_cards"))
|
|
668
|
+
attempts: list[tuple[str, str]] = []
|
|
669
|
+
for card in material_source_cards(settings, enabled_only=True) if has_explicit_cards else []:
|
|
669
670
|
provider = str(card.get("provider") or "").strip().casefold()
|
|
670
|
-
|
|
671
|
+
api_key = str(card.get("api_key") or "").strip()
|
|
672
|
+
if provider not in {"pexels", "pixabay"} or not api_key:
|
|
671
673
|
continue
|
|
672
|
-
|
|
673
|
-
|
|
674
|
+
attempts.append((provider, api_key))
|
|
675
|
+
|
|
676
|
+
# A selected source is an explicit preference for this task. Its individual
|
|
677
|
+
# keys remain in their configured priority order before other providers.
|
|
678
|
+
preferred = [item for item in attempts if item[0] == route]
|
|
679
|
+
fallback = [item for item in attempts if item[0] != route]
|
|
680
|
+
ordered = preferred + fallback
|
|
681
|
+
if ordered:
|
|
682
|
+
return ordered
|
|
683
|
+
# Legacy settings keep the complete key list in one config field. Let
|
|
684
|
+
# MoneyPrinterTurbo retain its own list handling when no individual cards
|
|
685
|
+
# are available, preserving backwards compatibility for older installs.
|
|
686
|
+
return [(route, "")]
|
|
674
687
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
ordered_providers.insert(0, route)
|
|
680
|
-
elif material_api_keys(settings, route):
|
|
681
|
-
ordered_providers.insert(0, route)
|
|
682
|
-
return ordered_providers or [route]
|
|
688
|
+
|
|
689
|
+
def _material_video_routes(task: dict[str, Any], settings: dict[str, Any]) -> list[str]:
|
|
690
|
+
"""Return provider names for compatibility with diagnostics and callers."""
|
|
691
|
+
return [provider for provider, _api_key in _material_video_attempts(task, settings)]
|
|
683
692
|
|
|
684
693
|
|
|
685
694
|
def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
|
|
@@ -890,6 +899,7 @@ def _run_video_helper_once(
|
|
|
890
899
|
task: dict[str, Any],
|
|
891
900
|
*,
|
|
892
901
|
route_override: str = "",
|
|
902
|
+
api_key_override: str = "",
|
|
893
903
|
settings: dict[str, Any] | None = None,
|
|
894
904
|
) -> Path:
|
|
895
905
|
helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
|
|
@@ -917,6 +927,8 @@ def _run_video_helper_once(
|
|
|
917
927
|
definition = provider_definition(provider)
|
|
918
928
|
route = str(route_override or _normalise_video_route(task, settings)).strip().casefold()
|
|
919
929
|
source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
|
|
930
|
+
if api_key_override and route in {"pexels", "pixabay"}:
|
|
931
|
+
source_keys = [str(api_key_override).strip()]
|
|
920
932
|
if route in {"pexels", "pixabay"} and not source_keys:
|
|
921
933
|
source_label = "Pexels" if route == "pexels" else "Pixabay"
|
|
922
934
|
message = f"Configure pelo menos uma API key de {source_label} em Configurações > Configuração API > Fontes de materiais."
|
|
@@ -1175,31 +1187,31 @@ def _stock_fallback_is_eligible(route: str, error: PipelineError) -> bool:
|
|
|
1175
1187
|
def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
1176
1188
|
"""Run the stock helper with provider fallback in the configured priority order."""
|
|
1177
1189
|
settings = _settings()
|
|
1178
|
-
|
|
1179
|
-
if len(
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
for index, route in enumerate(
|
|
1190
|
+
attempts = _material_video_attempts(task, settings)
|
|
1191
|
+
if len(attempts) <= 1:
|
|
1192
|
+
route, api_key = attempts[0] if attempts else ("", "")
|
|
1193
|
+
return _run_video_helper_once(task, route_override=route, api_key_override=api_key, settings=settings)
|
|
1194
|
+
for index, (route, api_key) in enumerate(attempts):
|
|
1183
1195
|
attempt_task = dict(task)
|
|
1184
1196
|
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
1185
1197
|
attempt_task["material_source"] = route
|
|
1186
1198
|
attempt_task["generation_settings"] = {**generation_settings, "material_source": route}
|
|
1187
1199
|
try:
|
|
1188
|
-
return _run_video_helper_once(attempt_task, route_override=route, settings=settings)
|
|
1200
|
+
return _run_video_helper_once(attempt_task, route_override=route, api_key_override=api_key, settings=settings)
|
|
1189
1201
|
except PipelineStopped:
|
|
1190
1202
|
raise
|
|
1191
1203
|
except PipelineError as exc:
|
|
1192
|
-
if not _stock_fallback_is_eligible(route, exc) or index == len(
|
|
1204
|
+
if not _stock_fallback_is_eligible(route, exc) or index == len(attempts) - 1:
|
|
1193
1205
|
metadata = dict(getattr(exc, "failure_metadata", {}) or {})
|
|
1194
1206
|
metadata["failure_route"] = route
|
|
1195
|
-
metadata["fallback_attempts"] = " → ".join(_provider_api_label(item) for item in
|
|
1207
|
+
metadata["fallback_attempts"] = " → ".join(_provider_api_label(item[0]) for item in attempts[: index + 1])
|
|
1196
1208
|
message = (
|
|
1197
1209
|
f"Falha no provider {_provider_api_label(route)} após tentar "
|
|
1198
1210
|
f"{metadata['fallback_attempts']}. Último erro: {exc}"
|
|
1199
1211
|
)
|
|
1200
1212
|
raise PipelineError(message, failure_metadata=metadata) from exc
|
|
1201
1213
|
|
|
1202
|
-
raise PipelineError("
|
|
1214
|
+
raise PipelineError("Nenhuma chave de provider de vídeo stock configurada.")
|
|
1203
1215
|
|
|
1204
1216
|
|
|
1205
1217
|
def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
|
package/package.json
CHANGED